
Writeup for Tenda AC15 router firmware rehosting and remote command execution (CVE-2020-10987) exploit replication.
This write-up shows exactly how I emulated the AC15 (V15.03.05.19) firmware’s webserver with QEMU, made it reachable from the host browser, and exercised the vulnerable /goform/setUsbUnload handler (CVE-2020-10987) to get command execution inside the emulated rootfs.
squashfs filesystem from the firmware image and start reversingFirst we need to get our firmware image. I was not able to find the image download for AC15 V15.03.05.19 however I was able to find a github repo with the already extracted squashfs filesystem.
If we were to have the correct image file we could extracted it using binwalk like so,
user@computer $ binwalk -e AC15_V15.03.05.19.bin
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
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
Since we don't have the correct image file but a repo with the already extracted filesystem, we can just clone the repo.
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,
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;
This is the vulnerability. The deviceName parameter is passed directly into doSystemCmd allowing us to send whatever commands we want.
Now since we are going to be rehosting this firmware using qemu and not the original router hardware, some programs are going to try to reach devices that aren't there and crash our startup.
Since our goal is to exploit the webserver (httpd) I only focused on rehosting that binary not the whole startup (/rootfs/etc_ro/init.d/rcS). In hindsight im not sure this was the right move
So when looking at rcS I wanted to find anything that rcS might do that httpd would need. Towards the end of the file we can see:
cfmd &
echo '' > /proc/sys/kernel/hotplug
udevd &
logserver &
rcS starts cfmd in the background right before the rest of the stack spins up.
After some more research and looking at how the vulnerable function sends the command I came to the conclusion that,
httpd builds a cfm postcfm client talks to cfmd over a UNIX domain socket (e.g. /var/cfm_socket)In the InitServer routine in cfmd we can see
unlink("/var/cfm_socket");
strncpy(sa_unix.sun_path, "/var/cfm_socket", ...);
bind(fd, (sockaddr*)&sa_unix, 0x6e);
listen(fd, 5);
It creates a UIX socket and listens.
Overall how it works is the handler reads a fixed 0x7e0-byte frame from each client (RecvMsg/SendMsg). The first 4 bytes are a command code; then there’s a 512-byte key buffer and a 1500-byte value buffer (you can see the stack object sizes in the handler). It switches on the opcode and replies with an ACK code:
2 -> Get: GetCfmValue(key, value) then reply code 30 -> Set: SetCfmValue(key, value) then reply code 10x11 -> Unset: UnSetCfmValue(key) then reply code 0x1210 -> Commit: SaveCfm2Flash() then reply 0x10 (OK) or 0xB (error)So in order to emulate cfmd I created a short script cfm_stub
#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;
}
I will show how to compile this after the next part.
The next helper file is hooks.so. There are a few functions that are used in httpd and cfm that try to interact with non existent hardware.
The following is what our program assumes when starting up
/dev/nvram) and returns sane defaults.The following functions become an issue and therefore we have to patch with LD_PRELOAD=/hooks.so.
get_flash_type() -> if it returns 4, the code takes a file-based path (cfm_file_init), otherwise it tries to talk to MTD (which we don’t have).get_cfm_blk_size_from_cache() (and or the variant j_get_cfm_blk_size_from_cache) is consulted for config block sizing.bcm_nvram_get) must not fail or the stack assumes “NVRAM destroyed” and heads into restore/reboot logic.load_l7setting_file() and restore_power() are expected to succeed but touch non-existent hardware/files.Here is our hooks.c. Credit to azeria-labs for the original.
#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”
}