July 9, 2026

USB and HID PCAP Forensics for CTF: Reconstructing Keystrokes

Handed a pcap of captured USB traffic? Extract usb.capdata with tshark, decode the 8-byte HID keyboard report, and reconstruct the typed flag with a Python script.

You have a pcap and the flag was typed: what now?

A forensics challenge hands you a .pcap or .pcapng file. You open it in Wireshark expecting HTTP or DNS, and instead every packet is labeled URB_INTERRUPT in with a source like 1.5.1. This is not a network capture. It is a recording of the USB bus, and somewhere in those interrupt transfers is a keyboard sending one report per keypress. The flag was typed on that keyboard. Your job is to read the keystrokes back out.

Here is the whole solve in three commands. Pull the HID report bytes out with tshark, feed them to a decoder, and read the result:

# 1) extract the 8-byte HID reports, one per line
tshark -r capture.pcap -Y 'usb.capdata' -T fields -e usb.capdata > data.txt
# 2) decode with the script from the 'Decoding with Python' section
python3 hid_decode.py data.txt
# output: picoCTF{us8_15_n0t_s3cur3}

Everything below explains why this works: how to know you are looking at a USB capture, what the 8-byte keyboard report contains, how the HID usage ID maps to a character, and the exact decoder you paste into step 2. The mouse variant (reconstructing a drawn path instead of typed text) is at the end. If you came from the network side of forensics, this is the sibling of the Wireshark and pcap guide; that post covers captures of the wire, this one covers captures of the bus.

Note: USB captures come from tools like usbmon on Linux or USBPcap on Windows. You do not need the original hardware. The traffic is already in the file, and the decode is pure data processing.

How do I know this is a USB capture in the first place?

The tell is in the first few packets. A USB capture has no IP addresses and no Ethernet frames. Instead the columns show USB Request Blocks (URBs), which are the kernel structures that wrap every transfer on the bus. Open the file and look for these signals:

  • The protocol column reads USB, and the info column says things like URB_INTERRUPT in or GET DESCRIPTOR Response DEVICE.
  • Source and destination look like host and 1.5.1 (bus.device.endpoint), not like IP addresses.
  • Early in the capture you see the device enumerate: descriptor requests that reveal the device class. A keyboard or mouse reports bInterfaceClass = 3 (HID, Human Interface Device).

The field that carries the actual keypress payload is usb.capdata (sometimes shown as usbhid.data on newer Wireshark builds). For a standard boot-protocol keyboard, that field is exactly 8 bytes per interrupt-in packet. Click one of the URB_INTERRUPT in packets, expand the Leftover Capture Data node in the packet detail pane, and you will see those 8 bytes.

Tip: To confirm what kind of device you are dealing with, filter for the descriptor exchange with usb.bInterfaceClass == 3 and check usb.bInterfaceProtocol: a value of 1 is a boot keyboard, 2 is a boot mouse. That one field tells you whether to expect the keyboard decoder or the mouse decoder before you extract a single byte.

What is inside the 8-byte HID keyboard report?

A boot-protocol keyboard sends a fixed 8-byte report every time the set of pressed keys changes. The layout never varies, which is what makes the decode mechanical:

byte 0 modifier bitmask (Shift, Ctrl, Alt, GUI; left and right)
byte 1 reserved (always 0x00, ignore it)
byte 2 keycode 1 (HID usage ID of the 1st key held)
byte 3 keycode 2
byte 4 keycode 3
byte 5 keycode 4
byte 6 keycode 5
byte 7 keycode 6

Bytes 2 through 7 hold up to six simultaneously-pressed keys (this is what "6-key rollover" means). In almost every CTF capture only the first keycode slot, byte 2, is ever non-zero, because the typist presses one key at a time. So the report you care about is usually just byte[0] (the modifier) and byte[2] (the key).

One keypress, one report, eight bytes. Read byte 2 for the key and byte 0 for the Shift, and the typed string falls out in order.

The modifier byte in byte[0] is a bitmask. Each bit is one modifier key, and you can OR them together:

0x01 Left Ctrl 0x10 Right Ctrl
0x02 Left Shift 0x20 Right Shift
0x04 Left Alt 0x40 Right Alt
0x08 Left GUI 0x80 Right GUI (GUI = Windows / Command key)

