← Back to Writeups
HTBN/Apwn

Heapify

XESXOR8/23/20266 min read
#pwn#htb#n/a

Heapify

Platform: HackTheBox | Category: Pwn | Type: Challenge | Difficulty: Insane | OS: Linux | Author: D3v0o0Nu11 | Date: 2026-08-20 | Status: Solved Techniques: Heap oracle binary search, tcache poisoning, FSOP (File Stream Oriented Programming), OOB write via min-heap bug, libc leak via unsorted bin, safe-link leak

Summary

Heapify is an insane pwn challenge implementing a custom min-heap priority queue over a contiguous buffer managed by glibc malloc. The binary has no visible vulnerability at first glance — the heap operations look correct. However, a subtle off-by-one in downheap allows OOB access when the command count exceeds 32, enabling controlled writes to out-of-bounds memory. Combined with oracle-based heap/libc leaking via priority comparison, tcache poisoning, and FSOP via _IO_flush_all, the exploit chains together: leak heap address → leak libc address → build fake FILE structure → poison tcache → corrupt _IO_list_all → trigger exit(1) → get shell via system().

Recon

The binary is a standard heap challenge with a custom heap implementation:

  • Menu: Add command (size + priority + data), Execute min-priority, Quit
  • Heap: Contiguous buffer, min-heap stored in fixed-size slots array at base offset 0x2a0
  • Key data: Anchor chunk at 0x4b0, K chunk at 0x520 (priority MAXU64)
  • Size classes: Small (0x20), Probe (0x30), Poison (0x40), Mid (0x60), Anchor (0x70), Big (0x80)

Vulnerability

The Bug: OOB in downheap

In downheap, when count > 32 (after 33+ elements), the left-child index l = 2*i + 1 can reach 63. The binary checks l >= 63 but the check is after the access, not before. This means:

slots[65] = *(heap_base + 0x4a0)  // read before bounds check

When count >= 33, the downheap loop can reach index i=32, where it reads slots[63] = *(HB + 0x4a0). If this slot contains a non-zero value, the loop dereferences it as a priority, causing OOB behavior that swaps slots[32] with the out-of-bounds value.

Why 34 elements?

With exactly 34 elements, after one pop, the count drops to 33. The only nodes at level 5 that exist are:

  • Index 31: slots[63] = *(HB + 0x4a0) = 0 → loop terminates (0 is "leaf")
  • Index 32: slots[65] = anchor.prioOOB dereference happens

Any other index causes random memory reads → crash.

Exploitation

Phase 0: Fixed Address Reservation

Reserve two chunks at known offsets:

  • Anchor (SZ_ANCHOR=0x60): User pointer at HB + 0x4b0slots[65]
  • K (SZ_BIG=0x70): User pointer at HB + 0x520, priority MAXU64

The anchor's priority (slots[65]) controls what gets written to slots[32] during OOB.

Phase 1: Heap Leak via Oracle

The heap address is leaked by exploiting the safe-link mechanism:

  1. Allocate a small chunk C, execute it (free → fd = &C >> 12)
  2. Allocate a target T with scanf-fail priority (scanf reads the heap-leaked fd as priority)
  3. Use range_search to binary-search the heap base:
  • The oracle inserts the target, then probes with known priorities
  • By checking whether the target or a probe wins execute(), we determine the heap address
U = range_search(h, 0, 1 << 36, KPROBE, mk_heap_target, ...)
HB = U << 12  # heap base

Phase 2: Libc Leak via Unsorted Bin

  1. Allocate 7 + 22 = 29 chunks of size 0x80
  2. Execute all → free them all to fastbin
  3. bad_size(70000) → triggers malloc_consolidate → chunks move to unsorted bin
  4. The unsorted bin fd pointer (main_arena.bins[0]) is the libc leak target
  5. Use the same oracle technique to find the libc address
V = range_search(h, 0, 1 << 47, KPROBE, mk_libc_target, ...)
LIBC = V - OFF_UNSORTED_HEAD  # libc base

Phase 3: FSOP Payload Construction

Build a fake FILE structure across 6 contiguous chunks (0x80 each):

ChunkContentPurpose
R0_flags = "AA;/bin/sh"system argument + branch control
R1_chain = NULL, _lock, _wide_dataChain termination, lock zeroing, wide_data pointer
R2_IO_wfile_jumps vtablePoints to libc vtable
R3Zero-filledLOCK area (16 bytes zero)
R4Wide vtable stubPoints to R5 as __doallocate
R5system addressCalled via _IO_wfile_overflow__doallocate(fp)

Key constraints:

  • _flags byte 0: bits 1 (_IO_UNBUFFERED) and 3 (_IO_NO_WRITES) must be 0
  • _flags byte 1: bit 3 (_IO_CURRENTLY_PUTTING) must be 0
  • 'A' = 0x41 satisfies all three
  • No 0x0a bytes in W or LOCK addresses (breaks fgets)

Phase 4/5: Groom → OOB → Free Arbitrary

  1. Find a grooming configuration via find_groom (random priority search):
  • After inserting N_TOTAL - sim.n elements, the next pop must cause downheap to reach index 32
  • After OOB injects slots[32] = Ff, two more pops must extract Ff
  1. Insert grooming elements → count reaches 34 → pop triggers OOB
  2. slots[32] is written with Ff's priority (0) → the next two pops extract Ff
  3. Ff is now free'd → tcache[0x40] = 2

Phase 6/7: Tcache Poison → _IO_list_all

  1. Leak Q chunk (the carrier of Ff)
  2. Corrupt Ff's tcache fd pointer:
   target = LIBC + OFF_IO_LIST_ALL
   mangled = (FF >> 12) ^ target  # PROTECT_PTR
  1. Allocate from tcache[0x40] → gets chunk at _IO_list_all
  2. Write fake FILE structure address (SF) to _IO_list_all

Phase 8: Trigger Shell

  1. h.quit() → sends invalid option → puts() + exit(1)
  2. exit(1) calls _IO_flush_all
  3. _IO_flush_all traverses _IO_list_all → finds fake FILE at SF
  4. Checks _IO_write_ptr > _IO_write_base → calls _IO_wfile_overflow
  5. _IO_wfile_overflow calls fp->_wide_data->_wide_vtable->__doallocate(fp)
  6. Wide vtable's __doallocate = systemsystem("AA;/bin/sh")
  7. "AA" is an invalid command → shell is spawned

Flags

FlagLocationValue
user/home/*/user.txtREDACTED
root/flag*REDACTED

Key Takeaways / Lessons

  • Oracle-based binary search for heap/libc leaks: When you can compare two priorities via execute(), you can binary-search the address space. The heap stores safe-link mangled pointers, and unsorted bin stores libc pointers — both are exploitable via priority comparison.
  • OOB via min-heap downheap bug: The bounds check l >= 63 comes after the access, not before. With exactly 34 elements, only index 32 reaches this path, and the anchor's priority controls what gets written to slots[32].
  • FSOP via _IO_flush_all: On exit(1), glibc traverses _IO_list_all. A fake FILE structure with _wide_data pointing to a fake vtable where __doallocate = system gives code execution. The _flags value must satisfy branch conditions (0x41 works).
  • Tcache poisoning with safe-link: The mangled fd is (chunk_addr >> 12) ^ target. Knowing both the chunk address and target, the corruption is straightforward.
  • Grooming for deterministic heap state: The find_groom function brute-forces priorities such that after a specific sequence of pops, the OOB triggers at exactly index 32. The simulator mirrors the binary's heap logic exactly.

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.