Getting root on on TP-Link Smart Switches using CVE-2026-1668 - Part 2
In the previous post, I described the details of CVE-2026-1668, and how we’re able to obtain an out-of-bounds write by setting a specific value in the Content-Length header. In this post, we describe how we can use that achieve arbitrary code execution.
HTTP server event loop
It is first helpful to understand the the high-level architecture of the firmware’s HTTP server.
The server is event-driven and can hold state for up to 16 connections simultaneously (though it appears that it can only serve one at a time). The event loop repeatedly loops over all the active connections and services any that are ready.
A simplified summary of the event-loop looks roughly like the following:
- Read all active connection slots and collect socket file descriptors.
- Call
selecton the file descriptors with a 5 second timeout. - Loop over all the active connections slots.
- If the associated socket has activity (as indicated by the results of the
selectcall), service the request. Otherwise, check if the last activity was too long ago, closing the connection if so. - If there was an error servicing the request for whatever reason (including timeouts), call the error notification callback function, and close the connection.
The fields of the connection state-tracking structure relevant to this exploit are:
- Socket file descriptor number
- Unix timestamp of the last activity on this connection
- Enum representing the state of the connection tracking entry (i.e. whether it is active or free)
- Pointer to the error notification callback function
Each connection state-tracking entry also has an associated per-connection memory pool for allocating temporary buffers in the HTTP request handler. The memory pool structure immediately follows the associated state-tracking structure in memory.
Both the memory for the connection state-tracking structure and the per-connection memory pool is pre-allocated at init time.
Hijacking the control flow
The out-of-bounds write happens from within the per-connection memory pool and gives us the ability to overwrite anything in the memory that follows.
The goal here is to overwrite some part of memory that will affect the control flow somehow. The most straightforward way is to look for function pointers, since they let us hijack the control flow to arbitrary locations. Conveniently, the memory that follows the per-connection memory pool contains a connection state-tracking structure, and the most obvious candidate within that structure is the error notification callback function pointer.
We can combine overwriting the function pointer and the last activity timestamp to hijack the control flow. We set the error notification function pointer to the memory location we want to eventually jump to, and the last activity to a timestamp in the past (e.g. 1970-01-01 00:00:00). This represents a timeout condition, causing the error notification function pointer to be called upon the next iteration of the event-loop.
Putting it all together
Interestingly, connection state-tracking entries appear to be allocated in reverse order of memory (that is, older connections use entries at higher memory addresses compared to newer connections). Unfortunately, it is currently unclear to me why this is the case. The code for the event handling loop has been heavily optimized by the compiler, making the logic difficult to follow.
In practice, this does not matter because the placement and the values of the connection state-tracking entries on new connections is highly deterministic (at least on a fresh boot). Thus, I did not feel the need to accurately reason about the event-handling logic for the purposes of making this exploit work.
To set up the target memory in a way that lets us hijack the control flow, we first open a connection to reserve a connection state-tracking entry (connection #1 in the diagram below). Note that this is merely acts as a placeholder and we don’t actually write anything to it.
We then open a second connection (connection #2 in the diagram below), which is allocated the entry immediately before connection #1 in memory, and then write a carefully crafted payload. This overwrites the values in the connection #1 state-tracking structure with values that we need in order to hijack the control flow.
Here is a diagram summarising the memory layout when we perform the exploit (addresses are in ascending order):
...
~ ~
+---------------------+
| Connection State #2 |
| |-> last_activity |
| |-> error_notif_cb |
+---------------------+
| Allocator Metadata |
+---------------------+
| Connection #2 |
| Request Handler | -+ [ Start of out-of-bounds write ]
| Memory Pool | |
+---------------------+ |
| Allocator Metadata | |
+---------------------+ |
| Connection State #1 | |
| |-> last_activity | |
| |-> error_notif_cb | |
+---------------------+ -+ [ End of out-of-bounds write ]
| Allocator Metadata |
+---------------------+
| Connection #1 |
| Request Handler |
| Memory Pool |
+---------------------+
~ ~
...
Once the HTTP handler on connection #2 returns (e.g. through a premature connection closure), the event loop will find that connection #1 has “timed out”, and calls the error notification callback (whose address we just overwrote).
Now that we have control over the program counter, we can redirect execution to any executable region in memory.
But where?
Let’s see if there are any candidate executable memory regions that we can usefully redirect execution to.
We’re able to dump a list of memory mappings and their associated permission bits using GDB. Here is the output with the less interesting parts elided:
(gdb) info proc mappings
process 257
Mapped address spaces:
Start Addr End Addr Size Offset Perms File
0x00400000 0x0047f000 0x7f000 0x0 r-xp /tplink/image/usrImage/app/sbin/httpd
0x0048e000 0x00498000 0xa000 0x7e000 rw-p /tplink/image/usrImage/app/sbin/httpd
0x00498000 0x004d2000 0x3a000 0x0 rwxp [heap]
0x2aaa8000 0x2aaaf000 0x7000 0x0 r-xp /lib/ld-uClibc-0.9.33.so
...
0x2ba19000 0x2c291000 0x878000 0x0 rw-p
...
0x7fa99000 0x7faae000 0x15000 0x0 rwxp [stack]
0x7fff7000 0x7fff8000 0x1000 0x0 r-xp [vdso]
All of the memory associated with HTTP request handling, and connection metadata tracking is located in the 0x2ba19000-0x2c291000 region, which, unfortunately, does not have the execute permission bit set. This is rather unfortunate because it seems like we can’t simply load in shellcode along with the payload, and jump directly to it.
I was considering employing some sort of return-oriented programming to get around this. Or, find a way to get shellcode loaded somewhere predictable in the heap or stack (which for some reason, has execute permissions).
An amusing note on memory permission bits in MIPS
On a hunch, I decided to test what would happen if I did attempt to execute code in a non-executable memory region. Surprisingly, it worked!
My guess, which seemingly turned out to be correct, was that the 4KEc MIPS core on the SoC does not implement the Inhibit Execute bit. The 4KEc MIPS core implements an older revision of the ISA specification without the IX bit.
Amusingly, this means that there is literally no hardware mechanism for enforcing the execute memory permission bit. For all intents and purposes, we can simply ignore it, which greatly simplifies exploitation.
Conclusion
We have now turned an out-of-bounds write vulnerability into a primitive that allows us to gain arbitrary code execution. In the next post, we explore some ways of crafting a useful exploit payload.
Notes
It’s worth mentioning that modern hardening mechanisms would have made exploitation much less straightforward (perhaps even impossible):
- Almost all commonly used modern CPU architectures have support for preventing executing code in non-executable memory regions.
- We would have had to find some other way to obtain arbitrary code execution.
- Modern compilers and operating systems employ address space layout randomization.
- This would have made crafting the payload much more difficult, since we wouldn’t know exactly where our payload was loaded in memory.