Decrypting Ubiquiti SuperLink
Decrypting Ubiquiti SuperLink
After spending time with Lutron's CCA and CCX protocols, I wanted something different. Ubiquiti's SuperLink caught my eye — a proprietary LoRa-based protocol for their UniFi Protect security sensors, running on 915 MHz.
This time I tried an experiment: push Claude as far as possible before buying hardware. Work from FCC filings, firmware dumps, and static analysis. See how far you can get without a single RF capture. We got pretty far, but not 100%.

What is SuperLink
SuperLink is Ubiquiti's wireless protocol for security sensors: door/window contacts, motion detectors, that sort of thing. It operates in the 902–928 MHz ISM band in the US, using Semtech LoRa modulation with a completely proprietary MAC layer on top. Not LoRaWAN — this is Ubiquiti's own protocol, sharing only the physical-layer chirp spread spectrum modulation.
The gateway side uses a Semtech SX1302 multi-channel baseband processor that can receive on all 8 uplink channels simultaneously. Sensors use an SX1262 single-channel transceiver with a Skyworks SKY66420-11 front-end module for amplification. The architecture is a simple star topology — sensors talk to the gateway, gateway talks to the UniFi controller.
Phase 1: Firmware without hardware
UniFi Protect firmware links aren't published the way their Network products are. I found a script on GitHub that enumerates firmware URLs by SKU, and used it to pull the UP-Sense-Link gateway image. Standard binwalk extraction gave me the filesystem, and buried in /usr/bin/ was lorabrd — the LoRa bridge daemon responsible for everything SuperLink.
I pointed Claude at the binary with --dangerously-skip-permissions and let it loose in Ghidra. This is the "giving a monkey a machine gun" phase: the agent has full access to the decompiler and can iterate on type annotations, function signatures, and cross-references much faster than I can click through the Ghidra UI.
The hardcoded pairing key
The first real find was the default encryption key. Deep in .rodata, two 32-byte constants sit at fixed offsets. The firmware adds them byte-wise to produce the initial pairing key:
47be3dffb41ea35749c9290e6d2124e6b3e3842ab4e443bd0ac41eda045c2dbe
This key is only used during initial device pairing — it encrypts the key exchange that establishes per-device session keys. After pairing, the controller can push a custom default key via setDevsDefaultKey, but factory-fresh sensors all ship with this one.
The crypto stack
Working through the imports and cross-references, Claude mapped out the full cryptographic stack. SuperLink uses libsodium throughout:
- Key exchange: Curve25519 ECDH
- Session key derivation: BLAKE2b hash of the shared secret, both public keys, and additional context
- Management frames: XSalsa20-Poly1305 authenticated encryption
- Data frames: XSalsa20 stream cipher (no authentication tag — presumably to save bytes on frequent sensor reports)
- Integrity check: BLAKE2b truncated to 4 bytes, covering the header and payload
This is genuinely good cryptography. The only real weakness is that factory-default sensors can be paired by an imposter gateway using the hardcoded key. Once a sensor has been adopted by a real controller and re-keyed, passive decryption is infeasible — the session keys are ephemeral Curve25519 derivatives.
Protocol structure from RTTI
C++ binaries are a gift to reverse engineers. The RTTI symbols in lorabrd reveal the entire class hierarchy:
ubnt::lorapack::phypayload::{Header, PlainHeader, SecureHeader}
ubnt::lorapack::connection::{ConnectionReq, ConnectionRsp, ChallengeReq, ChallengeRsp}
ubnt::lorapack::management::{KeyRenewReq, KeyRenewRsp, SwitchClassA/B/CReq}
From this, and cross-referencing function bodies, the connection sequence became clear: Beacon → Discovery → ConnectionReq/Rsp → ChallengeReq/Rsp (authenticated with the default key) → Curve25519 DH exchange → ChMap (channel assignment) → encrypted data flow.
Channel plan
Also from the binary: 8 uplink channels on 125 kHz bandwidth from 915.6 to 917.0 MHz, each paired with a downlink channel on 500 kHz bandwidth from 920.4 to 924.6 MHz. Plus a beacon channel at 927.6 MHz on 500 kHz. The LoRa parameters are SF5, coding rate 4/5, sync word 0x1424, 12-symbol preamble.
SF5 is unusual. Standard LoRaWAN uses SF7 through SF12 — lower spreading factors mean faster transmission but shorter range. SF5 is actually below the LoRaWAN minimum, which tells you something about Ubiquiti's priorities: they're optimizing for latency over range. When your door sensor needs sub-second acknowledgment, you don't want to be spending 200ms on a single frame.
Phase 2: Confirming with hardware
Static analysis got me the protocol structure, crypto primitives, channel plan, and pairing key. But I couldn't confirm any of it without live traffic. So I bought a USL-Gateway and a USL-Entry door sensor.
The sniffer
I built a packet sniffer on a Heltec LoRa 32 V3 (ESP32-S3 + SX1262) using the RadioLib library. It hops through all 8 uplink channels, parking on each for long enough to catch a frame. The OLED shows RSSI, SNR, packet count, and the active MAC address. Serial commands let me lock it to a single channel for focused capture.
The limitation of a single-channel sniffer is obvious: the SX1302 in the gateway receives all 8 channels simultaneously, but my SX1262 can only listen to one at a time. In scanning mode I catch roughly 1 in 8 packets. Enough to confirm the protocol, not enough for reliable real-time monitoring. I found a great deal on some real SX1302 dev kits (including RPI4s!) on eBay and when those arrive I'll properly explore.
What the air looks like
The first captures confirmed the frame format from static analysis almost exactly. A typical uplink data frame is 19 bytes:
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | Mctrl (0xE0 — SecureHeader) |
| 1 | 1 | Dctrl (0x54 — uplink data) |
| 2 | 6 | Sensor MAC address |
| 8 | 1 | SeqHi (frame counter) |
| 9 | 1 | SeqLo (nonce component) |
| 10 | 4 | BLAKE2b integrity check |
| 14 | 5 | Encrypted payload |
Downlink responses are shorter — 16 bytes with a 2-byte payload. The gateway always addresses the sensor's MAC, even in the downlink direction.
Channel hopping is sequential: the sensor walks CH1 through CH8, spending about 2 seconds on each channel, with the full cycle taking around 16 seconds. No clock synchronization, no TDMA — just a round-robin walk.
Confirming the nonce
The one thing I couldn't verify from firmware alone was the nonce construction. I needed to see actual encrypted traffic, decrypt it, and confirm the nonce matched what the decompiled code suggested.
I SSH'd into the gateway (Ubiquiti exposes SSH access via the Protect API if you know to ask for it) and used an LD_PRELOAD hook on lorabrd to intercept libsodium calls. This gave me the session keys and nonce values the gateway was actually using.
The 24-byte XSalsa20 nonce is built deterministically from the packet header: Mctrl, Dctrl, the full 6-byte MAC address, the sequence counter, and zero padding. For a frame with mctrl=0xE0, dctrl=0x62, the nonce starts with e062 followed by the MAC and sequence bytes. No nonce field needed in the frame because the receiver can reconstruct it from the header.
Decrypted sensor data
With the session key from the LD_PRELOAD hook, I wrote a Python decoder that processes the sniffer's serial output in real time. A standard door sensor report decrypts to 5 bytes:
0C 00 0F 00 [00|01]
Type 0x0C (sensor report), command 0x0F (door state), and the last byte is 0x00 for open or 0x01 for closed. Periodically the sensor sends an extended 22-byte report with presumably with battery percentage, what looks like a temperature reading (?), uptime counter, and tamper status.
The security model
SuperLink's security is solid. The cryptographic primitives are modern and well-chosen — Curve25519, XSalsa20, BLAKE2b are all top-tier. The session key derivation is sound. The main attack surface is the hardcoded factory pairing key, which is an inherent bootstrapping problem: the sensor has to trust something before it has a relationship with a specific gateway.
| Attack | Feasible? |
|---|---|
| Passive sniffing (metadata) | Yes — MACs, timing, signal strength are in the clear |
| Passive sniffing (data) | No — ephemeral session keys |
| Gateway impersonation | Only for factory-default sensors |
| MITM on existing session | No — DH exchange authenticated via pre-shared key |
| Brute force session key | No — 256-bit Curve25519 |
If you want to decrypt live traffic, you need to either sniff the initial pairing key exchange or extract the session key from the gateway. The LD_PRELOAD approach works but requires SSH access to the gateway, which requires controller access. At that point you already own the system.
What I learned about the process
The firmware-first approach worked better than I expected. About 85% of the protocol was recoverable from static analysis alone — frame format, crypto algorithms, channel plan, pairing sequence, default key. The remaining 15% required live hardware: confirming the nonce construction, verifying the channel hopping pattern, and decoding the actual sensor payload format.
Claude's ability to iterate on Ghidra decompilation output was the force multiplier. Renaming variables, annotating structures, following cross-references, testing hypotheses against the disassembly — this is exactly the kind of tedious-but-mechanical work that an agent can do faster than a human clicking through a GUI. The agent didn't have flashes of insight about the protocol design, but it could grind through decompiled C++ at a pace that would have taken me weeks.
The code is available at github.com/alxgmpr/superlink.