In the first post , I opened my Tapo C500, got a root shell, and worked out how its firmware was put together. Having UART and the firmware image made the next question hard to ignore: what happens when the camera receives network input it did not expect?
I found two vulnerabilities that crash the camera’s main process, each through a different network protocol. TP-Link has since published fixes for both:
| Finding | Entry point |
|---|---|
| CVE-2026-104944 | TP-Link Device Protocol (TDP), UDP port 20002 |
| CVE-2026-104945 | ONVIF PTZ, HTTP port 2020 |
I reproduced both on a Tapo C500 v2.0 running firmware 1.3.2 Build 260309 Rel.8833n. The TP-Link advisory lists Tapo C500 V2 firmware 1.3.5 Build 260810 as the fix.
one process, a lot of camera#
The first post was mostly about getting into the camera. This time I followed input from its network services into /bin/main, the large MIPS32 little-endian program that implements most of the camera’s functions. TDP discovery and ONVIF are different doors into that same program.
The Ghidra screenshots and addresses below come from /bin/main in firmware 1.3.2 Build 260309 Rel.8833n.
When main crashes, camera functions go away while the process is down. A separate monitor starts it again. On UART I saw the process exit, then the sensor and network services initialize again. The recordings near the end show that sequence.
door one: TDP discovery#
TDP is TP-Link’s device protocol. The C500 uses it for local discovery and related messages. The listener accepts UDP packets on port 20002. The firmware also sends port 20010 through the same handler, although I only ran the crash test against 20002.
The packet has a 16-byte header with a version, length, flags, and checksum. After its payload text, it can carry an optional extension made of type–length–value entries, usually called TLVs. In plain terms, each entry says “this is type X, here are N bytes of data.” The type chooses a function to handle those bytes.
UDP packet
├── 16-byte TDP header: version, length, flags, checksum
├── payload text (may be empty)
└── optional extension: 12-byte header, then TLVs
└── TLV: 1-byte type | 2-byte length | valuemapping the 16-byte header#
These offsets are counted from the first byte of the UDP payload. Values marked BE are sent in big-endian byte order, even though the camera’s CPU is little-endian.
| Offset | Bytes | Field | Purpose |
|---|---|---|---|
0x00 | 1 | Version | 1 selects the additive checksum; 2 selects CRC-32. |
0x01 | 1 | Reserved | Must be zero. |
0x02–0x03 | 2 | Opcode, BE | Selects the TDP operation. |
0x04–0x05 | 2 | Payload length, BE | Declared payload size; header plus this length is checked against the 4,096-byte receive limit. |
0x06 | 1 | Flags | Controls which packet path the handler takes. |
0x07 | 1 | Result | Status byte used by the protocol. |
0x08–0x0B | 4 | Serial number | Packet serial field. |
0x0C–0x0F | 4 | Checksum, BE | Integrity value for the declared header and payload. |
The payload begins at offset 0x10. If extra bytes follow its NUL terminator, the parser can treat them as an extension. The extension starts at that NUL byte; its 12-byte header is mapped below. These offsets are relative to the extension start, wherever the payload text ends.
| Extension offset | Bytes | Field | Purpose |
|---|---|---|---|
0x00 | 1 | Payload NUL | Ends the preceding payload text. |
0x01–0x02 | 2 | Magic | Must be 07 E7. |
0x03 | 1 | Version | Must be 01. |
0x04–0x05 | 2 | Unused | Not checked on this path. |
0x06 | 1 | Reply delay | Affects replies, not TLV parsing. |
0x07 | 1 | Flags | Affects replies, not TLV parsing. |
0x08–0x09 | 2 | Unused | Not read by the parser. |
0x0A–0x0B | 2 | Extension length, BE | Checked against the remaining datagram bytes. |
The TLVs begin 12 bytes after the extension start. Each has a 3-byte lead-in followed by its value:
| TLV field | Bytes | Meaning |
|---|---|---|
| Type | 1 | Handler-table index. |
| Length, BE | 2 | Number of value bytes. |
| Value | N | Data passed to the selected handler. |
For a packet with this extension, the UDP data size is 16 + P + 12 + Σ(3 + N) bytes: 16 for the TDP header, P payload-text bytes before the NUL, 12 for the extension header, and 3 plus N for each TLV. The extension-length field is checked against the available bytes, but the TLV loop itself walks to the end of the received datagram.
The dangerous field is that one-byte type, not the packet length. The packet can have valid lengths and still select a handler index that does not exist.
Ghidra shows the header checks before the extension parser: the packet must have enough bytes, a supported version and flags, and a valid checksum. The checksum covers the declared header and payload, but any sender can compute it. It uses no secret, so it does not prove who sent the packet. There is no login check on this path.
The packet has to pass these format checks before the extension is parsed. None authenticates the sender.
the handler table ends before the accepted types do#
The TLV parser accepts type values from 1 through 24 (0x18). It then uses the type as an index into a function-pointer table. Here is the important part, shortened from the decompilation:
if (type != 0 && type <= 24) {
if (handlers[type] != NULL)
handlers[type](value, length);
}The problem is simple once the table is visible: it has six slots total. Slot 0 is empty; slots 1–5 hold real handlers. There is no slot 6, let alone slot 24. Checking that a pointer is non-null does not help when the read is already past the table.
list_type is checked against 24, then used directly as the table index.
I checked the assembly too. It multiplies the type by four, reads a word from the calculated table address, and makes an indirect call through that word. There is no later check against the real table length.
The MIPS instructions lead from the too-wide bound to jalr t9, the indirect call.
Right after slot 5, the firmware stores a function-name string. So type 6, the first out-of-range index, reads the first four bytes of tdpd_check_bitmap_handler as though they were a function address. On this little-endian camera, "tdpd" becomes 0x64706474. That is text being used as a pointer, not a valid table entry.
The table ends at slot 5. Reading slot 6 instead picks up the first bytes of the name string below it.
One valid-format, 35-byte packet with type 6 sent to UDP 20002 was enough. UART printed signal SIGILL caught, followed by the exit and restart of main.
There is one timing wrinkle. TDP skips extension parsing for about 60 seconds after the camera gets or changes its IP address, including after main restarts. Once that window passes, the same packet can crash it again. Repeated packets can therefore cause repeated outages, with time for the camera to recover between them.
door two: ONVIF PTZ#
ONVIF gives third-party recorders and other software a common way to work with a camera. On this C500, a client sends an HTTP request to port 2020 containing a SOAP message: an XML envelope whose body names the action it wants. The PTZ part covers pan, tilt, and zoom. It also has actions for saved camera positions, called presets. A PresetToken identifies one of those positions; RemovePreset asks the camera to delete it.
This door has a lock. ONVIF must first be enabled in the Tapo app, and the request needs valid camera-account credentials in a WS-Security UsernameToken. That account is for third-party access to the camera; it is separate from the TP-Link cloud login. The bug is therefore authenticated, even though TDP is not.
To find where the request went, I followed the PTZ action names in /bin/main with Ghidra. The ONVIF front end reads the SOAP envelope, matches the action in its body, and calls the matching handler. From the RemovePreset entry, I followed the handler into the code that reads each XML tag. A second action, ModifyPresetTour, led to the same kind of parsing loop. Both loops put values from the request into arrays on the stack, so I checked how many values each array could hold and what stopped the loop. Then I tested the reachable path on the camera while watching UART.
repeated tokens, fixed stack space#
In RemovePreset, the XML-to-JSON handler reads each <PresetToken> value and stores the parsed integer in a local array with room for 32 values. Its loop stops when it reaches the end of the request, not when the array is full.
long presets[32];
int count = 0;
/* for each PresetToken element in the SOAP body */
presets[count++] = parsed_token; /* no count < 32 check */That short sketch leaves out the XML parser, but it captures the mistake. The input controls how many stores happen. The destination does not grow with it.
The loop watches the XML cursor and request length. There is no 32-element limit.
The store uses presets + n_ids, then increments n_ids without checking the array capacity.
Why did it crash there? I mapped the stack frame in Ghidra. Array slots 0–31 are valid. More values overwrite other local space. The 69th token (index 68) reaches the parser’s own cursor, which tells it where to read the next XML byte. On the next pass, the parser uses that overwritten value as a pointer.
I tried a control value first. With 1 in that position, the handler returned a SOAP fault and main stayed alive. The out-of-bounds write still happened; the parser simply rejected the new cursor before it was dereferenced. With -1, the parsed 32-bit value became 0xffffffff. The cursor checks mix unsigned and signed comparisons, and this value passed them. The next byte read faulted; UART reported SIGBUS and main exited.
The checks reject the control value 1, but not the overwritten cursor value 0xffffffff; the next byte-reader call uses it.
The SOAP body that caused this was 3,590 bytes. It fits under the 1.3.2 front end’s 4,096-byte body limit found in the binary. The video shows a dropped HTTP connection and SIGBUS on UART; the availability check measures a 13.3-second outage before the service returns. An HTTP 500 by itself would not prove a crash; that is why the control request and the UART trace matter.
what about ModifyPresetTour?#
Ghidra shows the same missing array bounds in ModifyPresetTour, at several write sites. The handler first checks tour_supported; the stock C500 v2.0 setting is 0, so the action returns ActionNotSupported before reaching the parser. The PresetToken input needed to overwrite its cursor would also exceed the 4,096-byte body limit in the 1.3.2 front end.
The stock unit’s tour-capability check blocks this action before the parser. RemovePreset has no such gate.
The crash path here is RemovePreset. ModifyPresetTour has related unchecked writes, but the stock C500 v2.0 blocks that action at its capability check.
watching both faults on UART#
I kept the complete PoC scripts out of this post. They have separate repositories: CVE-2026-104944 (TDP) and CVE-2026-104945 (ONVIF) . The videos below show each test and its UART output.
TDP: SIGILL#
Video transcript
The terminal sends the TDP test to the camera. UART printssignal SIGILL caught, then the main process exits and begins starting again.ONVIF: SIGBUS#
Video transcript
The terminal runs the authenticatedRemovePreset test. UART prints signal SIGBUS caught and shows the main service exit. The availability check first reports the camera down at about 1 second, then up at about 14.3 seconds, measuring a 13.3-second interruption.the fix#
TP-Link’s security advisory says both issues are fixed in Tapo C500 V2 firmware 1.3.5 Build 260810. Use the update offered for your camera’s exact hardware version and region, then check its installed firmware version. The public records rate TDP at 7.1 (High) and ONVIF at 6.8 (Medium) under CVSS 4.0.
One parser checked the wrong limit. The other kept writing after its array was full. Small mistakes, but enough to take main down. UART earned its keep.