Dead Reckoning
Challenge#
- Name: Dead Reckoning
- Category: Hardware
- Difficulty: Medium
- Prompt: “A damaged embedded CNC controller was discovered… Can you recover what it was making and find the flag in the process? The flag contains the characters
f'to identify it.” - Given file:
controller_fw.bin - Expected flag format:
apoorvctf{...}
1) Fast Triage#
File type and strings#
file controller_fw.bin
strings -n 6 controller_fw.binbashKey observations from strings:
- Firmware/banner-like strings:
AXIOM-CNC fw v2.3.1 - Job-related hints:
job_buffer: packet format [4B:length][1B:seg_id][NB:data] x4 segmentsJOB BUFFER FRAGMENTED
- Debug hint:
cal_reserved (0x0C18): DO NOT MODIFY
- Markers in binary:
JBUFHDR5SEG4AXIOM_END
This strongly suggests embedded job data exists in 4 fragmented packets, probably obfuscated/encrypted.
2) Locate Important Offsets#
I scanned offsets for known markers and found:
JBUFHDR5SEG4at0x1000AXIOM_ENDnear EOF- file size
0x5010(20496 bytes)
Hex around 0x1000 looked like:
0x1000: JBUFHDR5SEG4
0x100C: 92 0f 00 00 03 ...textInterpreting with hinted format [4B:length][1B:seg_id][NB:data]:
0x00000f92= 3986 bytes,seg_id = 3- followed by next packet, etc.
3) Parse Fragmented Job Packets#
I parsed 4 packets sequentially from 0x100C:
seg 3: length 3986seg 0: length 1580seg 2: length 2767seg 1: length 246
Reordered by segment ID (0,1,2,3) to reconstruct logical job stream.
4) Recover Obfuscation Key#
From strings, this value stood out:
cal_reserved (0x0C18): DO NOT MODIFY
Hex dump at 0x0C18:
f1 4c 3b a7 2e 91 c4 08textTesting this as repeating XOR key over packet payload produced clear ASCII G-code immediately.
Recovered XOR key:
f14c3ba72e91c408textThis is the core trick of the challenge: job packets are XOR-obfuscated with the cal_reserved bytes.
5) Decrypt Result = CNC G-code#
After XOR-decrypting each segment with the 8-byte repeating key, plaintext shows valid G-code, e.g.:
%
(AXIOM CNC CONTROLLER v2.3.1)
(job_id: 0x3F2A seg:1/4)
G21
M3
G00 Z5.000000
G00 X35.105656 Y72.065903
G01 Z-1.000000 F100.0
...
M5
G00 X0.0000 Y0.0000
M2
%textSo firmware did indeed contain the engraving toolpath.
6) Reconstruct the Engraving Geometry#
I rendered the path by:
- treating
G01as line segments - approximating
G02/G03arcs with short line samples - only drawing moves while
Z < 0(cutting depth)
The rendered output clearly spells:
f'GStextTherefore flag content is f'GS.
7) Final Flag#
apoorvctf{f'GS}textIf the platform enforces typographic apostrophe from statement wording, alternate try:
apoorvctf{f’GS}textRepro Script (Single-File)#
#!/usr/bin/env python3
from pathlib import Path
import re, math
import numpy as np
import cv2
fw = Path("controller_fw.bin").read_bytes()
# 1) parse 4 packets from job buffer
off = 0x100C
segments = []
for _ in range(4):
ln = int.from_bytes(fw[off:off+4], "little")
sid = fw[off+4]
data = fw[off+5:off+5+ln]
segments.append((sid, data))
off += 5 + ln
# 2) key from cal_reserved offset
key = fw[0x0C18:0x0C20] # f1 4c 3b a7 2e 91 c4 08
def xor_rep(data, key):
return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
dec = b"".join(xor_rep(d, key) for _, d in sorted(segments, key=lambda x: x[0]))
Path("controller_job_dec.bin").write_bytes(dec)
text = dec.decode("ascii", errors="ignore")
print("--- GCODE PREVIEW ---")
print("\n".join(text.splitlines()[:20]))
# 3) render toolpath
lines = [ln.strip() for ln in text.splitlines() if ln.strip() and not ln.strip().startswith("(") and ln.strip() != "%"]
x = y = z = 0.0
draw_segments = []
for ln in lines:
m = re.search(r"G(\d\d)", ln)
cmd = "G" + m.group(1) if m else None
p = {k: float(v) for k, v in re.findall(r"([XYZIJF])(-?\d+(?:\.\d+)?)", ln)}
nx, ny, nz = p.get("X", x), p.get("Y", y), p.get("Z", z)
if "Z" in p:
z = nz
cut = z < 0
if cmd in ("G00", "G01"):
if "X" in p or "Y" in p:
if cut:
draw_segments.append(((x, y), (nx, ny)))
x, y = nx, ny
elif cmd in ("G02", "G03"):
I, J = p.get("I", 0.0), p.get("J", 0.0)
cx, cy = x + I, y + J
r = math.hypot(x - cx, y - cy)
a0 = math.atan2(y - cy, x - cx)
a1 = math.atan2(ny - cy, nx - cx)
cw = cmd == "G02"
if cw and a1 >= a0:
a1 -= 2 * math.pi
if (not cw) and a1 <= a0:
a1 += 2 * math.pi
steps = max(16, int(abs(a1 - a0) * max(r, 1) / 0.5))
px, py = x, y
for i in range(1, steps + 1):
a = a0 + (a1 - a0) * i / steps
qx, qy = cx + r * math.cos(a), cy + r * math.sin(a)
if cut:
draw_segments.append(((px, py), (qx, qy)))
px, py = qx, qy
x, y = nx, ny
pts = np.array([p for s in draw_segments for p in s], dtype=np.float64)
minx, miny = pts.min(axis=0)
maxx, maxy = pts.max(axis=0)
scale, pad = 8, 20
W = int((maxx - minx) * scale + 2 * pad)
H = int((maxy - miny) * scale + 2 * pad)
img = np.full((H, W), 255, np.uint8)
def tr(pt):
xx = int(round((pt[0] - minx) * scale + pad))
yy = int(round((maxy - pt[1]) * scale + pad))
return xx, yy
for a, b in draw_segments:
cv2.line(img, tr(a), tr(b), 0, 2, cv2.LINE_AA)
cv2.imwrite("controller_job_render.png", img)
print("Saved controller_job_render.png")
print("Visually read text as: f'GS")
print("Flag: apoorvctf{f'GS}")pythonDead Ends / Notes#
- The string
Qkkballooked like Base64 bait; decoding it alone was not the path. - Searching for direct
flag/apoorvctfstrings in raw firmware fails (as expected due to obfuscation). - Key recovery depended on interpreting
cal_reserved (0x0C18)as a real decryption material location.