This blog delves into a vulnerability that was reported to TP-Link in 2020. Despite this, no CVE was assigned, therefore no information regarding the bug has been made public. Reading write-ups often offers valuable insights and learning opportunities. I strongly believe that sharing methodologies and research publicly benefits both the industry and all 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.
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
.
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
.
Below is the TddpPktInterfaceFunction
function.
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.
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.
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.
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.
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.
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!