Flash CTF – Key Evidence

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). 0x040x1d are az, 0x1e0x27 are 10, and so on:

  • 02 00 16 = shift + 0x16 = S
  • 00 00 0e = 0x0e = k
  • 00 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))