· 5 min read ·

Reading the Wire: Reverse Engineering USB Devices Without a Spec

Source: lobsters

Most USB devices that come off the shelf have documentation of some kind, but the ones most worth understanding rarely do. A custom macro pad, an industrial sensor, a rebranded accessory from a manufacturer that discontinued its SDK three years ago: these are the devices that force you to read the wire.

This post at crescentro.se walks through exactly this process for a mystery USB peripheral, using Wireshark and USBPcap to capture and decode the device’s protocol. The post is a solid introduction, but there is more to pull on here, particularly around how the USB stack structures data, how Wireshark’s dissector exposes it, and what to do when a device deliberately obscures its report format.

What Wireshark Actually Captures

Wireshark does not see USB packets the way it sees Ethernet frames. What it captures are URBs, USB Request Blocks, the kernel data structure that the operating system uses for all USB I/O. Each URB records the transfer type, endpoint, direction, status code, and payload, along with whether it is a submit (host initiating the request) or a complete (response or acknowledgment).

On Windows, USBPcap intercepts these URBs as a WDM filter driver sitting between the USB hub driver and the device driver. It writes them as a pcap file with link-layer type DLT_USBPCAP (249). On Linux, the equivalent is usbmon, a kernel module that has been part of the mainline kernel since 2.6.11. Enable it with modprobe usbmon and Wireshark will list /dev/usbmonN interfaces directly, one per USB bus.

The distinction between submit and complete URBs matters for analysis. For an interrupt IN read, the submit carries no data; the device’s payload arrives in the complete URB. Wireshark links them via the usb.usb_id field, and you can right-click any URB to navigate to its pair.

Filtering to the Signal

A raw USB capture is full of noise: polling submits, NAK responses, hub keep-alives, unrelated devices sharing the bus. The first task is narrowing to what you care about.

Wireshark’s USB display filters are specific enough to get there quickly:

usb.device_address == 5 && usb.transfer_type == 0x03 && usb.endpoint_number.direction == 1 && usb.data_len > 0

This isolates interrupt IN completions from device address 5 that carry actual data. The transfer type values are 0x00 (control), 0x01 (isochronous), 0x02 (bulk), and 0x03 (interrupt). Most HID peripherals, keyboards, mice, gamepads, custom macro pads, use interrupt transfers polled by the host at whatever interval the endpoint descriptor specifies, typically one to eight milliseconds.

If you do not know the device address ahead of time, check Statistics > USB Devices in Wireshark. It gives a quick overview of everything that appeared on the bus during the capture, with class and subclass information where available. The endpoint carrying the most traffic is almost always the one you want.

For scripted extraction, tshark is more convenient than clicking through the Wireshark GUI:

tshark -r capture.pcap \
  -Y "usb.transfer_type==0x03 && usb.endpoint_number.direction==1 && usb.data_len > 0" \
  -T fields -e usb.capdata > payloads.txt

This produces one hex payload per line, easy to diff against captures of different device states.

The Two Paths: Descriptors and Pattern-Matching

HID-class devices declare their data format via a Report Descriptor, a compact binary structure that enumerates every field in every report the device sends. During enumeration, the host fetches this descriptor via a control transfer (GET_DESCRIPTOR, type 0x22). If your capture includes the moment you plugged the device in, this exchange will be in it.

The USB HID specification, version 1.11, defines the descriptor format. The bytes are dense but Wireshark’s HID dissector renders them if you captured the enumeration. Alternatively, paste the raw hex into the HID descriptor parser at eleccelerator.com for a readable breakdown. A standard mouse descriptor encodes three buttons as one bit each plus five padding bits, then X and Y as signed 8-bit relative values. Once you know the layout, every report byte falls into place.

The harder case is a device using USB class code 0xFF, vendor-specific, rather than the HID class. These devices have no standard descriptor to fetch. The protocol is whatever the manufacturer decided, and you have no reference point other than the traffic itself.

The technique here is systematic delta analysis. Capture the device in a known state: no buttons pressed, no movement, no change. Capture it again with exactly one input active. Export both sets of payloads and compare. The bytes that changed encode that input. Repeat for every button, axis, and mode. A rotary encoder will appear as a byte that increments or decrements; a button maps to a specific bit or byte that toggles between two values. If the first byte varies across captures, the device uses HID report IDs, and each ID value has its own separate payload layout that you map independently.

From Capture to Code

Once the protocol is mapped, the fastest path to a working consumer is pyusb, a Python wrapper around libusb. A minimal reader for a device with an interrupt IN endpoint at address 0x81 looks like this:

import usb.core

dev = usb.core.find(idVendor=0x1234, idProduct=0x5678)
if dev is None:
    raise ValueError("device not found")

if dev.is_kernel_driver_active(0):
    dev.detach_kernel_driver(0)

dev.set_configuration()

while True:
    try:
        data = dev.read(0x81, 64, timeout=1000)
        print(list(data))
    except usb.core.USBError as e:
        if e.args[0] == 110:  # ETIMEDOUT
            continue
        raise

Endpoint 0x81 is endpoint 1 IN; the high bit in the endpoint address encodes direction. The timeout is in milliseconds. Interrupt endpoints return data at the polling interval defined in their descriptor.

On Windows, there is a prerequisite: the OS assigns a class driver to the device at plug-in time, and libusb needs WinUSB or libusbK to be the active driver. Zadig handles this without touching the registry manually. Select the device, choose WinUSB, and install. The change is scoped to that VID/PID, so other devices using the same class elsewhere on the system are unaffected.

For devices that are genuinely HID-class and already have a working OS driver, the hidapi library is simpler than going through libusb directly because it does not require a driver swap on Windows. The trade-off is that hidapi only handles HID-class report exchanges; for bulk or vendor-class transfers, you need the full libusb path.

The Discipline of One Change at a Time

Tooling and protocol parsing are solvable problems; experimental discipline is what limits progress. Each capture session should represent exactly one change from the previous state. Two simultaneous inputs create ambiguity that compounds quickly. If a five-byte payload can represent 256^5 distinct states, isolating individual bit meanings from correlated inputs becomes genuinely difficult.

This is where the enumeration-first approach pays off. Even for vendor-class devices with no readable descriptor, capturing the full plug-in sequence sometimes reveals partial documentation in the string descriptors, or in proprietary OS descriptor requests that Windows sends during enumeration. The device manufacturer’s driver may have left fingerprints in the descriptor tree that narrow your search considerably.

It is also worth capturing on Linux even when you plan to deploy on Windows, because usbmon gives you lower-overhead capture with less interference from the OS driver stack. The payloads are identical; you are just reducing the number of variables that can corrupt a clean capture session.

USB reverse engineering is an exercise in reducing the search space methodically. Wireshark provides the view into the bus. The work is in the discipline of the experiments you run against it.

Was this interesting?