1290 words
6 minutes
ApoorvCTF 2026 - deaDr3con'in - Hardware Writeup

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.bin
controller_fw.bin: data

The 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.bin
00000be0: 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.py
key8 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 0

That clean decode was the satisfying part because the core crypto/obfuscation turned out to be just the right XOR key at the right offset.

smile

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.py
sid  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.44

From 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.

tableflip

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.py
decoded written to decoded_keyonly_full.nc
The engraved text reads: f'GS
apoorvctf{f'GS}
ApoorvCTF 2026 - deaDr3con'in - Hardware Writeup
https://blog.rei.my.id/posts/102/apoorvctf-2026-deadr3con-in-hardware-writeup/
Author
Reidho Satria
Published at
2026-03-10
License
CC BY-NC-SA 4.0