Dot Matrix

Parse the I2C capture → map registers to LEDs → rebuild frames → fix the row order → recover the true scroll period

2026.09.30 NNS CTF 2026 146 pts Misc
FLAG 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 bus
  • dot-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.
#hardware