For keystroke recovery the only bit that usually matters is Shift. If byte[0] & 0x22 is non-zero (left or right Shift), you emit the shifted form of the key: a becomes A, 2 becomes @, and so on. Every report where byte 2 is 0x00 is a key release (all keys up) and you skip it.

Warning: Watch for key-repeat and key-up duplication. The keyboard sends a report on press and another on release, and holding a key can emit repeats. Decode only the press events (byte 2 non-zero), and if you see the same keycode in two consecutive reports without an intervening all-zero release, that is a genuine double letter, not noise. Get this wrong and your flag is missing or has doubled characters.

How does a usage ID become a character?

The number in byte 2 is not ASCII. It is a USB HID usage ID from the Keyboard/Keypad page (page 0x07), defined in the USB HID Usage Tables specification. The mapping is its own little alphabet. The letters are contiguous, which is the part worth memorizing:

0x04 = a 0x05 = b ... 0x1d = z (a..z is 0x04..0x1d)
0x1e = 1 0x1f = 2 ... 0x26 = 9 0x27 = 0
0x28 = Enter 0x29 = Esc 0x2a = Backspace 0x2b = Tab
0x2c = Space 0x2d = - 0x2e = = 0x2f = [ 0x30 = ]
0x31 = \ 0x33 = ; 0x34 = ' 0x36 = , 0x37 = . 0x38 = /

Note the quirk in the number row: usage IDs 0x1e through 0x26 are the digits 1 through 9, and 0x27 is 0 (zero comes last, not first). The shifted forms follow the US keyboard layout: Shift plus 0x1e is !, Shift plus 0x1f is @, and so on. A full decoder needs two tables, the base character and the shifted character, indexed by usage ID. The script in the next section ships both.

Key insight: The reason this works at all is that the boot protocol is deliberately rigid. A BIOS has to read a keyboard before any driver loads, so the report format is frozen at 8 bytes with a fixed usage-ID encoding. That rigidity is what lets a forensic analyst, years later, decode a capture with a 40-line script and no knowledge of the specific keyboard.

How do I pull the report bytes out with tshark?

tshark is the command-line Wireshark. The pattern is a display filter to keep only the packets that carry HID data, plus a field extraction to print just the bytes you want. The flags you need are -Y (display filter), -T fields (output mode), and -e (which field to print), all documented in the tshark man page.

# every HID data field, one hex string per line, colon-separated
tshark -r capture.pcap -Y 'usb.capdata' -T fields -e usb.capdata
# example output (each line is one 8-byte report):
00:00:00:00:00:00:00:00
00:00:0f:00:00:00:00:00 <- 0x0f = 'l'
00:00:00:00:00:00:00:00
02:00:13:00:00:00:00:00 <- Shift + 0x13 = 'P'

On some captures the field is named differently. If usb.capdata returns nothing, try usbhid.data, and if you are unsure which exists, ask tshark to show you every field on a sample packet:

# alternative field name on newer dissectors
tshark -r capture.pcap -Y 'usbhid.data' -T fields -e usbhid.data
# dump all field names from packet 30 to find the right one
tshark -r capture.pcap -Y 'frame.number == 30' -T pdml | grep -i 'name='
# filter to a single device + endpoint if multiple HID devices are present
tshark -r capture.pcap -Y 'usb.src == "1.5.1" and usb.capdata' \
-T fields -e usb.capdata
Warning: If two HID devices are on the bus (say a keyboard and a mouse), a bare usb.capdata filter interleaves both streams and your decode turns to garbage. Pin the filter to one device address with usb.src or usb.device_address first. Identify which address is the keyboard from the descriptor exchange you found earlier.

The complete Python HID decoder

This script reads the colon-separated hex from tshark, walks each report, and prints the typed string. It handles Shift, skips release events, and renders Backspace, Enter, Tab, and Space sensibly. Paste it into hid_decode.py and run it against the file from step 1. If you want a deeper grounding in the Python patterns here, the Python for CTF guide covers the byte handling and parsing idioms used throughout.

