HTB: Trace Challenge
Trace - HackTheBox Challenge Writeup
Challenge Information
| Field | Value |
|---|---|
| Name | Trace |
| Category | Hardware |
| Difficulty | Medium |
| Author | d3vn0mi |
Description
One of our embedded devices has been compromised. It was flashing a message on the debug matrix that was too fast to read, although we managed to capture one iteration of it. We must find out what was displayed. To help you with your mission, we will also provide you with the fabrication files of the PCB module the matrix was on.
The challenge provides a traces.csv logic-analyzer capture of 16 GPIO lines alongside a full set of Gerber/Excellon PCB fabrication files for a Raspberry Pi 3B+ HAT carrying a common-anode 8×8 LED matrix. The challenge header hinted at forensics, but the actual artifact set puts this squarely in the Hardware category — the real work is reverse-engineering the PCB itself to figure out how the microcontroller’s GPIOs are wired to the matrix, since the mapping is intentionally scrambled by the board layout rather than being a simple 1:1 pinout.
Solution
The solve breaks into two independent problems that have to be joined together:
- Signal analysis —
traces.csvcontains 336 samples across 16 GPIO channels. Splitting the channels by behavior shows 8 lines are strictly one-hot with a period of 8 samples (these are the row/anode strobe lines scanning the matrix), while the other 8 carry the actual pixel data and are active-low (common-anode column/cathode lines). 336 samples / 8 rows-per-frame = 42 complete frames, i.e. 42 characters of flashed text. - Netlist recovery — the CSV only tells you which GPIO number toggled, not which physical row or column of the matrix that GPIO drives. The PCB fabrication files (copper layers, solder mask, silkscreen, drill file) had to be parsed as raw Gerber/Excellon geometry to reconstruct which copper pours are physically the same electrical net, then trace each net back from a header pin to a matrix row or column pad. Only once that GPIO→row/column mapping was known could the strobe/data frames from step 1 be decoded into actual characters.
The board’s copper layout deliberately misroutes the traces (rows and columns are not in GPIO-number order), so the mapping had to be derived from geometry rather than assumed — that scrambling is the actual “puzzle” behind the challenge’s Hardware classification.
Key Steps
Step 1: Locate the real challenge artifacts
The supplied working directory only contained a 0-byte Gerber_BoardOutline.GKO, which made the initial download look broken/incomplete.
# The provided artifact set was incomplete — one Gerber file was 0 bytesstat -c '%y %s %n' ./Gerber_module/Gerber_BoardOutline.GKO# -> 0 bytes, clearly a corrupted/truncated downloadCross-referencing the solve-session UUID against the HTB challenge catalog confirmed this was challenge Trace, in the Hardware category (not Forensics), and the archive was re-pulled directly from the HTB API to get an intact copy:
# Re-download the full challenge zip via the HTB API clientimport syssys.path.insert(0, "/app/lib")from htb_api import HTBClient
c = HTBClient()data = c.download_challenge(challenge_id=221) # "Trace"open("/tmp/dl/221_trace.zip", "wb").write(data)# Extract — HTB challenge archives are password-protected with "hackthebox"mkdir -p /tmp/dl/trace && cd /tmp/dl/traceunzip -o -P hackthebox ../221_trace.zip# -> traces.csv + 11 Gerber/Excellon fabrication files for a# Raspberry Pi 3B+ HAT with a common-anode 8x8 LED matrixStep 2: Parse the GPIO trace capture
traces.csv is a 336-row × 16-column logic capture. Splitting columns by toggling behavior separates the 8 one-hot row/strobe signals (period 8) from the 8 active-low data/column signals:
import csv
rows = list(csv.reader(open("traces.csv")))hdr = [h.strip() for h in rows[0]]data = rows[1:]
# 8 channels are strictly one-hot with period 8 -> row/anode strobes# the other 8 carry pixel data, active LOW -> column/cathode linesstrobe_cols, data_cols = [], []for i, name in enumerate(hdr): col = [int(r[i]) for r in data] onehot_period8 = all(sum(col[j:j+8]) == 1 for j in range(0, len(col), 8)) (strobe_cols if onehot_period8 else data_cols).append(name)
# 336 samples / 8 rows per frame = 42 frames -> 42 characters flashedn_frames = len(data) // 8print(n_frames, strobe_cols, data_cols)Step 3: Reconstruct the copper netlist from Gerber/Excellon files
The critical step: without knowing which GPIO physically drives which matrix row/column, the captured bits are meaningless. A minimal Gerber (RS-274X) and Excellon drill parser was written to extract copper flash/trace geometry per layer, then a union-find pass merged overlapping primitives — across both copper layers, joined at every plated-through-hole — into electrical nets:
# gerb.py — minimal Gerber aperture-flash / draw parser (D-code geometry only)def parse_gerber(path): """Return list of primitives: (kind, x, y, [w,h/dia], aperture, layer)""" ...
# net.py — union-find over copper geometry to recover electrical netsimport math
top = parse_gerber("Gerber_TopLayer.GTL")bot = parse_gerber("Gerber_BottomLayer.GBL")holes = parse_excellon("Drill_PTH.TXT")
def intersects(a, b): # capsule/circle intersection test between two copper primitives ...
parent = {}def find(x): while parent.get(x, x) != x: x = parent[x] return x
def union(a, b): parent[find(a)] = find(b)
# Merge same-layer overlapping copper, then bridge top/bottom via PTH holesfor layer in (top, bot): for i, a in enumerate(layer): for b in layer[i+1:]: if intersects(a, b): union(id(a), id(b))for hole in holes: bridge_layers_at(hole, top, bot, union)Gotcha: the 0.2441″ mounting-hole copper pour physically overlapped header pins 1–4 and several escape traces, incorrectly shorting ~5 distinct signal nets into a single 179-primitive blob during union-find. Filtering that specific aperture out of the pass restored a clean separation of exactly 8 row nets + 8 column nets:
# Filter out the offending mounting-hole aperture before unioningdef flt(primitives): return [p for p in primitives if p[5] != MOUNTING_HOLE_APERTURE]
top = flt(parse_gerber("Gerber_TopLayer.GTL"))bot = flt(parse_gerber("Gerber_BottomLayer.GBL"))# re-run union-find -> exactly 8 row nets, 8 column nets, no cross-shortsPin 1 orientation was recovered from the silkscreen layer: a square marker enclosing the plated hole at (1.0764, 2.116) identifies odd header pins as running along y = 2.116, which anchors the header-pin-number → net mapping to the physical GPIO numbering used in traces.csv.
Step 4: Combine the netlist with the trace capture to decode characters
With row-net → GPIO and column-net → GPIO both known from the netlist, each of the 42 captured frames could be mapped onto physical matrix row/column coordinates and rendered as an 8×8 bitmap, then decoded to ASCII:
gpio_to_row = {...} # from netlist: strobe GPIO -> physical matrix row 0-7gpio_to_col = {...} # from netlist: data GPIO -> physical matrix col 0-7
chars = []for f in range(n_frames): bitmap = [[0]*8 for _ in range(8)] for sample in range(8): r = data[f*8 + sample] row_gpio = next(g for g in strobe_cols if int(r[hdr.index(g)]) == 1) row = gpio_to_row[row_gpio] for col_gpio in data_cols: col = gpio_to_col[col_gpio] # active LOW: 0 = lit pixel bitmap[row][col] = 1 if int(r[hdr.index(col_gpio)]) == 0 else 0 chars.append(glyph_to_char(bitmap)) # match against an 8x8 font table
message = "".join(chars)print(message) # -> the flag flashed on the debug matrixStep 5: Submit and verify the flag
import syssys.path.insert(0, "/app/lib")from htb_api import HTBClient
c = HTBClient()flag = "HTB{REDACTED}"result = c.submit_flag(challenge_id=221, flag=flag)print(result) # -> {'message': 'Congratulations!'}Tools Used
| Tool | Purpose |
|---|---|
Python 3 (csv) | Parsing the 336×16 traces.csv GPIO logic capture |
| Custom Gerber (RS-274X) parser | Extracting copper-layer geometry from .GTL/.GBL fabrication files |
| Custom Excellon drill parser | Locating plated-through-holes to bridge layers into electrical nets |
| Union-find (disjoint-set) | Merging overlapping copper primitives into distinct electrical nets |
| PIL / matplotlib | Rendering Gerber/silkscreen layers to images for visual verification |
HTB API client (htb_api.py) | Re-downloading the challenge archive and submitting/verifying the flag |
Key Learnings
- Fabrication files are a first-class artifact, not decoration. In hardware challenges, Gerber/Excellon files aren’t just “nice to have” for context — they can encode the actual puzzle (a scrambled pin mapping) that’s required to make sense of a separate data capture.
- Corrupted/incomplete artifacts are worth re-fetching, not working around. A 0-byte file in the provided directory was a red flag that the download was broken, not that the file was intentionally empty; re-pulling from the source API resolved it.
- Copper geometry overlap can create false electrical shorts during netlist extraction. Non-signal copper pours (mounting holes, ground fills) can straddle multiple real signal traces and must be explicitly filtered out of a union-find pass, or they’ll merge unrelated nets together.
- Silkscreen matters for orientation. Pin-1 markers on the silk layer were the only reliable way to anchor the recovered netlist to the actual GPIO numbering used by the microcontroller/logic analyzer.
- Signal classification by statistical behavior (one-hot period-N vs. general active-low data) is a fast way to separate strobe/scan lines from data lines in an unlabeled multi-channel capture, before any hardware mapping is even known.
Flag
HTB{REDACTED}