Omada - Adventures in IoT Reverse Engineering

Background

IoT security has long been of interest to hackers looking for low-hanging fruit (as the saying goes, the "S" in "IoT" stands for Security), however there are significant barriers to entry that must be scaled before we can get our hands on all those juicy findings. The fact that these are typically proprietary firmware systems running on embedded devices, and that the firmware and devices themselves are often built using complicated supply chains involving many third parties, means that hardware hacking and reversing has always entailed a significant lift and very specific knowledge (not to mention lab setups).

It's no secret that the emerging capabilities of LLM tooling has flattened the barriers to entry for almost any task involving the use of a computer, and lately there has been much attention paid to using AI to reverse-engineer everything from old Nintendo games to entire hardware platforms such as the Playstation 5. I've never been on the cutting edge of IoT security research, but I do understand security bugs, as well as the end-to-end flow of hardware hacking at a high level, so why not try applying that knowledge with the help of a little AI magic? In theory, I should be able to guide the AI tooling towards (what I consider to be) relevant security issues, while being afforded the privilege of skipping the hours of in-depth tool research and trial-and-error that goes along with traditional approaches to reversing IoT software and firmware.

This approach has one major and obvious drawback right away: the hours of research into tooling and the trial-and-error of developing manual workflows leads to a deeper understanding of the attack surfaces as well as more novel findings. This maxim has held true for my main area of professional expertise (web application and network security) and although I may be decried as a luddite for saying so, I fully believe that AI is incapable of the truly creative solutions that underpin "serious" hacking. Still, I don't really need to re-invent the wheel here, right? I only want to find well-documented, existent patterns in unexplored software. The novelties can come later.

The Lab Setup

I toyed with the idea of using a dedicated Ubuntu LTS VM to conduct this research, but instead decided to go with a bone-stock Kali install since I figured it would be nice to have access to all of Kali's pre-bundled security tools. I installed firmware analysis toolkit (FAT) to help with emulation, as well as morluto's REA which is a collection of skills and MCPs that integrate nicely with Claude (my LLM of choice right now).

Kali gives most everything else you need to conduct reverse engineering out-of-the-box. I run it in a VirtualBox VM with 8G of RAM and 4 dedicated cores, which seems to be plenty for most applications. I connect via SSH with X server enabled for GUI passthrough. There's nothing fancy about my setup, and after years of finagling with complicated hybrid-cloud solutions, I like it that way.

Analyzing the Omada Firmware

A lesson in reachability

From the Omada website:

TP-Link Omada is a smarter end-to-end cloud solution dedicated for business networking and integrating gateways, switches, access points, and more into a full Software Defined Networking (SDN) solution. TP-Link Omada provides 100% centralized cloud management to create a highly scalable network—all controlled from a single interface, anywhere, anytime.

It also happens that many of the Omada device firmwares are publicly available.

Getting inside

The target was the publicly downloadable firmware for a TP-Link Omada/VIGI 12MP IP camera (1.0.3 Build 260801, Novatek NT98539A, Cortex-A53, Linux 5.10.168). The image unpacks conventionally until you hit the root filesystem, which is encrypted: each 512-byte sector is XOR-obfuscated, and the squashfs superblock is additionally wrapped in AES-128-CFB-1. The key (TP_LINK88i667gnt) and the XOR schedule both ship inside the file you just downloaded, so anyone willing to read the loader gets the filesystem. This oversight applies to other protections, as we will see shortly.

Inside sits /bin/main: 5.8 MB, fully stripped, written in plain C with no C++ and no scripting layer. One process is the whole camera, running an in-house framework with 158 registered configuration namespaces and about 220 constructors. Binaries that size are miserable to navigate stripped, but this one logs every function's own name through a debug helper via __FUNCTION__. Pairing .eh_frame function boundaries against those logging call sites recovered 3,642 function names automatically, which turned the blob into something close to a symbolized build. It is the most reusable thing I took away from the exercise: any vendor binary with __FUNCTION__-style logging has the same tell.

Following the shell

With names in hand, the fastest route to interesting code is the shell-exec choke points. Two functions wrap popen, and auditing their callers instead of all 47 raw call sites pointed at execute_mt_tlv_data, a function in a mactool module that assembles a shell command out of a parsed TLV message. Factory provisioning protocols are classically LAN-reachable with weak authentication, so this looked like a promising lead.

Tracing it backwards showed it is not reached through the camera's main JSON API at all. It hangs off a second network listener the process opens itself:

flowchart TD
    A["TCP *:20002 — opened only while<br/>factory_test_mode != 0"] --> B["TLS: self-signed cert<br/>regenerated each boot, no CA check"]
    B --> C["TMP association handshake<br/>unauthenticated"]
    C --> D["16-byte header<br/>integrity = additive 32-bit sum"]
    D --> E["payload[0] = app id<br/>20-slot dispatch table"]
    E -->|0x02| F["mactool_handle"]
    E -->|0x10| G["script_tool_handle"]
    F --> H["verify_auth_key<br/>MD5 'senior key'"]
    H -->|pass| I["execute_mt_tlv_data"]
    I --> J["get_tlv_data:<br/>memcpy with no bound"]
    I --> K["snprintf into<br/>'iwpriv ... %s'"]
    J --> L["stack overflow<br/>3072 bytes to saved LR"]
    K --> M["popen, running as root"]