#!/usr/bin/env python3
import sys
# HID usage ID -> (unshifted, shifted) for the US Keyboard/Keypad page 0x07
HID = {
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', ')'),
0x2d: ('-', '_'), 0x2e: ('=', '+'), 0x2f: ('[', '{'), 0x30: (']', '}'),
0x31: ('\\', '|'), 0x33: (';', ':'), 0x34: ("'", '\"'),
0x35: ('`', '~'), 0x36: (',', '<'), 0x37: ('.', '>'), 0x38: ('/', '?'),
0x2c: (' ', ' '),
}
SPECIAL = {0x28: '\n', 0x2a: '[BKSP]', 0x2b: '\t'}
out = []
for line in open(sys.argv[1]):
line = line.strip()
if not line:
continue
b = bytes.fromhex(line.replace(':', ''))
if len(b) < 3:
continue
mod, key = b[0], b[2]
if key == 0x00: # all keys up: a release event, skip it
continue
shift = bool(mod & 0x22) # left (0x02) or right (0x20) Shift
if key in HID:
out.append(HID[key][1 if shift else 0])
elif key in SPECIAL:
out.append(SPECIAL[key])
text = ''.join(out)
# apply backspaces the way a terminal would
buf = []
for ch in text.split('[BKSP]'):
buf.append(ch)
print('[BKSP]'.join(buf)) # raw view
# cleaned view: actually delete a char per backspace
clean = []
i = 0
for token in out:
if token == '[BKSP]':
if clean:
clean.pop()
else:
clean.append(token)
print('cleaned:', ''.join(clean))

Run it and read both lines of output. The raw view shows exactly what was pressed, [BKSP] markers included, which is useful when the typist corrected a mistake. The cleaned view applies those backspaces so you see the final string. Flags are often typed with a deliberate backspace or two to throw off a naive decoder, so always compare the two.

Tip: If the cleaned output is gibberish but has the right length, the capdata field may include the leading modifier byte in a different position, or the device may use a report ID prefix (the report is 9 bytes, not 8, and byte 0 is the report ID). Print len(b) for a few lines. If every report is 9 bytes, shift your indices by one: modifier is b[1] and the key is b[3].

Variant: the capture is a mouse, not a keyboard

Some challenges record a mouse instead, and the flag is whatever the cursor drew on screen (an image painted in MS Paint, a pattern lock, a signature). A boot-protocol mouse report is shorter, typically 3 or 4 bytes, and it carries relative movement rather than absolute keys:

byte 0 button state (bit 0 = left, bit 1 = right, bit 2 = middle)
byte 1 X displacement (signed: positive right, negative left)
byte 2 Y displacement (signed: positive down, negative up)
byte 3 wheel (optional, signed)

The movement bytes are signed 8-bit deltas. You integrate them into an absolute path by accumulating, and you only plot points where a button is held down (that is when the user was actually "drawing"). Extract the same way, then reconstruct:

tshark -r mouse.pcap -Y 'usb.capdata' -T fields -e usb.capdata > mouse.txt
# --- reconstruct.py ---
import matplotlib.pyplot as plt
x = y = 0
xs, ys = [], []
for line in open('mouse.txt'):
b = bytes.fromhex(line.strip().replace(':', ''))
if len(b) < 3:
continue
buttons = b[0]
dx = b[1] - 256 if b[1] > 127 else b[1] # signed
dy = b[2] - 256 if b[2] > 127 else b[2]
x += dx
y += dy
if buttons & 0x01: # left button held: pen is down
xs.append(x)
ys.append(y)
plt.scatter(xs, ys, s=2)
plt.gca().invert_yaxis() # screen Y grows downward
plt.axis('equal')
plt.savefig('flag.png')

Open flag.png and the drawn letters appear as a scatter of points. The invert_yaxis call matters: screen coordinates grow downward, so without it your flag comes out upside down. The same extract-then-process workflow applies; only the meaning of the bytes changes.

Note: Mouse captures sometimes use absolute positioning (a tablet or a touchscreen-style HID) where the bytes are coordinates, not deltas. If your reconstructed path looks like it drifts off-screen, stop accumulating and plot the raw byte pairs directly; you are probably looking at absolute, not relative, motion.

The general workflow for any USB pcap

