USB Reverse Engineering from the Kernel Up: usbmon, URBs, and Writing Your Own Driver
Source: lobsters
USB reverse engineering has a reputation for being arcane, but the Linux tooling around it is genuinely well-designed, and the protocol itself has architectural properties that make software-level capture far more tractable than most people assume. A recent walkthrough at crescentro.se demonstrates the basics with Wireshark and usbmon. It is a good practical introduction, but the workflow it describes deserves more explanation at the layer beneath the GUI, because understanding why it works makes you better at it when things get complicated.
USB Is a Polling Protocol
The single most important thing to understand before capturing USB traffic is that the host initiates every transfer. Devices cannot send unsolicited data; they can only respond when the host asks. This means that at the kernel level, every piece of data you care about arrives as the completion of a request the host already submitted. You are not intercepting a stream of unpredictable events; you are reading the results of a well-structured request queue.
This host-centric model is why software-based USB capture works at all. The kernel’s USB core is the single choke point through which all traffic passes, which gives the usbmon subsystem a clean place to tap.
What usbmon Actually Does
usbmon is not a loadable module in the traditional sense; it is a subsystem baked into the USB core that hooks into the usb_submit_urb and usb_hcd_submit_urb call paths. It was merged into the mainline kernel in version 2.6.11 in 2005, and it exposes captured traffic through two interfaces: a text-based debugfs interface at /sys/kernel/debug/usb/usbmon/ and a binary character device at /dev/usbmon0, /dev/usbmon1, etc. Wireshark reads from the character device interface via dumpcap.
The URB, or USB Request Block, is the kernel structure that represents a single USB I/O operation. It is defined in include/linux/usb.h and carries everything the host controller needs to execute a transfer: the target device and endpoint, the transfer type, the data buffer, the length, and the completion callback. When usbmon captures a URB, it records two events per transfer: the submit event (URB_SUBMIT, event type S) when the request goes down to the host controller, and the complete event (URB_COMPLETE, event type C) when the controller reports back. For an IN transfer (device to host), the data you want is in the complete packet; for an OUT transfer (host to device), the data is in the submit packet.
The text interface makes this concrete. Each line from /sys/kernel/debug/usb/usbmon/1t looks like:
d75a8280 1698234567.123456 S Ii:1:003:1 -115 8 <
d75a8280 1698234567.124001 C Ii:1:003:1 0 8 = 00 00 04 00 00 00 00 00
The pipe notation Ii:1:003:1 encodes transfer type (I = interrupt), direction (i = in), bus number, device address, and endpoint number. Status 0 on the complete event means success. The payload 00 00 04 00 00 00 00 00 is the 8-byte HID report from endpoint 1 of device 3 on bus 1.
Setting Up Capture
Getting Wireshark to see USB traffic on Linux requires loading usbmon and ensuring the capture user has read access to the character devices:
sudo modprobe usbmon
sudo mount -t debugfs none /sys/kernel/debug
lsusb
sudo chmod a+r /dev/usbmon*
In Wireshark, the interfaces appear as usbmon1, usbmon2, etc., corresponding to bus numbers. usbmon0 captures all buses simultaneously. Select the interface matching your device’s bus, start the capture, trigger whatever action you want to observe, and stop.
Before any capture, lsusb -v gives you the full descriptor tree for the device: vendor ID, product ID, configuration, interfaces, endpoints, and for HID devices the report descriptor index. This context tells you which endpoint numbers to watch and what transfer types to expect, so you are not reading packets blind.
Reading the Capture
Wireshark’s USB dissector decodes URBs into a structured packet detail view. The fields that matter most: urb_type distinguishes submits from completions; transfer_type tells you whether it is control, bulk, interrupt, or isochronous; endpoint_address tells you direction and endpoint number; and the data field shows the raw payload.
A few display filters that cut through the noise immediately:
usb.urb_type == 0x43
usb.transfer_type == 0x03
usb.device_address == 3
usb.endpoint_address == 0x81
usb.data_len > 0
For HID devices, Wireshark includes a full HID dissector. If the device presents standard HID class descriptors, the dissector will attempt to decode report fields according to the HID Report Descriptor. You can obtain the raw report descriptor without capturing anything using usbhid-dump:
sudo usbhid-dump -d 046d:c52b | grep -A 100 "DESCRIPTOR"
The report descriptor specifies the intended structure of every interrupt transfer: which bytes represent buttons, which represent axes, what the value ranges are, and whether values are absolute or relative. If the device exposes its report descriptor honestly, you may not need to do much payload analysis at all.
Decoding Unknown Payloads
When the device uses vendor-specific control requests or a proprietary report format, payload decoding requires more manual work. The core technique is single-variable isolation: trigger exactly one physical action at a time, capture the packet stream, and observe which byte or bit changed while everything else stays constant.
For axes and continuous values, three data points usually suffice: minimum position, center, and maximum. An unsigned 8-bit axis will show 0x00, 0x7F, and 0xFF. A signed 16-bit little-endian axis will show 0x0080, 0x0000, and 0xFF7F. Multi-byte values that fit neither pattern may be big-endian, or the vendor may have chosen a non-standard encoding.
For vendor control requests, look at the SETUP stage of control transfers. The bmRequestType byte distinguishes standard requests from vendor-specific ones: 0x40 is a vendor request to the device (OUT), 0xC0 is a vendor request from the device (IN). The bRequest field is the command identifier you need to replicate.
Frame 47: Control OUT
bmRequestType: 0x40 (Vendor, Device, Host-to-Device)
bRequest: 0x01
wValue: 0x0000
wIndex: 0x0000
Data: 10 01 00 00
This is the most valuable output from a USB capture: the exact bytes a vendor’s proprietary driver issues but has never documented publicly.
From Capture to libusb
Once the protocol is understood, libusb-1.0 is the standard way to speak it from userspace without writing a kernel driver. The library communicates directly with the USB core through /dev/bus/usb/, bypassing whatever class driver the kernel has attached to the device.
#include <libusb-1.0/libusb.h>
libusb_context *ctx;
libusb_device_handle *dev;
libusb_init(&ctx);
dev = libusb_open_device_with_vid_pid(ctx, 0x046d, 0xc52b);
if (libusb_kernel_driver_active(dev, 0))
libusb_detach_kernel_driver(dev, 0);
libusb_claim_interface(dev, 0);
uint8_t cmd[] = { 0x10, 0x01, 0x00, 0x00 };
libusb_control_transfer(dev,
0x40, /* bmRequestType: vendor, device, OUT */
0x01, /* bRequest */
0x0000, /* wValue */
0x0000, /* wIndex */
cmd, sizeof(cmd), 1000);
uint8_t report[64];
int transferred;
libusb_interrupt_transfer(dev, 0x81, report, sizeof(report),
&transferred, 0);
libusb_release_interface(dev, 0);
libusb_close(dev);
libusb_exit(ctx);
Compile with gcc -o driver driver.c $(pkg-config --cflags --libs libusb-1.0). If you want to avoid running as root, write a udev rule that grants group access to the device node:
SUBSYSTEM=="usb", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c52b", \
MODE="0666", GROUP="plugdev"
For continuous reading, use the async API: libusb_fill_interrupt_transfer with a callback that resubmits the transfer on each completion gives you a clean event loop. The synchronous libusb_interrupt_transfer call above blocks until one report arrives, which is fine for experimenting but will miss reports during the blocking period if the device sends them at high frequency.
The Projects This Enabled
The usbmon and libusb workflow has produced some significant software. The OpenKinect project reverse engineered Microsoft’s Kinect sensor in 2010 using USB captures before any official SDK existed, decoding isochronous streams for depth and color video plus the motor control protocol, entirely from pcap analysis. Solaar, the Linux manager for Logitech Unifying receivers, was built on reverse-engineered HID++ protocol captures, exposing device pairing, battery status, and per-device configuration that the vendor only supported on Windows. OpenRGB has reverse engineered USB protocols for dozens of RGB peripheral controllers from Corsair, NZXT, ASUS, and others using the same approach.
Each of these projects started with someone running modprobe usbmon, opening Wireshark, and interacting with the vendor’s software while watching the packet list. The USB protocol’s deterministic structure and the kernel’s clean capture interface made that a viable starting point for building fully independent implementations.
On Windows, USBPcap provides the same capability through a kernel filter driver that integrates with Wireshark. The capture format is compatible, so pcap files from either platform are interchangeable.
What Wireshark Cannot See
Software-based capture has one meaningful blind spot: it only sees traffic after the OS has enumerated the device. Traffic during firmware updates, in a device’s bootloader mode, or before kernel USB stack initialization is invisible to usbmon. For those cases, a hardware sniffer that taps the physical USB lines directly is necessary. OpenVizsla and the LUNA FPGA-based analyzer are open hardware options in the $80 to $200 range; commercial equivalents from Total Phase cost considerably more. For most reverse engineering work against devices with normal enumeration behavior, usbmon is sufficient; knowing the limitation matters when it is not.
The Wireshark-based workflow is not a workaround or a hack. It is the standard methodology that the open-source USB driver community has relied on for fifteen years. The tooling is mature, the capture format is well-documented, and the protocol’s host-centric design guarantees that every transfer you need to understand will appear in the capture without exception.