Overview
We get keyboard.pcap, a capture of USB traffic with one keyboard on the bus. The goal is to recover what was typed.
Looking at the capture
Open it in Wireshark, or list it with tshark:
$ tshark -r keyboard.pcap -c 4
1 0.000000 host → 1.7.1 USB 64 URB_INTERRUPT in
2 0.000428 1.7.1 → host USB 72 URB_INTERRUPT in
3 0.009939 host → 1.7.1 USB 64 URB_INTERRUPT in
4 0.010727 1.7.1 → host USB 72 URB_INTERRUPT in
A USB keyboard reports keypresses over an interrupt-IN endpoint. The host submits an empty IN request (the 64-byte frames, host → device) and the keyboard answers with the data (the 72-byte frames, device → host). The 8 extra bytes on the device-to-host frames are the HID keyboard report.
The report layout is fixed:
byte 0 modifier bitmap (bit1 = left shift, bit5 = right shift)
byte 1 reserved (0)
bytes 2-7 up to six keycodes currently held down
For normal typing only one key is down at a time, so byte 2 is the key and the rest are zero. A frame of all zeros means every key was released.
Pulling out the reports
The keyboard data lands in the usb.capdata field. Grab every report:
$ tshark -r keyboard.pcap -Y usb.capdata -T fields -e usb.capdata
0000000000000000
0000000000000000
0000000000000000
0200160000000000 <- modifier 0x02 (shift), key 0x16
0000000000000000
00000e0000000000 <- key 0x0e
00000c0000000000 <- key 0x0c
...
Now translate the keycodes with the USB HID usage table (Keyboard/Keypad page, 0x07). 0x04–0x1d are a–z, 0x1e–0x27 are 1–0, and so on:
02 00 16= shift +0x16=S00 00 0e=0x0e=k00 00 0c=0x0c=i
So the first three keys are Ski, and it keeps going as SkillBit{....
What makes it slightly fiddly
There’s an all-zero report after every keypress (the key coming back up). Ignore those or your output doubles up. There’s also a single 0x2a (Backspace) partway through, where the typist hit a wrong key and deleted it before retyping. Skip the backspace and you end up with a stray character in the middle of 3v3ry and a flag that won’t validate, so handle it the way a keyboard does and drop the previous character.
Solve script
import subprocess, sys
PCAP = sys.argv[1] if len(sys.argv) > 1 else "keyboard.pcap"
KEYS = { # keycode -> (unshifted, shifted)
0x04:('a','A'),0x05:('b','B'),0x06:('c','C'),0x07:('d','D'),0x08:('e','E'),
0x09:('f','F'),0x0a:('g','G'),0x0b:('h','H'),0x0c:('i','I'),0x0d:('j','J'),
0x0e:('k','K'),0x0f:('l','L'),0x10:('m','M'),0x11:('n','N'),0x12:('o','O'),
0x13:('p','P'),0x14:('q','Q'),0x15:('r','R'),0x16:('s','S'),0x17:('t','T'),
0x18:('u','U'),0x19:('v','V'),0x1a:('w','W'),0x1b:('x','X'),0x1c:('y','Y'),
0x1d:('z','Z'),0x1e:('1','!'),0x1f:('2','@'),0x20:('3','#'),0x21:('4','$'),
0x22:('5','%'),0x23:('6','^'),0x24:('7','&'),0x25:('8','*'),0x26:('9','('),
0x27:('0',')'),0x2c:(' ',' '),0x2d:('-','_'),0x2e:('=','+'),0x2f:('[','{'),
0x30:(']','}'),0x31:('\\','|'),0x33:(';',':'),0x34:('\'','"'),0x35:('`','~'),
0x36:(',','<'),0x37:('.','>'),0x38:('/','?'),
}
rows = subprocess.run(["tshark","-r",PCAP,"-Y","usb.capdata",
"-T","fields","-e","usb.capdata"], capture_output=True, text=True).stdout.split()
out, prev = [], None
for h in rows:
d = bytes.fromhex(h.replace(":",""))
mod, key = d[0], d[2]
if key == 0: prev = None; continue
if key == prev: continue
prev = key
if key == 0x2a:
if out: out.pop()
elif key in KEYS:
out.append(KEYS[key][1 if mod & 0x22 else 0])
print("".join(out))
This will get us the flag.
solve.py
#!/usr/bin/env python3
"""
Key Evidence solve script.
Pull the 8-byte USB HID keyboard reports out of the capture with tshark, then
translate scancodes back into the text that was typed. Each report is
[modifier, reserved, key1..key6]; we only need the first keycode. The shift bit
(0x02 / 0x20) selects the shifted character, and 0x2a is Backspace.
Usage: python3 solve.py [keyboard.pcap]
"""
import subprocess
import sys
PCAP = sys.argv[1] if len(sys.argv) > 1 else "../dist/keyboard.pcap"
# HID Usage ID -> (unshifted, shifted)
KEYS = {
0x04: ('a', 'A'), 0x05: ('b', 'B'), 0x06: ('c', 'C'), 0x07: ('d', 'D'),
0x08: ('e', 'E'), 0x09: ('f', 'F'), 0x0a: ('g', 'G'), 0x0b: ('h', 'H'),
0x0c: ('i', 'I'), 0x0d: ('j', 'J'), 0x0e: ('k', 'K'), 0x0f: ('l', 'L'),
0x10: ('m', 'M'), 0x11: ('n', 'N'), 0x12: ('o', 'O'), 0x13: ('p', 'P'),
0x14: ('q', 'Q'), 0x15: ('r', 'R'), 0x16: ('s', 'S'), 0x17: ('t', 'T'),
0x18: ('u', 'U'), 0x19: ('v', 'V'), 0x1a: ('w', 'W'), 0x1b: ('x', 'X'),
0x1c: ('y', 'Y'), 0x1d: ('z', 'Z'),
0x1e: ('1', '!'), 0x1f: ('2', '@'), 0x20: ('3', '#'), 0x21: ('4', '$'),
0x22: ('5', '%'), 0x23: ('6', '^'), 0x24: ('7', '&'), 0x25: ('8', '*'),
0x26: ('9', '('), 0x27: ('0', ')'),
0x2c: (' ', ' '), 0x2d: ('-', '_'), 0x2e: ('=', '+'), 0x2f: ('[', '{'),
0x30: (']', '}'), 0x31: ('\\', '|'), 0x33: (';', ':'), 0x34: ('\'', '"'),
0x35: ('`', '~'), 0x36: (',', '<'), 0x37: ('.', '>'), 0x38: ('/', '?'),
}
BACKSPACE = 0x2a
rows = subprocess.run(
["tshark", "-r", PCAP, "-Y", "usb.capdata",
"-T", "fields", "-e", "usb.capdata"],
capture_output=True, text=True, check=True).stdout.split()
out = []
prev = None
for hexrow in rows:
data = bytes.fromhex(hexrow.replace(":", ""))
if len(data) < 3:
continue
mod, key = data[0], data[2]
if key == 0: # key release: nothing held
prev = None
continue
if key == prev: # ignore auto-repeat of a held key
continue
prev = key
if key == BACKSPACE:
if out:
out.pop()
continue
if key in KEYS:
shifted = bool(mod & 0x22) # left or right shift
out.append(KEYS[key][1 if shifted else 0])
print("".join(out))