Keyboards and mice are the common two, but the steps generalize to any USB device in a capture (a mass-storage stick, a serial adapter, a game controller). The workflow is always the same shape:

  1. Identify the devices. Find the enumeration. Read GET DESCRIPTOR responses to learn the device class, vendor, and product. Note each device's bus address.
  2. Pick the interesting endpoint. Interrupt-in transfers carry HID input. Bulk transfers carry mass-storage and serial data. Filter to the one device and endpoint that holds the payload.
  3. Extract the data field with tshark -T fields -e usb.capdata (or the bulk-data field for storage).
  4. Interpret the bytesagainst the device's report format. For HID that means the usage-ID tables. For mass storage it means SCSI command blocks and file carving.

The principle that ties it together is that USB is just framed bytes with a documented layout. Once you know which device sent a transfer and what its report descriptor says those bytes mean, decoding is bookkeeping. The hard part is never the math; it is making sure you are reading one device's stream and not a blend of several.

Identify the device, isolate its endpoint, extract the field, interpret against the spec. Every USB forensics challenge is that same four-step loop.

Tooling notes

A few practical notes that save time on real captures:

  • tshark over the GUI for extraction. Wireshark is great for orientation (finding the device, reading descriptors), but for pulling hundreds of reports you want the command line so you can pipe straight into Python.
  • Mind the field name. usb.capdata and usbhid.data both appear depending on Wireshark version and how the capture was made. If one is empty, try the other before assuming the packets are blank.
  • Report length is a clue. 8 bytes is a boot keyboard, 3 or 4 bytes is a boot mouse, 9 bytes usually means a report-ID prefix. Check len(b) before trusting fixed byte offsets.
  • Reuse a keymap. Keep the HID dictionary from the decoder in a snippets file. The same table solves every keyboard challenge you will ever see, so you should never type it twice.
Note: The full set of usage IDs (function keys, keypad, international keys, media controls) lives in the HID Usage Tables document. The decoder above covers the printable subset that flags are built from; extend the dictionary from that spec if a challenge uses something exotic.

Where to practice this on picoCTF

picoCTF's forensics track leans more on network captures than on raw USB, so the closest practice is on the pcap-reading skills that transfer directly. Build the muscle for filtering, following a stream, and extracting a field on these, then the USB decode is just a different payload format on top of the same workflow:

  • picoCTF 2019 Shark on wire 1 teaches you to navigate a capture and follow a single stream out of the noise, the exact skill you use to isolate one USB device.
  • picoCTF 2019 Shark on wire 2 hides data in a covert channel, which forces you to read packet fields the dissector does not surface by default, the same instinct you need for usb.capdata.
  • picoCTF 2022 Eavesdrop is a follow-the-conversation capture where the payload is reassembled from many packets, mirroring how you stitch keystrokes back into a flag.

For the full pcap toolkit (display filters, stream following, statistics, and exports), the Wireshark and pcap guide is the companion piece; this USB-HID specialty sits one layer down from it.

Quick reference

The whole solve, start to finish

  1. Confirm it is a USB capture: protocol column USB, info shows URB_INTERRUPT in, sources like 1.5.1.
  2. Identify the device class from the descriptor exchange (usb.bInterfaceClass == 3 is HID; bInterfaceProtocol 1 = keyboard, 2 = mouse).
  3. Extract the report bytes, pinned to one device: tshark -r f.pcap -Y 'usb.capdata' -T fields -e usb.capdata.
  4. Keyboard: byte 0 = modifier (Shift = 0x22 mask), byte 2 = usage ID. Skip byte-2 = 0x00 releases. Map with the page-0x07 table.
  5. Mouse: byte 0 = buttons, byte 1 = signed dX, byte 2 = signed dY. Integrate and plot the points where the left button is held.
  6. Apply backspaces and read the flag. Compare the raw and cleaned views.

Letter and digit usage IDs (page 0x07)

a..z = 0x04 .. 0x1d (a = 0x04, contiguous through z = 0x1d)
1..9 = 0x1e .. 0x26 0 = 0x27 (zero comes last)
space = 0x2c enter = 0x28 backspace = 0x2a tab = 0x2b
Shift bit in byte 0 = 0x02 (left) or 0x20 (right); mask 0x22

A USB pcap is not a wall; it is a transcript. Find the keyboard, pull usb.capdata, and the flag is just an 8-byte report read one keypress at a time.

Keep reading

Guides that build on the same ideas, plus the roadmap this topic sits under.