- initial analysis
- testing setup
- crafting payloads
- escaping constraints: arbitrary code execution
As I’ve mentioned in previous posts, I’ve been hunting for bugs on the Netgear Orbi for about a year and half. A few weeks ago, I came across an advisory for an unauthenticated command injection vulnerability that was reported to Netgear back in December 2020 and realized that the vulnerable firmware version was the same one that was installed on my device back when I first started looking for bugs on it. In fact, the issue had only been fixed about a month before then. If only I’d started looking just a little bit sooner! Since it was way too late for that, I thought it’d be fun to see if I could find where the vulnerability was located and write a functional exploit to gain full control of the device.
There were no known exploits for this vulnerability at the time I started looking and the only useful information came from the CVE entry on Mitre:
This vulnerability allows network-adjacent attackers to execute arbitrary code on affected installations of NETGEAR Orbi 126.96.36.199 routers. Authentication is not required to exploit this vulnerability. The specific flaw exists within the UA_Parser utility. A crafted Host Name option in a DHCP request can trigger execution of a system call composed from a user-supplied string. An attacker can leverage this vulnerability to execute code in the context of root. Was ZDI-CAN-11076.
Even though it’s not much, this provided enough information to narrow things down to a specific binary and a specific input path. It would mostly come down to finding the bug and working up from there to trace the input back to the source.
finding the vulnerability
With the info taken from the advisory, I moved on to analyzing the vulnerable version of
UA_Parser to try to find where the vulnerability occurred. Since I knew this was command injection, the most likely suspect was insecure use of
system() to execute commands. I loaded the binary up in Ghidra and used the symbol table to select
system() and then used the Function Call Tree to check the functions that had incoming references to it. While doing this, I came across the following code snippet in one of these functions (annotated for clarity):
This code stood out as a good candidate for command injection given that the argument passed to
system() is a string constructed with what looked like user-controlled data. Additionally, the values are placed inside double quotes, which would allow for expansion.
I then took a look at the function I labeled
get_host_from_file() at line 78 in the image above and learned that this function eventually reads from a file at
/tmp/netscan/attach_device , which contains entries for each client connected to the router, including MAC, IP, and hostname. It parses out the hostname it finds and fills the static buffer
hostname which is passed as an argument to
get_host_from_file(). This value is eventually passed as the 4th format string arg to
snprintf() on line 84, which constructs the string that is passed to
At this point I felt pretty confident this was where the vulnerability occurred, but to get additional confirmation I took a look at the version of
UA_Parser included with a firmware that was released after this bug was fixed (v188.8.131.52) to compare. The only thing that changed between the two snippets is the double-quotes that surround the user-controlled values were replaced with single-quotes (also known as ‘strong-quotes’), where no expansion/meta-character interpretation occurs:
tracing the data from sink → source
The next thing I needed to figure out was how the hostname value eventually reached this code. As mentioned above,
UA_Parser reads the hostname value from the file
/tmp/netscan/attach_device. I didn’t find any other references to this file in
UA_Parser that indicated it writing to the file, so I used grep to recursively search the root filesystem I had extracted from the firmware image to find other files that referenced it. This is when I came across the binary
/usr/sbin/net-scan which seemed like a good lead given the name.
After spending some time going through the code in Ghidra, I eventually found the function used to write
/tmp/netscan/attach_device which I labeled
update_attach_devices(). There was only a single reference to this function, which occured within another function that appeared to be the main entrypoint to trigger a ‘device scan’; this function is also only referenced once (from
Naturally, my next question was about where/how
net-scan was getting the values it used to populate the attached devices file. I spent some more time looking through the decompiled code and eventually came across a function that opened a file at
/tmp/dhcpd_hostlist for reading. This caught my attention because the advisory for the vulnerability mentioned that a “crafted Host Name option in a DHCP request can trigger execution of a system call”, so it made sense that
net-scan would get it’s values from whatever the DHCP server had received.
Another recursive grep later, I had confirmed that the DHCP server binary (
/sbin/udhcpd-ext) contained references to
/tmp/dhcpd_hostlist. Since the source code for udhcp is included in the GPL sources for the device provided by the vendor, I took a look there and found the string in
leases.h as a constant called
HOSTNAME_SHOWFILE. This value is used in some custom code in a function called
dhcpd.c (shown below at line 111) which writes the MAC/IP and hostname for each lease in the global
leases structure to this file.
Below is a snippet of code from
serverpacket.c:, one of two locations where
show_clients_hostname() is called. One detail that became really important later is the call to
toupper() on line 535 — it’s called for each character in the hostname string before it is saved to the lease object where it’s saved. This causes all alphabetic letters to be uppercased in the value that’s written out to
/tmp/dhcpd_hostlist and eventually ends up in the vulnerable call to
summary of the analysis
- the vulnerability occurs in
UA_Parserdue to a call to
system()using a string containing a attacker-controlled hostname value
UA_Parserreads the hostname value from
UA_Parseris executed by a binary called
net-scanused to detect attached devices
net-scancreates the file
net-scanreads the hostname values from the file
/tmp/dhcpd_hostlistis created by
udhcpdusing the hostnames saved in it’s global leases array
udhcpdpopulates the hostname field for each lease struct in the global array using values received in DHCP REQUEST packets
I created the following GDB script to set breakpoints on
system() when I attached to
UA_Parser , as well as set the fork-mode settings to be sure the debugger follows the processes as they spawn.
set breakpoint pending on set follow-fork-mode child break main commands 1 set follow-fork-mode parent continue end break system commands 2 info args backtrace full info frame info registers x/s $r0 x/s $r1 x/s $r2 continue end
net-scan runs periodically in the background on the device but a re-scan can also be manually triggered by loading the “Attached Devices” page in the web admin UI. This is what I used during testing to force it to run the vulnerable code. Interestingly, I later discovered it would run even with unauthenticated requests to the homepage, so an attacker would actually be able to trigger the vulnerable code on-demand.
Below is a screenshot of the GDB script breaking on
system() while attached to the
UA_Parser process and triggering the vulnerable code. The argument that was passed to
system() can be seen near the bottom of the register listing (the call to ‘devices_info update …’).
In order to send custom DHCP hostname values easily and establish DHCP connections, I used
udcpc, which allows for passing in a custom hostname at the command line. I used this command after connecting to the Orbi using a static IP and confirmed the payload appeared in the relevant files.
sudo ./udhcpc -H "\$PATH" -f -i <interface> -n
With everything set up to be able to deliver payloads, it was time to start building one.
using parameter+substring expansion to build a payload
As mentioned earlier in this post, each character in the hostname value that
udhcpd receives from clients is uppercased before it’s saved to the global
leases array and eventually written to
/tmp/dhcpd_hostlist. This means that by the time
UA_Parser reads these value from
/tmp/netscan/attach_device, a payload like
$(reboot) would be transformed to
$(REBOOT). Linux and native Linux file systems are case-sensitive, which means any such payload would fail to execute the desired program. So, I needed to find a way to call binaries using only uppercase letters, alphanumeric symbols, and numbers.
My first thought was I would likely need to use shell expansion and environment variables since they typically use uppercase names. I did a bit of Googling and came across this page about bash parameter expansion, which seemed like a viable way to construct a working payload. Specifically, I thought substring expansion could be used slice up pieces of environment variables to grab the characters needed to build a payload. With this in mind, I checked what environment variables were available in the shell for root (the UA_Parser process runs as root) and quickly realized I would have to get pretty creative given what was was there.
I played around with different patterns in the shell while connected via serial and eventually found one that made use of both parameter and filepath expansion which would expand out to
/sbin/reboot built up from characters sliced from the
$PATH env variable
In this context, the
? symbol is used to match up to a single character — the expansion works because
reboot is the only binary in
/sbin that starts with
r followed by 5 characters. The screenshot below shows the final payload constructed piece by piece.
The final step was to place this payload within a shell context using command substitution so that once the string expanded out it would be interpreted as a command. To do this I wrapped the payload inside
$() syntax and set everything up to do a test run. After getting a DHCP lease with the crafted request and attaching to the running
UA_Parser process, I loaded the Attached Devices page in the web UI and caused
net-scan to run. Easy peasy, right?
Yeah, right. It’s never that easy.
more constraints: html encoding
The screenshow below shows the debugger output when catching the vulnerable call to
system() with the payload above.
This is when I discovered there was some filtering happening and certain characters are HTML encoded. Specifically, the parentheses characters are encoded in the payload above. I went back through code for
net-scan and found the function responsible for the encoding — the full list of encoded characters are:
< > ( ) & ' " \
I also learned that
net-scan truncates the hostname string read from the DHCP host list at 32 characters before writing it to
Pretty rough! Almost every useful character (in regards to shell manipulation) is encoded and there’s a pretty tight length limit, which only makes writing a functional exploit that much more difficult. But, first I had to get code execution, so I pushed on.
success: backquote command substitution
There was really only one other option left to get to get command execution considering parenthese were filtered and that was the use the older backquote form of substitution:
I did the usual dance to connect and send a DHCP request while attached to the UA_Parser process with GDB. After triggering
net-scan again, I caught the call to
system() and saw that a new chain of calls had occurred and
reboot had been called. The device then began to reboot!
And there it is, a working proof-of-concept showing the bug could be exploited…
escaping constraints: arbitrary code execution
Okay, but rebooting the device isn’t particularly cool. Now it was time to think about what could actually be done with the bug.
To recap, the contraints for the payload are:
- 32 character length limit
- All letters get uppercased
- Filters chars:
< > ( ) & ' " \
- must build payload from chars in env variables + file/parameter expansion:
The length limit was probably the most frustrating part of this whole thing when combined with the uppercasing issue — it could easily take 7-9 characters to do the expansion needed to grab a single character for the final payload, so using up those 32 characters was very easy. The inability to use
< also meant using redirection to overwrite files wasn’t an option.
With all of this in mind, I narrowed down the possible attack scenarios to the following:
- Leak admin credentials back to the attacker somehow
- Reset the admin password
- Download and execute a script to run arbitrary commands without dealing with constraints
I spent a couple of days experimenting with what felt like hundreds of payloads and possible angles to achieve one of the results above. Again, that length limit SUCKED — on multiple occasions I had figured out working payloads that ended up exceeding the limit by 1-2 characters and immediately became useless. At the end of each session I would tell myself I would give up and that it just wasn’t possible to do anything useful given the restrictions. Then the next day when I signed back on I would get sucked back in, convinced there just had to be a way.
going after curl
After tons of failed attempts, I eventually came to a conclusion: in order to do anything useful, I would need to find a way to break out of / get around the length limit. Having determined this, I knew the only feasible way forward would be to find a way to download a script from a remote source and run it, which would avoid the uppercasing issues, length limits, etc. With this in mind, I shifted my focus to figuring out a way to use the
wget binaries on the router to achieve this.
This finally paid off after another couple of hacking sessions when I figured out the following payload:
The first part makes use of expansion to match
/usr/bin/curl, which is used to make a request to a server (
hy.me in this example), and pipes the contents of the response to a shell (pointed to by
In order to test this and show it working without having to actually go out and buy a two character domain (which apparently go for anywhere between $500 - $15k), I edited
/etc/hosts on the device to add a record pointing
hy.me to my ‘evil’ server where I was running a Python web server that would respond with the contents of a shell script to requests for the root path (to use as few characters as possible).
The screenshot below shows everything in action, going counter-clockwise starting at the top-left:
- the udhcpc process that sent the payload
- the code for the Python webapp that returns code to spawn a reverse shell
- the running webapp showing requests were received from the Orbi
- (open telnet session, unrelated)
- the netcat listener receiving the connection from reverse shell
There it is: there’s now an exploit for CVE-2020-27861 and it can be used to completely take over a vulnerable Orbi device. In total, it took about a week to go from starting to initial analysis to finally getting arbitrary code execution, with most of the time being spent on figuring out how to create a payload to actually do something useful given the restrictions. It was pretty fun and I was able to pick up some new techniques for dealing with payload constraints for command execution.
The most important lesson learned? Persistence pays off (usually). I really didn’t think a full exploit would be possible and almost gave up before getting full system control, but I’m glad I kept pushing. I’ll probably start spending more time doing this kind of n-day research and monitoring advisories for specific devices/applications to hopefully be able to do this for a more recent vuln.