Starless C — LA CTF 2025 Reverse Engineering Writeup
Category: Reverse Engineering
Difficulty: Medium
Points: 211
Solves: 72
Flag: lactf{starless_c_more_like_starless_0xcc}
Solved by: Smothy @ 0xN1umb

"Three doors. A bee. A key. A flag. I chose all of them — 145 times."
Challenge Description
The son of the fortune-teller stands before three doors. A bee. A key. A flag.
nc chall.lac.tf 32223
We're given a single binary starless_c and a netcat endpoint. The name is a reference to "Starless" by King Crimson — prog rock and CTFs, the perfect combo.
TL;DR
A hand-crafted ELF binary implements a virtual maze using 23 memory-mapped pages as rooms. Navigate with wasd, where passing through "NOP gates" closes them behind you and opens the next one ahead. Press f to trigger a NOP-sled chain through 5 specific rooms to reach the flag reader. BFS over ~500k states finds the 145-move solution. ELF segments go brrr.
Initial Recon
First impressions of the binary:
- Statically linked, no section headers — fully custom ELF
- No protections at all — no PIE, no canary, no RELRO, executable stack, RWX segments
- Base address 0x13370000 — leet speak:
1337. Author is vibing. - 58 program headers — way more than any normal binary
Running it prints a cryptic message about fortune-tellers and flags, then reads input. Wrong input = SIGSEGV → error message → exit. The SIGSEGV handler is at 0x13370103.
The strace output reveals the binary reads 1 byte at a time from stdin. Each byte is a navigation command.
Step 1: Understanding the ELF Layout
Parsing all 58 program headers reveals 25 segments with actual code:
| Segment | VAddr | Purpose |
|---|---|---|
| Entry Point | 0x13370000 | Prints intro, sets SIGSEGV handler, jumps to maze |
| Flag Reader | 0x42069000 | Reads and prints flag.txt |
| 23 Room Pages | 0x6767xxxx - 0x6769xxxx | The maze rooms (RWE!) |
The room pages are arranged in a grid where:
- Moving right (
d) =+0x1000in virtual address space - Moving down (
s) =+0x8000in virtual address space - Moving left (
a) =-0x1000 - Moving up (
w) =-0x8000
Unmapped addresses between rooms are intentional — if you try to move to a non-existent room, the CPU fetches code from unmapped memory → SIGSEGV → game over.
Step 2: Disassembling Room Code
Each 0x1000-byte room page has a consistent structure:
Offset 0x000 — The Gate (4 bytes):
90 90 90 90= NOP NOP NOP NOP → OPEN (passable in f-chain)31 c0 88 00=xor eax, eax; mov [rax], al→ CLOSED (NULL deref crash)
Offset 0x004 — Chain Jump (5 bytes):
e9 XX XX XX XX— hardcoded jump to the next room in the f-chain
Offset 0x00C — Navigation Code:
read(0, rsp, 1) ; read 1 byte
cmp al, 0x0a ; newline → retry
cmp al, 'w' ; up
cmp al, 's' ; down
cmp al, 'a' ; left
cmp al, 'd' ; right
cmp al, 'f' ; flag chain trigger
; anything else → xor eax,eax; mov [rax],al → CRASHThe gate logic for each direction:
- Read 4 bytes at target room's offset 0
- If gate == 0x90909090 (open):
- Close the gate (write crash code to target room)
- Open the gate TWO rooms further in the same direction
- Jump to target room's navigation code (always, regardless of gate state)
For the homies who haven't touched self-modifying code: the rooms literally rewrite each other's code at runtime. Passing through an open gate closes it behind you and opens the next one ahead. Like dominos.
Step 3: Mapping the Maze
The 23 rooms form this grid (gap at position (2,3) — unmapped):
x=0 x=1 x=2 x=3 x=4 x=5
y=0: [S] [ ]
y=1: [ ] [ ] [ ] [ ] [ ]
y=2: [O] [O] [ ] [O] [O]
y=3: [ ] [ ] ╳ [O] [ ] [ ]
y=4: [ ] [ ] [ ] [ ] [ ] [ ]
S = Start, O = Initially OPEN gate, ╳ = Unmapped (wall)
Five gates are initially open: (0,2), (1,2), (3,2), (4,2), (3,3).
Step 4: The F-Chain — Path to the Flag
The f key is special — it always jumps to 0x6767A000 (room (1,0) at offset 0), regardless of which room you're in. Each room's f instruction has a different relative offset that all resolve to the same absolute target.
If room (1,0)'s gate is open (NOPs), execution slides through to offset 4, which is a hardcoded jump to the next room. This chains through 5 rooms:
f → (1,0) → (1,1) → (1,2) → (0,3) → (1,3) → 0x42069000 (FLAG!)
Each arrow represents: NOP NOP NOP NOP → jmp next_room_offset_0
The last room (1,3) has a special jump at offset 4: e9 f7 6f 9d da which resolves to 0x42069000 — the flag reader page that opens and prints flag.txt.
ALL FIVE gates must be 0x90909090 simultaneously when f is pressed.
Step 5: Gate Shuffling — The Real Puzzle
Here's the core puzzle. We need to transform the gate state:
Initial: {(0,2), (1,2), (3,2), (4,2), (3,3)} → Target: {(1,0), (1,1), (1,2), (0,3), (1,3)}
Key constraints:
- Gate count is conserved — each pass closes one gate and opens another (5 → 5)
- Open target must be mapped — writing NOP to an unmapped address = SIGSEGV = death
- Movement is free — you can always move to mapped rooms regardless of gate state
- Direction matters — gates shift along rows (left/right) or columns (up/down)
- The gap at (2,3) blocks several lateral gate movements
This is a sliding puzzle implemented in x86 memory pages. Wild.
Step 6: BFS Solver
Manual solving is impractical — we need to explore the state space. Each state is (player_position, frozenset_of_open_gates).
from collections import deque
# State: (position, frozenset of 5 open gates)
# BFS guarantees shortest path
positions = { ... } # 23 mapped rooms
initial_open = frozenset({(0,2), (1,2), (3,2), (4,2), (3,3)})
target_gates = frozenset({(1,0), (1,1), (1,2), (0,3), (1,3)})
queue = deque([(((0,0), initial_open), "")])
visited = set()
while queue:
(pos, gates), path = queue.popleft()
if target_gates.issubset(gates):
print(f"SOLUTION: {path}f") # append 'f' to trigger chain
break
for direction in 'wasd':
nx, ny = pos[0]+dx, pos[1]+dy
if (nx,ny) not in positions: continue # unmapped = crash
new_gates = gates
if (nx,ny) in gates: # passing through open gate
next_pos = (nx+dx, ny+dy)
if next_pos not in positions: continue # open-target unmapped = crash
new_gates = (gates - {(nx,ny)}) | {next_pos}
new_state = ((nx,ny), new_gates)
if new_state not in visited:
visited.add(new_state)
queue.append((new_state, path + direction))~500,000 states explored → 145-character solution found:
sddddswaasdwaaasdssawwdwddsawasassdddwsddwasaaaawwdwdddsawaasassdddww
dwasssaaawwdwwassdddssddwasaaawwddwdsaaawdsassddwsddwawaawasdddssawdwaa
ddwaaf
The Flag
$ printf 'sddddswaasdwaaasdssawwdwdds...<145 chars>...ddwaaf' | nc chall.lac.tf 32223
A person this far into a challenge has their path to follow.
There were many paths, once, in a time that is past, lost many
bytes and pages ago. Now there is only one path for you to choose.
The path that leads to the flag.
lactf{starless_c_more_like_starless_0xcc}
lactf{starless_c_more_like_starless_0xcc}
The flag is a pun: "Starless C" → "Starless 0xCC". Every room page was padded with 0xCC bytes (INT3 / breakpoint opcode). No stars — just breakpoints. Beautiful.
The Solve Script
"""
Starless C — LA CTF 2025 Reverse Engineering
Solver by Smothy @ 0xN1umb
Custom ELF maze with self-modifying gate pages.
BFS over (position, gate_state) to find path that opens
all 5 f-chain gates simultaneously.
"""
import struct
from collections import deque
from pwn import *
with open('starless_c', 'rb') as f:
data = f.read()
# Parse ELF segments
segments = []
for i in range(58):
off = 64 + i * 56
p_offset = struct.unpack_from('<Q', data, off + 8)[0]
p_vaddr = struct.unpack_from('<Q', data, off + 16)[0]
p_filesz = struct.unpack_from('<Q', data, off + 32)[0]
if p_filesz > 0:
segments.append((p_vaddr, p_offset, p_filesz))
def vaddr_to_offset(va):
for vaddr, offset, filesz in segments:
if vaddr <= va < vaddr + filesz:
return offset + (va - vaddr)
return None
base = 0x67679000
room_addrs = set(v for v, _, fs in segments if 0x67670000 <= v <= 0x676A0000 and fs > 0)
# Map to grid coordinates
positions = set()
for addr in room_addrs:
diff = addr - base
x = (diff % 0x8000) // 0x1000
y = diff // 0x8000
positions.add((x, y))
# Read initial gate states
initial_open = frozenset()
for x, y in positions:
addr = base + y * 0x8000 + x * 0x1000
foff = vaddr_to_offset(addr)
gate_val = struct.unpack_from('<I', data, foff)[0]
if gate_val == 0x90909090:
initial_open = initial_open | {(x, y)}
target_gates = frozenset({(1,0), (1,1), (1,2), (0,3), (1,3)})
# BFS
dirs = {'w': (0,-1), 's': (0,1), 'a': (-1,0), 'd': (1,0)}
start = ((0,0), initial_open)
queue = deque([(start, "")])
visited = {start}
while queue:
(pos, gates), path = queue.popleft()
if target_gates.issubset(gates):
solution = path + "f"
break
for dname, (dx, dy) in dirs.items():
nx, ny = pos[0]+dx, pos[1]+dy
if (nx, ny) not in positions:
continue
new_gates = gates
if (nx, ny) in gates:
ox, oy = nx+dx, ny+dy
if (ox, oy) not in positions:
continue
new_gates = (gates - {(nx,ny)}) | {(ox,oy)}
state = ((nx,ny), new_gates)
if state not in visited:
visited.add(state)
queue.append((state, path + dname))
print(f"Solution ({len(solution)} moves): {solution}")
# Send to remote
r = remote('chall.lac.tf', 32223)
r.send(solution.encode())
r.recvuntil(b'lactf{')
flag = b'lactf{' + r.recvuntil(b'}')
print(flag.decode())The Graveyard of Failed Attempts
-
"f goes to the right neighbor" — Spent way too long thinking
fjumped topage_base + 0x1000. It actually always resolves to0x6767A000regardless of which room you're in. Each room'sfinstruction has a uniquely computed relative offset that cancels out to the same absolute address. Sneaky. -
Arithmetic error on the chain — Initially calculated room (0,3)'s chain jump as going to
0x676A1000(unmapped). Thought the chain was broken. Re-did the hex addition carefully:0x67691009 + 0x0FF7 = 0x67692000(room (1,3)). That carry bit almost cost me the solve. -
Wrong simulation — First simulation assumed
fgoes topos + 0x1000, got a false "SUCCESS" result, then the binary still crashed. Had to re-derive the f-target from scratch. -
Trying to solve by hand — Attempted manual gate shuffling. After 30 minutes of "close this, open that, wait now THAT one's closed", gave up and wrote the BFS. Should have started there.
Key Takeaways
- Custom ELF binaries can use program headers creatively — 58 LOAD segments as maze rooms is genuinely clever
- Self-modifying code through RWE segments enables runtime gate mechanics — each room rewrites other rooms' code
- Gate puzzles conserve count — closing one always opens another. It's a sliding puzzle in virtual memory.
- BFS is your friend for state-space exploration — ~500k states is trivial for a modern CPU
- Always double-check hex arithmetic — one wrong carry bit can send you down a multi-hour rabbit hole
- The flag
starless_0xccis a perfect pun: 0xCC (INT3) is the padding byte in every room page. No stars, just breakpoints.
Tools Used
- Python 3 + struct (ELF parsing)
- objdump (disassembly)
- strace (syscall tracing)
- BFS solver (custom)
- pwntools (remote connection)
- Way too much caffeine
Writeup by Smothy from 0xN1umb team. "58 program headers walk into a bar. The bartender says 'we don't serve your type'. They SIGSEGV." GG.