Dot Matrix
Parse the I2C capture → map registers to LEDs → rebuild frames → fix the row order → recover the true scroll period
NNS{FL4G-SCR0LL1NG-PA5T-0N-TH3-DOT-MATR1X} Author: simen
Flag: NNS{FL4G-SCR0LL1NG-PA5T-0N-TH3-DOT-MATR1X}
Challenge
We’re given three files:
as1130.logicdata— a Saleae logic analyzer capture of an I2C busdot-matrix.png— a photo of the physical hardware (a 5x7-ish LED dot matrix display driven by an AS1130 LED matrix driver IC)pinout.txt— pin mapping notes for the board
“I programmed a dot matrix display to print the flag. Can you check that I am sending the right commands?”
The goal: passively reconstruct whatever text was scrolling across the LED matrix, purely from the I2C bus traffic.
Step 1 — Parse the I2C capture
The AS1130 is addressed over I2C and exposes its LED driver state as a grid of
(segment, rank) registers — each register write turns a set of LEDs on a
given segment on or off. We parsed the logic analyzer capture into a
time-ordered list of register writes.
Step 2 — Map registers to physical LEDs
Using pinout.txt and the board photo, we built a (segment, rank) -> (row, col) mapping so that we could replay the register writes as a physical LED
grid over time. A handful of cells were initially mis-mapped (pinout entries
where anode/cathode had been swapped); these were caught because they never
lit up correctly in any replayed frame and were fixed by inspection.
Step 3 — Reconstruct frames
Replaying every register write in order and snapshotting the full grid after each meaningful update gives 705 frames of a 7-row x 17-column physical display, scrolling text through the visible window over time.
Step 4 — Fix the row order
The AS1130 drives LEDs through a small number of current-source “chains”, and initially we didn’t know which physical row each chain corresponded to. We guessed an ordering using pairwise correlation between adjacent-looking rows (a Hamiltonian-path optimization over a correlation matrix), which got most letters readable but left a systematic bias (~75% match rate against a reference font).
The real fix: once a handful of words were legible (SCROLLING, MATRIX,
…), we used them as a crib — brute-forced every permutation of the 7 rows
and scored each one against a standard 5x7 font using the letters we were
already confident about. One permutation scored 94%+ (essentially every
letter now matched a real font glyph exactly), versus 75% for our original
guess. That was the correct row order.
best = max(itertools.permutations(range(7)), key=lambda p: score_against_font(p))
Step 5 — Find the true scroll period
Consecutive raw frames did not look like simple 1-column shifts of each
other — the naive assumption “one shift every frame” was wrong. Checking
frame[i] vs frame[i+k] shifted by one column for various k showed a
near-perfect match (mean Hamming distance ≈ 0) specifically at k=3, and
only when i % 3 == 2 (the last frame of each group of 3). So: the display
advances by exactly one column every 3 raw capture frames, and only the 3rd
sub-frame is the fully-settled state (the AS1130 appears to write a column in
multiple I2C bursts, and the earlier 2 frames of each group are mid-write).
We verified this exhaustively — of 234 checked transitions, 233 had zero error, confirming the pixel data itself was clean; any remaining ambiguity was purely a font-recognition problem, not a data problem.
Step 6 — Assemble the strip
Taking column 0 (the newly-entering column) from every “settled” (phase-2) frame and concatenating them in time order reconstructs the full scrolled message as one long 7-row bitmap image.
Step 7 — Segment into characters
Fully-blank columns separate individual glyphs. Width-4 blank-except-bottom-row
segments turned out to be the - (hyphen) word separator; two width-3 glyphs
turned out to be mirror images of each other — the opening { and closing }
of the flag (the capture happened to start and end almost exactly on the
brace characters, so the literal NNS prefix wasn’t even in the capture).
Step 8 — Read it, and hit the leetspeak twist
Every letter mostly matched a standard 5x7 font glyph pixel-for-pixel — except a handful that matched a digit with zero error instead:
i.e. the scrolling text is written in leetspeak, and the font renders those digits with a near-identical glyph to the letter they’re substituting — which is exactly what made this deceptively hard to OCR from pixels alone: statistically, “closest standard glyph” pointed at the letter, when the true rendered content was the digit.
The other trap: the word separator renders identically to an underscore-shaped
glyph (a single row lit along the bottom of the cell) but is actually meant to
be read as a hyphen in the final flag string, not _.
Final message
FLAG-SCR0LL1NG-PA5T-0N-TH3-DOT-MATR1X
wrapped in NNS{...} (the true opening { was inferred, since capture
started right at the brace).
Flag
NNS{FL4G-SCR0LL1NG-PA5T-0N-TH3-DOT-MATR1X}
Lessons learned
- When brute-forcing dictionary words against noisy OCR fails completely even with high-confidence individual letters, question the format assumptions (separator character, prefix) before endlessly varying the content.
- A “94% match against a reference font” is a huge tell that a small number of systematic bit positions (row order, in this case) are still wrong — don’t settle for “good enough,” brute-force the remaining permutation space if it’s small.
- Custom pixel fonts that reuse near-identical glyphs for a letter and its leetspeak digit substitute are a great way to make an otherwise-easy OCR problem land right on the “so close, but always wrong” trap.