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 linetshark -r capture.pcap -Y 'usb.capdata' -T fields -e usb.capdata > data.txt# 2) decode with the script from the 'Decoding with Python' sectionpython3 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.
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 likeURB_INTERRUPT inorGET DESCRIPTOR Response DEVICE. - Source and destination look like
hostand1.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.
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 2byte 4 keycode 3byte 5 keycode 4byte 6 keycode 5byte 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 Ctrl0x02 Left Shift 0x20 Right Shift0x04 Left Alt 0x40 Right Alt0x08 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.
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 = 00x28 = Enter 0x29 = Esc 0x2a = Backspace 0x2b = Tab0x2c = 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.
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-separatedtshark -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:0000:00:0f:00:00:00:00:00 <- 0x0f = 'l'00:00:00:00:00:00:00:0002: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 dissectorstshark -r capture.pcap -Y 'usbhid.data' -T fields -e usbhid.data# dump all field names from packet 30 to find the right onetshark -r capture.pcap -Y 'frame.number == 30' -T pdml | grep -i 'name='# filter to a single device + endpoint if multiple HID devices are presenttshark -r capture.pcap -Y 'usb.src == "1.5.1" and usb.capdata' \-T fields -e usb.capdata
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 python3import sys# HID usage ID -> (unshifted, shifted) for the US Keyboard/Keypad page 0x07HID = {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:continueb = bytes.fromhex(line.replace(':', ''))if len(b) < 3:continuemod, key = b[0], b[2]if key == 0x00: # all keys up: a release event, skip itcontinueshift = bool(mod & 0x22) # left (0x02) or right (0x20) Shiftif 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 wouldbuf = []for ch in text.split('[BKSP]'):buf.append(ch)print('[BKSP]'.join(buf)) # raw view# cleaned view: actually delete a char per backspaceclean = []i = 0for 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.
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 pltx = y = 0xs, ys = [], []for line in open('mouse.txt'):b = bytes.fromhex(line.strip().replace(':', ''))if len(b) < 3:continuebuttons = b[0]dx = b[1] - 256 if b[1] > 127 else b[1] # signeddy = b[2] - 256 if b[2] > 127 else b[2]x += dxy += dyif buttons & 0x01: # left button held: pen is downxs.append(x)ys.append(y)plt.scatter(xs, ys, s=2)plt.gca().invert_yaxis() # screen Y grows downwardplt.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.
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:
- Identify the devices. Find the enumeration. Read
GET DESCRIPTORresponses to learn the device class, vendor, and product. Note each device's bus address. - 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.
- Extract the data field with
tshark -T fields -e usb.capdata(or the bulk-data field for storage). - 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.capdataandusbhid.databoth 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
HIDdictionary 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.
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
- Confirm it is a USB capture: protocol column
USB, info showsURB_INTERRUPT in, sources like1.5.1. - Identify the device class from the descriptor exchange (
usb.bInterfaceClass == 3is HID;bInterfaceProtocol1 = keyboard, 2 = mouse). - Extract the report bytes, pinned to one device:
tshark -r f.pcap -Y 'usb.capdata' -T fields -e usb.capdata. - Keyboard: byte 0 = modifier (Shift =
0x22mask), byte 2 = usage ID. Skip byte-2 =0x00releases. Map with the page-0x07 table. - Mouse: byte 0 = buttons, byte 1 = signed dX, byte 2 = signed dY. Integrate and plot the points where the left button is held.
- 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 = 0x2bShift 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.