Keyboard
Decoding USB Low-Speed off the raw wire, then replaying every keystroke - including the edits that build the real flag.
NNS{typ1ng!_4way@_th3/_0n_USB?_"k3ybofla[MY]_gg)4r$"} Challenge
“I thought I was writing confidentially on my USB keyboard, but it seems like someone has been analysing my bus.” (author: simen)
We are given a Saleae logic-analyzer capture, exported to VCD (keyboard_export.vcd), of the raw D+/D- signal lines of a USB keyboard while someone typed. The two single-bit channels Channel_1 and Channel_2 are the differential USB data lines, unlabeled. The task is to fully decode the USB bus traffic at the electrical level, recover the HID keyboard reports, and reconstruct the characters that were typed to find a flag of the form NNS{…}.
Approach
-
VCD parsing. Wrote a small parser (decode.py) that reads “$var wire 1
” declarations and walks ”# -
Speed/idle determination. Histogrammed the inter-edge intervals: clear clusters appeared near multiples of ~666.7 ns (680, 1320, 1960, 2000 ns, …), matching the nominal USB Low-Speed bit period of 1/1.5MHz = 666.67 ns - not the 83.3 ns period expected for Full-Speed. This was confirmed further by a ~1.33 us single-ended-zero (SE0) “keep-alive” strobe repeating every exactly 1 ms near the start of the capture, which is the Low-Speed-only keep-alive EOP host devices send when idle (Full-Speed uses SOF tokens instead). At time 0 (bus idle) Channel_1 was high and Channel_2 was low; since idle/J for Low-Speed is D+=0, D-=1, this fixed the channel mapping as D+ = Channel_2, D- = Channel_1.
-
Differential decode. Built a merged timeline of (D+, D-) and classified every point as J (idle/‘1’ rest state), K, or SE0. The two channels’ edges for the same logical transition were offset by tens to a few hundred ns (channel skew in the capture), producing brief spurious SE0/SE1 glitches; these were filtered by dropping any SE0/SE1 segment shorter than 300 ns (well under the ~1.3 us real EOP SE0 and far above the skew glitches), then re-merging adjacent identical states (decode2.py).
-
NRZI decoding + bit-stuff removal. For each cleaned J/K run of duration D, the number of bits it represents is round(D / bit_period); the run’s first bit is always a ‘0’ (an NRZI transition occurred to start the run) and the remaining bits are all ‘1’ (no further transitions). This directly reconstructs the pre-destuffing NRZI bitstream without needing a separate oversampling/clock-recovery step. Bit destuffing then walks the bitstream, counts consecutive ‘1’ bits, and discards the bit immediately following six consecutive 1s (the mandatory stuffed 0) (decode4.py: segment_to_bits, destuff).
-
Packet framing. USB End-Of-Packet markers were located as an SE0 run of ~2 bit widths followed by a return to J; these delimit packets. Each packet’s bits were grouped into bytes (LSB-first). The first byte must equal the standard SYNC pattern 0x80; the following byte is the PID, whose low nibble and complemented high nibble were checked for consistency. Out of 191132 raw EOP-delimited candidates, 19713 packets passed SYNC+PID validation: 18980 IN tokens (repeated every ~8 ms - standard low-speed keyboard polling interval), 362 DATA0 and 363 DATA1 packets (the actual HID reports), and a handful of ACK/NAK handshakes.
-
HID report extraction. Each DATA0/DATA1 payload (minus the trailing 16-bit CRC) is an 8-byte USB HID Boot Keyboard report: byte0=modifier bitmask, byte1=reserved, bytes2-7=up to six currently-pressed HID usage IDs (0x00 = none). 725 reports were recovered in time order.
-
Keystroke reconstruction. Compared each report’s key set to the previous report’s to find newly-pressed keys (ignoring keys that were already held, i.e. only rising edges produce a character). Decoding the very first English/Norwegian words this way (e.g. “brXd og vann”) only made sense with a Norwegian physical keyboard layout, not a US one, so a Norwegian usage-ID -> character table was built (letters a-z unchanged; number row unshifted 1234567890, shifted !”#\xa4%&/()=, AltGr row 2=@ 3=\xa3 4=$ 5=€ 7={ 8=[ 9=] 0=}; usage 0x2D ”+/? ” key, 0x38 ”-/_” key, 0x2F=\xe5/\xc5, 0x33=\xf8/\xd8, 0x34=\xe6/\xc6, etc.), using the Shift (bit1/bit5) and Right-Alt/AltGr (bit6) bits of the modifier byte. With this table the surrounding text decoded perfectly to a real Norwegian folk-song fragment (“tore tang / ein gammal mann / … / han som leve av gammalt brod og vann / kor han komme fra vett bare han”), confirming the layout and the whole decode pipeline end to end.
The keystrokes also included Backspace/Delete and, later in the capture, arrow-key/Home/End cursor navigation, so a small text-buffer simulator (insert-at-cursor, Left/Right/Home/End move the cursor, Backspace/Delete remove characters) was used to faithfully replay the session (decode5.py: reports_to_buffer).
IMPORTANT CORRECTION (previous version of this writeup was wrong): an earlier pass stopped as soon as a well-formed NNS{…} substring appeared on screen, at NNS{typ1ng!_4way@_th3/0n_USB?“k3yboard”}, and dismissed everything the typist did afterward as “irrelevant scrambling”. That flag was submitted and rejected as incorrect.
Replaying the ENTIRE 725-report keystroke stream to the very end (decode6.py, checkpointing the buffer after every single keystroke) shows that what happens next is not random mashing: it’s a second, equally deliberate, single-character-at-a-time edit pass with the cursor navigated by exact Home/End/Left/Right counts (no Ctrl-word- jump modifiers involved, confirmed by inspecting the raw modifier byte on every navigation report - decode7.py). Step by step, inside the word “k3yboard”:
- cursor is moved back between “bo” and “ard” and “fla” is typed, then further left to before “gg” and “[MY]” is inserted (AltGr+8 -> ’[’, Shift+m -> ‘M’, Shift+y -> ‘Y’, AltGr+9 -> ’]’), then “gg)” is typed (Shift+9 -> ’)’, Shift+- -> ''),
- the trailing “_a” from the original “…ard” is fixed with a Backspace + ‘4’ (removing the leftover underscore and inserting ‘4’), then Delete removes the original ‘a’,
- the cursor is moved onto the original trailing ‘d’, AltGr+4 (’”. Checkpointing the buffer at many points through this edit sequence (and confirming nothing else in the capture touches this region again afterwards, all subsequent keystrokes only continue typing the unrelated folk-song text below the flag) shows this is stable: the FINAL buffer state, after all 725 reports are replayed, contains exactly one balanced NNS{…} substring: NNS{typ1ng!_4way@_th3/0n_USB?“k3ybofla[MY]_gg)4r$”} This - the fully replayed, final on-screen text - is the actual flag; the earlier “k3yboard” version was only an intermediate, not-yet-corrected state that a typist deliberately went back and edited, exactly the way they had already done for every other part of the flag.
SECOND REJECTION - EXHAUSTIVE RE-VERIFICATION (this pass)
The flag above (NNS{typ1ng!_4way@_th3/0n_USB?“k3ybofla[MY]_gg)4r"}) was submitted and rejected a second time. Because the "k3ybofla[MY]_gg)4r” segment looks less obviously “typed by a human” than the rest of the flag, we re-verified the entire second edit pass completely from scratch, distrusting the previous symbol table and cursor-tracking instead of re-using it blindly. Scripts: decode8_raw_dump.py, decode9_overtype.py, decode10_full.py, decode11_crc.py, decode12_final_verify.py (all in misc/keyboard/).
-
Raw HID bytes for the whole disputed region (steps ~149-222, i.e. from the cursor re-entering the “k3yboard” word after the initial clean pass, through to the last keystroke that touches it) were dumped directly: modifier byte + usage ID for every new-key event. Nothing here required trusting the old table - the bytes were re-read from decode_packet() output directly.
-
A GENUINE GAP was found and fixed: the packet validator (decode3.py/ decode4.py) only ever checked the SYNC byte (0x80) and the PID nibble/ complement-nibble consistency - it never verified the USB CRC16 that is transmitted at the end of every DATA0/DATA1 packet. That means none of the 725 recovered HID reports had ever actually been checked for bit-level correctness. We implemented CRC-16/USB (poly 0x8005, reflected form 0xA001, init 0xFFFF, xorout 0xFFFF, LSB-bit-first per byte) computed over the 8-byte report field ONLY (the PID byte is NOT covered by the packet CRC16 in the USB spec - it has its own 4-bit nibble-complement check instead; an earlier attempt at this check that mistakenly included the PID byte in the CRC made all 725 packets fail, which is what led us to notice and fix the bug). With the corrected CRC computation: 725 / 725 HID reports pass USB CRC16 verification, with zero exceptions, across the ENTIRE capture including every single report in the disputed “k3yboard” edit region. A 16-bit CRC passing on 725/725 independent packets is about as strong a guarantee of byte-level correctness as this capture can offer - this closes off “maybe one report got bit-corrupted and slipped past a weak SYNC+PID-only filter” as an explanation for anything looking odd.
-
The modifier-byte bit layout used by the decoder (bit1=LShift=0x02, bit5=RShift=0x20, bit6=RAlt/AltGr=0x40) was independently re-checked against the standard USB HID Boot Keyboard Protocol modifier byte layout (bit0=LCtrl,1=LShift,2=LAlt,3=LGUI,4=RCtrl,5=RShift,6=RAlt, 7=RGUI) - it matches exactly, and it’s the same interpretation already load-bearing for the undisputed, sense-making parts of the flag (e.g. the capital “NNS”, “USB” via LShift, ”{” via AltGr+7, ”}” via AltGr+0).
-
The Norwegian AltGr digit-row mapping (2=@, 3=£, 4=, 5=€, 7={, 8=[, 9=], 0=}) was independently re-checked against the standard Norwegian (nb-NO) physical keyboard layout and matches. It is also internally self-consistent: usage 0x1F (digit "2") + AltGr already produces the '@' used in the undisputed "4way@"; usage 0x24 (digit "7") + Shift already produces the '/' used in the undisputed "th3/"; usage 0x27 (digit "0") + AltGr produces the flag's own closing '}'. The specific usage IDs used in the disputed region - 0x25 (digit "8") AltGr->'[', 0x26 (digit "9") AltGr->']', 0x21 (digit "4") AltGr->'’ - all follow the exact same, already-validated table.
-
Cursor tracking was traced character-by-character through the ENTIRE 361-keystroke capture (decode10_full.py), not just the disputed region. Finding: Up/Down arrow keys (HID usage 0x51/0x52) are NEVER pressed even once in the whole capture. This matters because the buffer simulator’s Up/Down handling was only ever a “best-effort” approximation (per its own code comment) - but since it’s simply never exercised, it cannot be the source of any mis-attribution. Only Left/Right/Home/End/Backspace/ Delete are used, all of which are modeled exactly (not approximated).
-
HID usage 0x49 (Insert) is pressed EXACTLY ONCE in the entire capture, at step 210, in the middle of the disputed region, immediately before the AltGr+4 ('' at step 211 overwrites the original ‘d’ directly, and the following Delete at step 212 then deletes the very next character - which turns out to be the flag’s own closing double-quote (”) - producing an UNBALANCED result with an opening quote but no closing quote: NNS{typ1ng!_4way@_th3/0n_USB?“k3ybofla[MY]_gg)4r$} This is a worse, structurally broken result compared to the already-proposed flag (mismatched quotes), so the Overtype hypothesis is rejected in favor of the simpler “Insert had no effect” interpretation, which is also what the already-proposed flag assumes.
-
New corroborating discovery (not noticed in the previous writeup pass): the edit doesn’t just scramble “k3yboard” - it is built around the Norwegian word “flagg” (bokmål for “flag”). The typist first rough-drafts “flagg)” between “k3ybo” and “ard” (f-l-a-g-g-)-, i.e. literally typing the word “flagg”), then goes back and inserts “[MY]” in the middle of that exact word, splitting “flagg” into “fla” + “[MY]” + “gg” = “fla[MY]_gg”. Strip the bracketed insertion back out and you get “flagg” again - i.e. this is a deliberate pun: “MY” flag spliced into the middle of the Norwegian word for “flag” itself. That is exactly the same kind of deliberate wordplay as “typ1ng!”, “4way@”, “th3/”, “0n”, “USB?” elsewhere in the flag, not random mashing - so the “this looks like a jumble of raw symbols” concern that prompted this re-check is, on inspection, actually a decode-independent pun, not a symptom of a bug.
-
A full from-scratch re-implementation of the whole pipeline (decode12_final_verify.py), gated on the CRC16 check from step 2 (i.e. any report that failed CRC would have been silently discarded - none were), reproduces character-for-character the exact same final flag as the previous pass: NNS{typ1ng!_4way@_th3/0n_USB?“k3ybofla[MY]_gg)4r$”}
CONCLUSION: after re-deriving the disputed region from raw HID bytes independently, adding a USB CRC16 integrity check that the original pipeline was missing (725/725 reports pass with the fix), re-validating the modifier-bit and Norwegian-AltGr tables against external references, confirming Up/Down navigation is unused (so the “best-effort” approximation is a non-issue), explicitly testing and rejecting the Overtype-mode alternative for the one Insert keypress, and finding an intentional Norwegian “flagg”/“[MY]” pun that explains why the region looks “jumbled”, we could NOT find any further decode error. The flag string is unchanged and is now confirmed via an independent, CRC-gated, from-scratch re-derivation of the entire keystroke stream:
NNS{typ1ng!_4way@_th3/_0n_USB?_"k3ybofla[MY]_gg)4r$"}
If this is rejected a third time, the bug (if any) is very unlikely to be in the raw USB/HID decode layer itself (that layer is about as thoroughly validated as this capture allows) - worth double-checking instead: exact copy/paste of the flag into the submission form (no smart-quote substitution, no trailing whitespace/newline), whether the challenge expects the literal ASCII ’”’ / ’$’ / ’[’ / ’]’ / ’)’ characters as typed (vs. some normalized form), and whether there might be a second/different capture or hint file in the challenge handout that changes which buffer state is “the” answer.
Flag
NNS{typ1ng!_4way@_th3/0n_USB?“k3ybofla[MY]_gg)4r$”} (re-confirmed via independent CRC16-gated re-derivation; see “SECOND REJECTION - EXHAUSTIVE RE-VERIFICATION” above - unchanged from the previous pass, high confidence, but still rejected twice as of this writing, so treat with appropriate caution and see the debugging suggestions above if it fails again.)