home / ctf / k17

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().

2026.09.19 K17 CTF 2026 500 pts Pwn
FLAG 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_writenew_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_WRITES makes the _IO_OVERFLOW that xsputn would otherwise fall into return EOF instead of writing anything unwanted.
  • _IO_IS_APPENDING makes new_do_write skip its repositioning lseek.

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

  1. view_historyfclose leaves fp dangling
  2. create(464) → reclaims the FILE; view leaks _IO_wfile_jumps past the memset
  3. fake FILE + _IO_file_jumpsedit’s fflush becomes write(1, any, len)
  4. read __curbrk, dump the heap, find the fake FILE by its _lock
  5. fake FILE + _IO_wfile_jumps-0x20edit’s fwrite becomes system(" sh;")

Notes for next time

  • A FILE * that is fclosed but never NULLed is a full struct-forging primitive the moment any allocation can land on sizeof(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 exceeds size leaves the tail intact. Choosing size so 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/_fileno alone turn fflush into write(2), and _IO_buf_base/_fileno alone turn fgetc into read(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.
#heap#file-structure#house-of-apple#uaf#glibc