Königsberg Delivery Problem
Königsberg Delivery Problem
Platform: GPN CTF | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2024-06-01 | Status: Solved Techniques: bfs_nearest_unvisited, control_flow_graph_reversing, covering_walk, docker_dynamic_verification, jump_table_extraction, scanf_format_string_analysis, static_disassembly_parsing
Summary
Task: reverse a 250-node control-flow-graph maze ELF (cartographer) where 250 signed-decimal inputs (%hhd; format) act as edge selectors driving a single walk through a jump-table graph. Solution: extract the graph from .rodata jump tables, then build a covering walk (greedy BFS to nearest unvisited node) that enters all 250 nodes at least once so check_instance opens /flag.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
gpn24| ID:20240601_gpn24_koenigsberg_delivery_problem - Tags: reverse, cfg_maze, jump_table, eulerian_path, graph_cover, scanf_format, elf_pie, kitctf, gpnctf
- Indicators: main function literally named cfg, 250 successive scanf calls, %hhd; format string, per-node incb counter then jmp *table, check_instance requires all node counters nonzero
- Source:
20240601_gpn24_koenigsberg_delivery_problem.md
Foothold
Vulnerability / Misconfiguration
- Bfs_nearest_unvisited
- Control_flow_graph_reversing
- Covering_walk
- Docker_dynamic_verification
- Jump_table_extraction
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- bfs_nearest_unvisited
- control_flow_graph_reversing
- covering_walk
- docker_dynamic_verification
- jump_table_extraction
- scanf_format_string_analysis
- static_disassembly_parsing
- Tags: reverse, cfg_maze, jump_table, eulerian_path, graph_cover, scanf_format, elf_pie, kitctf, gpnctf
Original Writeup
<details><summary>Click to expand original content</summary>Description
Euler the owl owls in Königsberg when he gets totally hungry. Sadly for Euler the owl, the Königsberg internal food delivery apps have only one very lazy driver that does not want to make multiple trips. Can you (Euler the night owl) get his pizza? Or are you willing to deliver it yourself?
We are given koenigsberg-delivery-problem.tar.gz containing one ELF64 PIE executable named cartographer (not stripped). The remote service prints /flag only when our input satisfies an internal gate.
Semantic clues (high priority for this category):
- "Euler / Königsberg" → Eulerian path / Seven Bridges of Königsberg → graph traversal.
- "one very lazy driver that does not want to make multiple trips" → a SINGLE walk covering the whole graph.
- The binary's main worker function is literally named
cfg(control flow graph).
Analysis
Recon
$ file cartographer
ELF 64-bit LSB pie executable, x86-64, dynamically linked, not stripped, GLIBC 2.34
Key symbols: main (0x40f0), cfg (0x1190, a huge function), check_instance (0x5520).
Strings include /flag, Congratulations! Here is your flag: %s, Error opening flag file, Not quite, try again!. So the binary reads /flag server-side and prints it only on success — the flag is not derived from the input; the input only satisfies a gate.
The binary would not run natively on macOS; it was executed/verified inside Docker ubuntu:24.04 --platform linux/amd64.
Reversing main()
main issues 250 successive scanf calls, each reading one value into a stack buffer (rbx = rsp+6, bytes rsp+6 .. rsp+0xff), then calls cfg(buffer).
The decisive detail is the format string in .data:
"%hhd;"
Each token is a signed decimal number followed by a literal ;, NOT a raw byte and NOT %c. The input is therefore 250 decimal numbers separated by ;:
0;0;0;2;3;...;127;
An initial attempt feeding raw bytes failed with "Not quite, try again!". Switching to the
%hhd;decimal format was the single fix that made it work.
Reversing cfg() — the 250-node CFG maze
cfg(rdi = input buffer):
- A local
0x108buffer atrspis zeroed via xmm stores. Its first 250 bytes (rsp+0 .. rsp+0xf9) are per-node visit COUNTERS, one byte each. rcxis the walk index into the input, starting at 0.- There are 250 node blocks (from
0x1210,0x1230, … up to ~0x40b0). Each node block does:
incb <off>(%rsp) ; increment THIS node's counter on ENTRY movzbl (%rdi,%rcx),%edx ; read next input value cmpq $MAX,%rdx ; ja 0x40d4 ; per-node maximum; value > MAX => terminate incq %rcx leaq <table>(%rip),%rsi movslq (%rsi,%rdx,4),%rdx ; int32 offset from jump table indexed by value addq %rsi,%rdx jmp *%rdx ; jump to next node
- Each node's jump table maps value
c(0..MAX) to a target node. Targets are monotonic increasing and distinct, i.e. the value is the RANK of the chosen outgoing neighbor (an edge selector), not an ASCII character. - Terminal block
0x40d4:mov rsp,rdi ; mov $0xfa(=250),esi ; call check_instance.
The counter is incremented on entry, before the bounds check, so a node is counted as visited even on the transition that terminates the walk.
Reversing check_instance(rdi=counters, esi=250)
- Loops
i = 0..249;clstarts as 1 and becomes 0 (sticky) if any counter byte == 0. - If all 250 counters are nonzero (
cl == 1):open("/flag"), read0x64bytes,printf("Congratulations! Here is your flag: %s"). - Otherwise:
puts("Not quite, try again!")and exit.
So the single walk encoded by the input must enter every one of the 250 nodes at least once — the Königsberg/Euler theme: deliver to every node in ONE trip (one lazy driver). Note: counters are single bytes, so visiting a node 256 times wraps to 0 — prefer a low-revisit covering walk.
Solution
Graph extraction
The whole binary is an obfuscated directed graph; extract it from .rodata rather than reading by hand.
- Disassemble with
objdump, parse all 250 node blocks via regex to recover for each node: counter index (0..249),MAX, and jump-table base VMA. - For this PIE the
.rodatafile offset equals its VMA (0x6000), so jump tables are read directly from the file. - For each node and each value
cin0..MAX:
entry = int32 at (table_base + 4*c)target_vaddr = table_base + entry- map
target_vaddrto its node index.
Result: a clean 250-node directed graph; every edge resolves to a valid node (no dangling targets), no explicit TERM entries in the tables (termination only via value > MAX). Start node = node 0 (first block, MAX=95). The graph is strongly connected.
Finding a covering walk
Greedy strategy: from the current node, BFS to the nearest not-yet-visited node, append the edge-value sequence, repeat until all 250 nodes are visited. This yields a 249-transition walk entering all 250 nodes exactly once (max visit count = 1, no byte-counter wraparound). Append a terminating value (127, which exceeds every node's MAX; max MAX observed = 120) so the program reaches check_instance.
#!/usr/bin/env python3
# Conceptual solver: extract the CFG-maze graph and build a covering walk.
import re, struct, subprocess
from collections import deque
ELF = "cartographer"
RODATA_VMA = 0x6000 # for this PIE, file offset == VMA for .rodata
data = open(ELF, "rb").read()
# 1) Disassemble and parse each node block.
dis = subprocess.check_output(["objdump", "-d", "-M", "intel", ELF]).decode()
# Each node: incb off(rsp) ; ... ; cmp rdx,MAX ; lea rsi,[rip+table] ; jmp *rdx
# Parse (block_vaddr -> counter_index, MAX, table_vaddr). (regex omitted for brevity)
# nodes = { node_vaddr: {"idx": counter_index, "max": MAX, "table": table_vaddr} }
nodes = parse_node_blocks(dis) # returns dict as described above
# Map every node start VMA to a sequential node index 0..249
vaddr_to_node = {}
for n, va in enumerate(sorted(nodes)):
vaddr_to_node[va] = n
# 2) Build adjacency: for value c in 0..MAX, resolve target node.
# adj[node][c] = target_node (value c is the edge selector / neighbor rank)
adj = {}
for va, info in nodes.items():
src = vaddr_to_node[va]
adj[src] = {}
tbl = info["table"]
for c in range(info["max"] + 1):
off = tbl - RODATA_VMA + 4 * c
entry = struct.unpack_from("<i", data, off)[0]
target_vaddr = tbl + entry
adj[src][c] = vaddr_to_node[target_vaddr]
START = 0
# 3) Covering walk: repeatedly BFS to the nearest unvisited node.
def bfs_path(src, visited):
# returns (target_node, [edge_values...]) reaching nearest unvisited node
q = deque([(src, [])])
seen = {src}
while q:
node, path = q.popleft()
for c, tgt in adj[node].items():
if tgt in seen:
continue
seen.add(tgt)
np = path + [c]
if tgt not in visited:
return tgt, np
q.append((tgt, np))
return None, None
visited = {START}
cur = START
walk = [] # sequence of edge-selector values
while len(visited) < 250:
tgt, path = bfs_path(cur, visited)
walk += path
for c in path:
cur = adj[cur][c]
visited.add(cur)
walk.append(127) # value > every node MAX => terminate -> check_instance
# 4) Format as 250 "%hhd;" tokens.
assert len(walk) <= 250
walk += [127] * (250 - len(walk)) # pad with terminators (harmless after stop)
payload = "".join(f"{v};" for v in walk)
open("flag_input.txt", "w").write(payload)
print(payload)
Verification and exploitation
Verified locally in Docker (ubuntu:24.04, with a test /flag): the binary printed Congratulations! Here is your flag: <test flag>.
Against the remote service:
cat flag_input.txt | ncat --ssl boiled-mozzarella-sticks-crusted-with-charred-b-arnaise-9fgh.gpn24.ctf.kitctf.de 443
Output:
Congratulations! Here is your flag: GPNCTF{REDACTED}
</details>
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR