逆向工程 UniFi inform 协议
Reverse-engineering the UniFi inform protocol

原始链接: https://tamarack.cloud/blog/reverse-engineering-unifi-inform-protocol

## UniFi 控制器托管:多租户解决方案 作者之前运营过 UniFi 托管服务,为 MSP 和 IT 公司提供托管云控制器。虽然存在需求——客户希望避免硬件和维护——但由于专用 VPS 实例的成本(每月 4-6 美元)导致利润微薄(每月 1-2 美元)且承担所有支持成本,导致业务难以为继。 可行性的关键在于多租户,但 UniFi 控制器被设计为隔离实例。挑战集中在“inform”协议(8080 端口)上,UniFi 设备通过 AES-128-CBC 加密数据包向控制器报告数据。最初,似乎需要每个设备的密钥才能解密这些数据包,从而抵消了共享基础设施的好处。 然而,分析表明每个 inform 数据包的前 14 个字节包含未加密的设备 MAC 地址。这允许代理识别设备并将整个数据包(包括加密负载)路由到正确的租户控制器实例,而无需解密。 这个简单的解决方案,用大约 200 行 Go 代码实现,通过共享基础设施同时为每个客户维护专用控制器环境,从而实现了盈利的托管模式。其他端口可以轻松使用标准技术处理,使得 inform 协议成为克服的主要障碍。

一个Hacker News的讨论围绕着对UniFi inform协议的反向工程项目,详情见tamarack.cloud。最初的帖子引发了关于缩写词“TNBU”是否是“UNBT”(UniFi Broadcast Technology)的反向拼写这一看似错误的假设的争论。 评论者指出,更合乎逻辑且具有历史准确性的反转是“UBNT”,Ubiquiti Networks的默认登录名,可以追溯到至少2010年,甚至在UniFi品牌出现*之前*。原发帖人(OP)承认了错误并承诺更新他们的发现。这次对话突显了一个大型技术项目中一个小的细节,展示了Hacker News社区的协作性和注重细节的特点。
相关文章

原文

A few years ago I ran a small UniFi hosting service. Managed cloud controllers for MSPs and IT shops who didn't want to run their own. Every customer got their own VPS running a dedicated controller.

The product worked. People wanted hosted controllers, mostly so they didn't have to deal with hardware, port forwarding, backups. The problem was the economics.

Each customer needed their own VPS. DigitalOcean droplets ran $4-6/month. I was charging $7-8. That's $1-2 of margin per customer, and any support request at all wiped it out. I was essentially volunteering.

The obvious fix is multi-tenancy: put multiple controllers on shared infrastructure instead of giving every customer their own VM. But UniFi controllers aren't multi-tenant. Each one is its own isolated instance with its own database and port bindings. You need a routing layer, something in front that can look at incoming traffic and figure out which customer it belongs to.

For the web UI on port 8443, that's easy. Subdomain per customer behind a reverse proxy, nothing special. But the inform protocol on port 8080 is where things get interesting.

What inform does

Every UniFi device (access points, switches, gateways) phones home to its controller. An HTTP POST to port 8080 every 10 seconds. This is how the controller keeps track of everything: device stats, config sync, firmware versions, client counts.

The payload is AES-128-CBC encrypted. So I assumed you'd need per-device encryption keys to do anything useful with the traffic, which would mean you'd need the controller's database, which would mean you're back to one instance per customer.

Then I looked at the raw bytes.

The packet

The first 40 bytes of every inform packet are unencrypted:

Offset  Size   Field
──────  ─────  ──────────────────────────
0       4B     Magic: "TNBU" (0x544E4255)
4       4B     Packet version (currently 0)
8       6B     Device MAC address
14      2B     Flags (encrypted, compressed, etc.)
16      2B     AES IV length
18      16B    AES IV
34      4B     Data version
38      4B     Payload length
42+     var    Encrypted payload (AES-128-CBC)

Byte offset 8 is the device's MAC address, completely unencrypted.

On the wire it looks like this:

