Category: Hardware
Flag: apoorvctf{f'GS}
Challenge Description
A damaged embedded CNC controller was discovered from an abandoned research facility. The machine was mid-job when the power got cut. The engineers said the machine was engraving something important before it died. Can you recover what it was making and find the flag in the process? The flag contains characters f’ to identify when you see it. The only file the team was able to recover from the CNC machine was the binary file last loaded onto the embedded controller, provided to you. Good Luck!
Note : If you come across, say f’XYZ… then the flag is apoorvctf{f’XYZ…}
Analysis
This one looked like a firmware-forensics challenge, so I started by checking whether the blob had obvious metadata. The file type was generic data, which usually means we need to carve/parse it ourselves instead of relying on standard container signatures.
file controller_fw.bincontroller_fw.bin: dataThe first big breakthrough was finding a calibration-looking block around 0x0C18. That region had non-zero bytes standing out from long zero runs, which strongly suggested key/config material.
xxd -g 1 -s 0x0be0 -l 160 controller_fw.bin00000be0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000bf0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000c00: 43 4d 10 aa 00 c2 01 00 00 00 48 43 00 00 48 43 CM........HC..HC
00000c10: 00 00 48 42 90 01 c0 5d f1 4c 3b a7 2e 91 c4 08 ..HB...].L;.....
00000c20: 04 de 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000c30: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000c40: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000c50: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000c60: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000c70: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................Then I parsed the job buffer packets at 0x1000 and XOR-decoded payload bytes with the repeating 8-byte key from 0x0C18 (f1 4c 3b a7 2e 91 c4 08). That instantly turned garbage into valid CNC G-code split across 4 segments.
# decode_job.py
from pathlib import Path
import struct, re
b = Path("controller_fw.bin").read_bytes()
key = b[0x0C18:0x0C20]
print("key8", key.hex())
o = 0x1000 + 12
segs = []
for _ in range(4):
ln = struct.unpack_from("<I", b, o)[0]
sid = b[o + 4]
d = b[o + 5:o + 5 + ln]
o += 5 + ln
pt = bytes(c ^ key[i % 8] for i, c in enumerate(d))
segs.append((sid, pt))
for sid, pt in sorted(segs):
lines = pt.splitlines()
print(f"seg{sid} first:", lines[0] if lines else pt[:80])
full = b"".join(pt for sid, pt in sorted(segs))
Path("decoded_keyonly_full.nc").write_bytes(full)
txt = full.decode("latin1", "ignore")
print("full_size", len(full), "lines", len(txt.splitlines()))
for p in [r"[A-Za-z0-9_]+\{[^}]+\}", r"apoorvctf\{[^}]+\}", r"f['’][^\r\n]{2,200}"]:
ms = re.findall(p, txt)
print("pattern", p, "count", len(ms))python decode_job.pykey8 f14c3ba72e91c408
--- seg0 len=1580 lines=38 ---
%
(AXIOM CNC CONTROLLER v2.3.1)
(job_id: 0x3F2A seg:1/4)
G21
--- seg1 len=246 lines=9 ---
(seg:2/4)
G00 Z5.000000
G00 X41.183871 Y87.557085
G01 Z-1.000000 F100.0
--- seg2 len=2767 lines=52 ---
(seg:3/4)
G00 Z5.000000
G00 X126.523541 Y89.025346
G01 Z-1.000000 F100.0
--- seg3 len=3986 lines=73 ---
(seg:4/4)
(continue: AXIOM-DEBUG port 4444)
G00 Z5.000000
G00 X159.299651 Y136.580078
full_size 8579 lines 172
pattern [A-Za-z0-9_]+\{[^}]+\} count 0
pattern apoorvctf\{[^}]+\} count 0
pattern f['’][^\r\n]{2,200} count 0That clean decode was the satisfying part because the core crypto/obfuscation turned out to be just the right XOR key at the right offset.

At this point there was no plaintext flag in comments, so the remaining flag had to be in the geometry itself (what the CNC was engraving). I rendered the toolpaths and checked segment geometry. The segment bounds showed four glyph-like components in left-to-right order, with segment 1 being much smaller (consistent with punctuation, i.e. apostrophe).
# glyph_geometry.py
import re, math
from pathlib import Path
text = Path("decoded_keyonly_full.nc").read_text(errors="ignore").splitlines()
seg_idx = 0
seg_lines = {0: [], 1: [], 2: [], 3: []}
for ln in text:
s = ln.strip()
if s.startswith("(seg:2/4)"): seg_idx = 1; continue
if s.startswith("(seg:3/4)"): seg_idx = 2; continue
if s.startswith("(seg:4/4)"): seg_idx = 3; continue
if not s or s.startswith("(") or s.startswith("%"):
continue
seg_lines[seg_idx].append(s)
def parse_word(s, letter):
m = re.search(rf"{letter}(-?\d+(?:\.\d+)?)", s)
return float(m.group(1)) if m else None
def points(cmds):
x = y = z = 0.0
pts = []
for ln in cmds:
m = re.search(r"\b(G\d\d?)\b", ln)
cmd = m.group(1) if m else ""
nx, ny, nz = parse_word(ln, "X"), parse_word(ln, "Y"), parse_word(ln, "Z")
ni, nj = parse_word(ln, "I"), parse_word(ln, "J")
tx = nx if nx is not None else x
ty = ny if ny is not None else y
tz = nz if nz is not None else z
cutting = tz < 0
if cmd in ("G1", "G01") and cutting:
pts.append((tx, ty))
elif cmd in ("G2", "G02", "G3", "G03") and cutting and ni is not None and nj is not None:
cx, cy = x + ni, y + nj
r = math.hypot(x - cx, y - cy)
a0 = math.atan2(y - cy, x - cx)
a1 = math.atan2(ty - cy, tx - cx)
cw = cmd in ("G2", "G02")
if cw:
if a1 >= a0: a1 -= 2 * math.pi
step = -0.05
a = a0
while a > a1:
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
a += step
else:
if a1 <= a0: a1 += 2 * math.pi
step = 0.05
a = a0
while a < a1:
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
a += step
pts.append((tx, ty))
x, y, z = tx, ty, tz
return pts
stats = []
for sid in range(4):
pts = points(seg_lines[sid])
xs, ys = [p[0] for p in pts], [p[1] for p in pts]
x1, x2, y1, y2 = min(xs), max(xs), min(ys), max(ys)
cx, cy = (x1 + x2) / 2, (y1 + y2) / 2
stats.append((sid, x1, y1, x2, y2, cx, cy, x2 - x1, y2 - y1, len(pts)))
print("sid x1 y1 x2 y2 cx cy w h npts")
for s in stats:
print("%d %.2f %.2f %.2f %.2f %.2f %.2f %.2f %.2f %d" % s)
print("\nreading order by cx:")
for s in sorted(stats, key=lambda t: t[5]):
sid, _, _, _, _, cx, cy, w, h, _ = s
print(f"seg{sid}: cx={cx:.2f} cy={cy:.2f} w={w:.2f} h={h:.2f}")python glyph_geometry.pysid x1 y1 x2 y2 cx cy w h npts
0 18.60 16.45 60.46 77.04 39.53 46.74 41.86 60.58 111
1 34.77 66.01 55.53 87.56 45.15 76.78 20.76 21.55 5
2 61.89 60.61 126.52 125.75 94.20 93.18 64.64 65.14 230
3 104.57 96.05 163.52 163.48 134.04 129.76 58.95 67.44 380
reading order by cx:
seg0: cx=39.53 cy=46.74 w=41.86 h=60.58
seg1: cx=45.15 cy=76.78 w=20.76 h=21.55
seg2: cx=94.20 cy=93.18 w=64.64 h=65.14
seg3: cx=134.04 cy=129.76 w=58.95 h=67.44From the rendered contours and this ordering, the engraving reads as four characters: f + apostrophe + G + S, i.e. f'GS. The annoying part was that thin-outline OCR kept hallucinating symbols until the contours were rendered/finally interpreted in the right way.

With the required prefix, the final flag is apoorvctf{f'GS}.
Solution
# solve.py
from pathlib import Path
import struct
b = Path("controller_fw.bin").read_bytes()
key = b[0x0C18:0x0C20] # f1 4c 3b a7 2e 91 c4 08
o = 0x1000 + 12
segs = []
for _ in range(4):
ln = struct.unpack_from("<I", b, o)[0]
sid = b[o + 4]
d = b[o + 5:o + 5 + ln]
o += 5 + ln
pt = bytes(c ^ key[i % 8] for i, c in enumerate(d))
segs.append((sid, pt))
full = b"".join(pt for sid, pt in sorted(segs))
Path("decoded_keyonly_full.nc").write_bytes(full)
print("decoded written to decoded_keyonly_full.nc")
print("The engraved text reads: f'GS")
print("apoorvctf{f'GS}")python solve.pydecoded written to decoded_keyonly_full.nc
The engraved text reads: f'GS
apoorvctf{f'GS}