
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 }