54 4E 42 55    # Magic: "TNBU"
00 00 00 00    # Version: 0
FC EC DA A1    # MAC: fc:ec:da:a1:b2:c3
B2 C3
01 00          # Flags
...

"TNBU" is just "UBNT" backwards, Ubiquiti's ticker symbol and the default SSH credentials on their devices.

The MAC is in the header because the controller needs to identify the device before decrypting. Encryption keys are per-device, assigned during adoption, so the controller has to know which device is talking before it can look up the right key. Not a security oversight, just a practical requirement. But it means you can route inform traffic without touching the encryption at all.

Reading the MAC

Extracting it is almost nothing:

header := make([]byte, 40)
if _, err := io.ReadFull(conn, header); err != nil {
    return err
}
 
if string(header[0:4]) != "TNBU" {
    return fmt.Errorf("not an inform packet")
}
 
mac := fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x",
    header[8], header[9], header[10],
    header[11], header[12], header[13])

Read 14 bytes and you know which device is talking. No decryption needed.

Building the proxy

With the MAC in hand, routing is simple. Keep a table of which MAC belongs to which tenant, forward the whole packet (header and encrypted payload, untouched) to the right backend.

Device (MAC: aa:bb:cc:dd:ee:ff)
      |
      v
+-----------------------------------+
|                                   |
|  Inform Proxy                     |
|                                   |
|  Read MAC from bytes 8-13         |
|                                   |
|  Lookup:                          |
|    aa:bb:cc:... -> tenant-7       |
|    11:22:33:... -> tenant-3       |
|    fe:dc:ba:... -> tenant-12      |
|                                   |
|  Forward to correct backend       |
|                                   |
+-----------------------------------+
      |           |           |
      v           v           v
   Tenant 7   Tenant 3   Tenant 12

The whole proxy is maybe 200 lines of Go with an in-memory MAC-to-tenant lookup table.

In practice, the proxy is mostly a fallback. Once a device is adopted, you point it at its tenant's subdomain (set-inform http://acme.tamarack.cloud:8080/inform) and after that, standard Host header routing handles it through normal ingress. The MAC-based routing catches edge cases like devices that haven't been reconfigured yet, or factory-reset devices re-adopting.

The other ports

Inform is the hard one. The rest of the controller's ports are more straightforward:

PortProtocolPurpose
8080TCP/HTTPInform (device phone-home)
8443TCP/HTTPSWeb UI and API
3478UDPSTUN
6789TCPSpeed test (internal)
27117TCPMongoDB (internal)
10001UDPL2 discovery (local only)

Once I figured out inform, the rest was almost anticlimactic. 8443 is the web UI, so that's just subdomain-per-tenant with standard HTTPS ingress. 3478 (STUN) is stateless so a single shared coturn instance covers every tenant. The rest are either internal to the container or L2-only, so they never leave the host.

Inside the encrypted payload

For the curious: the payload after byte 42 is AES-128-CBC. Freshly adopted devices use a default key (ba86f2bbe107c7c57eb5f2690775c712) which is publicly documented by Ubiquiti and ships in the controller source code. After adoption, the controller assigns a unique per-device key.

The decrypted payload contains device stats and configuration data. Interesting if you're building controller software, but irrelevant for routing.

So what does this get you

Every tenant still gets their own dedicated controller, but you're not paying for a whole VM per customer anymore. What was a volunteering operation at $1-2 margin becomes something you can actually make money on.

None of it works if the MAC is inside the encrypted payload. You'd need per-device keys at the proxy layer, which means you'd need access to every controller's database, which puts you right back at one instance per customer. Six plaintext bytes in a packet header make the whole thing possible.

I don't think Ubiquiti designed it this way for third parties to build on. The MAC is there because the controller genuinely needs it before decryption. But the happy side effect is that the inform protocol is routable by anyone who can read 14 bytes off a TCP connection.

If you've poked at the inform protocol yourself, I'd like to hear about it. [email protected]

联系我们 contact @ memedata.com