The sink is worse than the lead suggested. get_tlv_data is, in its entirety, memcpy(out, tlv+4, get_tlv_length(tlv)), with no destination size. The length is an attacker-controlled 16-bit field, the destination is a stack buffer 3,072 bytes below the caller's saved link register, and the one length cap that exists upstream is never passed down. Neither function in the chain emits a stack canary, the binary is non-PIE at a fixed base with a writable GOT, and the crash handler catches SIGSEGV, prints a line, and calls exit(0), after which a watchdog restarts the process at identical addresses. Unlimited retries at known addresses with no canary is about as forgiving as exploitation environments get. Separately, one TLV case interpolates 40 arbitrary bytes into an iwpriv command line guarded by nothing but a strlen == 40 check. This is getting interesting.

Who can actually reach it

Then came the reachability work, which inverted the conclusion. Two gates stand in front of that code.

The first is the listener itself, which only exists while a configuration node called factory_test_mode is set. The second is the protocol's own "senior key", which turns out to be MD5(securityEncode(root_password)). securityEncode is TP-Link's long-published obfuscation routine, with both constants sitting in .text, and the stored password is recovered by RSA-decrypting it with a 1024-bit private key shipped unencrypted in the rootfs (doh!), so any configuration backup or flash dump yields the plaintext admin password offline. It also means the key is the admin password rather than a shared factory secret, so the protocol is authenticated after all.

That left one question: who can flip factory_test_mode? The central API dispatcher every remote request converges on does no authentication or authorization at all. It validates the method number, checks the target modules have started, and dispatches. All the access control lives in the front ends:

flowchart TD
    R["Remote request"] --> S{"action in 22-entry<br/>pre-auth allowlist?"}
    S -->|yes| DS["dispatcher — no authorization"]
    S -->|no| T{"valid 32-byte session token,<br/>bound to client IP?"}
    T -->|no| X1["401"]
    T -->|yes| U{"session level"}
    U -->|"0 — admin"| DS
    U -->|"1 or 2"| V{"module present in<br/>148-entry ACL table?"}
    V -->|no| X2["403 — permission denied"]
    V -->|yes| W{"required mask AND<br/>session rights"}
    W -->|zero| X2
    W -->|non-zero| DS

factory_test_mode is reachable two ways, a set on the factory_info namespace and a do action named set_factory_mode, and neither appears anywhere in that 148-entry ACL, so restricted accounts fail closed. Admin accounts skip the ACL entirely. So the whole chain is admin-only, and further conditioned on the device not yet being in initialized state. An attacker who already holds admin on the camera gains very little from a root shell they could mostly reach through firmware upload anyway.

What's left

Two residues are more interesting than the memory-corruption bugs. The secondary openapi interface reaches the dispatcher with a hardcoded full-privilege mask and never calls the ACL check at all. That is harmless today only because its login hardcodes the username to admin and accepts nothing but the root password, but it becomes an immediate privilege escalation the moment anyone adds multi-user support there. The session tokens on both HTTP interfaces are derived from glibc's random(), whose state is seeded with clock_gettime nanoseconds and then disclosed to unauthenticated clients as a 32-hex-character challenge nonce: 32 consecutive outputs of a generator with roughly 2^30 possible seeds. The password login path then generates its token from the continuation of that same stream without reseeding. Whether that is practically exploitable depends on accounting for every other random() consumer in the process, which is where I stopped.

Conclusions

None of this work was all that novel, however one thing became immediately apparent as soon as I started undertaking it: moving at machine speed for reverse engineering is incredibly satisfying. Tasks that would have taken me days of scripting, reading, validating, and tweaking were condensed to mere minutes. For the most part, REA did a fantastic job of directing Claude in its tool calls, while I took the approach of a principal investigator and guided the work at a high level. I also made this process take much longer than it could have, because I prompted my agents along the way almost constantly with clarifying questions to make sure I understood everything that was happening, and to keep them on a short leash (Claude in particular is always eager to burn tokens going down rabbit holes).

Another big thing to note was that this work was entirely static analysis. No emulators were used and no binaries were ever run or debugged live. This is a big deal, because it shows the power of inference in doing what computers are already good at: crunching huge amounts of data and condensing that information. Obviously, if a potential exploitation path had been found, I would have had to emulate in order to develop and validate a working exploit, but I was still impressed by the amount of information thorough static analysis can provide.

This work showed enough promise that I intend to turn these posts into a series. I already have some ideas of IoT devices which I would like to look into next.