Heapify
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 at0x520(priorityMAXU64) - 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.prio→ OOB 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 atHB + 0x4b0→slots[65] - K (
SZ_BIG=0x70): User pointer atHB + 0x520, priorityMAXU64
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:
- Allocate a small chunk C, execute it (free → fd =
&C >> 12) - Allocate a target T with scanf-fail priority (scanf reads the heap-leaked fd as priority)
- Use
range_searchto 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
- Allocate 7 + 22 = 29 chunks of size 0x80
- Execute all → free them all to fastbin
bad_size(70000)→ triggersmalloc_consolidate→ chunks move to unsorted bin- The unsorted bin fd pointer (
main_arena.bins[0]) is the libc leak target - 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):
| Chunk | Content | Purpose |
|---|---|---|
| R0 | _flags = "AA;/bin/sh" | system argument + branch control |
| R1 | _chain = NULL, _lock, _wide_data | Chain termination, lock zeroing, wide_data pointer |
| R2 | _IO_wfile_jumps vtable | Points to libc vtable |
| R3 | Zero-filled | LOCK area (16 bytes zero) |
| R4 | Wide vtable stub | Points to R5 as __doallocate |
| R5 | system address | Called via _IO_wfile_overflow → __doallocate(fp) |
Key constraints:
_flagsbyte 0: bits 1 (_IO_UNBUFFERED) and 3 (_IO_NO_WRITES) must be 0_flagsbyte 1: bit 3 (_IO_CURRENTLY_PUTTING) must be 0'A' = 0x41satisfies all three- No
0x0abytes in W or LOCK addresses (breaksfgets)
Phase 4/5: Groom → OOB → Free Arbitrary
- Find a grooming configuration via
find_groom(random priority search):
- After inserting
N_TOTAL - sim.nelements, the nextpopmust causedownheapto reach index 32 - After OOB injects
slots[32] = Ff, two more pops must extract Ff
- Insert grooming elements → count reaches 34 →
poptriggers OOB slots[32]is written withFf's priority (0) → the next two pops extract Ff- Ff is now free'd →
tcache[0x40] = 2
Phase 6/7: Tcache Poison → _IO_list_all
- Leak Q chunk (the carrier of Ff)
- Corrupt Ff's tcache fd pointer:
target = LIBC + OFF_IO_LIST_ALL mangled = (FF >> 12) ^ target # PROTECT_PTR
- Allocate from tcache[0x40] → gets chunk at
_IO_list_all - Write fake FILE structure address (
SF) to_IO_list_all
Phase 8: Trigger Shell
h.quit()→ sends invalid option →puts()+exit(1)exit(1)calls_IO_flush_all_IO_flush_alltraverses_IO_list_all→ finds fake FILE atSF- Checks
_IO_write_ptr > _IO_write_base→ calls_IO_wfile_overflow _IO_wfile_overflowcallsfp->_wide_data->_wide_vtable->__doallocate(fp)- Wide vtable's
__doallocate=system→system("AA;/bin/sh") "AA"is an invalid command → shell is spawned
Flags
| Flag | Location | Value |
|---|---|---|
| user | /home/*/user.txt | REDACTED |
| 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 >= 63comes after the access, not before. With exactly 34 elements, only index 32 reaches this path, and the anchor's priority controls what gets written toslots[32]. - FSOP via
_IO_flush_all: Onexit(1), glibc traverses_IO_list_all. A fake FILE structure with_wide_datapointing to a fake vtable where__doallocate = systemgives code execution. The_flagsvalue must satisfy branch conditions (0x41works). - 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_groomfunction 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-reviseto fold lessons into XESXor_Methodology.md.