1 of 33

USB MITM: Building a Transparent USB Proxy with raw-gadget and libusb

Intercept, inspect, and modify USB traffic in real time

Aristo Chen · COSCUP 2026

github.com/AristoChen/usb-proxy

277

Stars

48

Forks

Apache-2.0

License

2 of 33

2 / 28

📢 Language Notice

This presentation will be delivered in Mandarin Chinese.

If you do not speak Mandarin, you are very welcome to stay after the session. I am happy to walk through the content again in English.

3 of 33

Aristo Chen

3 / 28

Embedded Linux engineer

usb-proxy

Built it to understand the Linux USB stack from the ground up, and it is what this talk is about.

Upstream contributions

U-Boot and the Linux kernel, focused on bootloader enablement and hardware bringup.

Day to day

Embedded Linux and IoT platform work on ARM SoCs.

Questions, issues and pull requests are all welcome.

4 of 33

Agenda

4 / 28

1

The Problem

Why USB is hard to debug, and what the existing tools can and cannot do

2

What is usb-proxy?

A transparent USB man-in-the-middle proxy on commodity hardware

3

Building Blocks

libusb, descriptors, transfer types, USB OTG, gadget modes, raw-gadget

4

Inside the Proxy

Component architecture, how packets flow, and a quick demo of it working

5

Injection Engine + Live Demo

Three ways to modify traffic: patterns, declarative operations, Lua

6

Setup, Limitations, Q&A

Running it yourself, CLI flags, current limits and where it is heading

5 of 33

The Problem

5 / 28

USB is everywhere

Keyboards, mice, webcams, storage devices, embedded hardware test fixtures: USB touches nearly every device we use.

🔧

How do you actually debug USB?

When a device misbehaves, what are your options for seeing, and changing, what is actually on the wire?

6 of 33

How Do You Debug USB?

6 / 28

Several approaches exist, each with different trade-offs:

Approach

Cost

Read

Modify

Open Source

usbmon + Wireshark

Linux kernel capture + packet analysis

Free

Hardware Sniffer

DIY ~NT$1,000 / Pro: Beagle, Ellisys NT$13,000+

NT$1k~100k+

7 of 33

How Do You Debug USB?

6 / 28

Several approaches exist, each with different trade-offs:

Approach

Cost

Read

Modify

Open Source

usbmon + Wireshark

Linux kernel capture + packet analysis

Free

Hardware Sniffer

DIY ~NT$1,000 / Pro: Beagle, Ellisys NT$13,000+

NT$1k~100k+

You cannot change what the device sends

8 of 33

How Do You Debug USB?

6 / 28

Several approaches exist, each with different trade-offs:

Approach

Cost

Read

Modify

Open Source

usbmon + Wireshark

Linux kernel capture + packet analysis

Free

Hardware Sniffer

DIY ~NT$1,000 / Pro: Beagle, Ellisys NT$13,000+

NT$1k~100k+

You cannot change what the device sends

You cannot test how the host reacts to something unexpected

9 of 33

How Do You Debug USB?

6 / 28

Several approaches exist, each with different trade-offs:

Approach

Cost

Read

Modify

Open Source

usbmon + Wireshark

Linux kernel capture + packet analysis

Free

Hardware Sniffer

DIY ~NT$1,000 / Pro: Beagle, Ellisys NT$13,000+

NT$1k~100k+

You cannot change what the device sends

You cannot test how the host reacts to something unexpected

You cannot emulate a device you do not physically own

10 of 33

How Do You Debug USB?

6 / 28

Several approaches exist, each with different trade-offs:

Approach

Cost

Read

Modify

Open Source

usbmon + Wireshark

Linux kernel capture + packet analysis

Free

Hardware Sniffer

DIY ~NT$1,000 / Pro: Beagle, Ellisys NT$13,000+

NT$1k~100k+

Your USB device and your computer are having a conversation.

You are not part of it.

11 of 33

How Do You Debug USB?

6 / 28

Several approaches exist, each with different trade-offs:

Approach

Cost

Read

Modify

Open Source

usbmon + Wireshark

Linux kernel capture + packet analysis

Free

Hardware Sniffer

DIY ~NT$1,000 / Pro: Beagle, Ellisys NT$13,000+

NT$1k~100k+

usb-proxy

Raspberry Pi + raw-gadget + libusb

~NT$1,500+

◐ = DIY builds are open source; commercial analyzers are proprietary

Only usb-proxy lets you modify traffic in real time, using a general-purpose Linux machine you may already own.

12 of 33

What is usb-proxy?

7 / 28

USB

Device

Real hardware

usb-proxy

Linux + raw-gadget

+ libusb

USB

Host

Sees a synthetic

device

libusb

raw-gadget

Transparent MITM Proxy

▸ Runs on a Raspberry Pi 4 (or any Linux machine with a USB OTG port)

▸ Use cases: protocol reverse engineering, HID remapping, hardware testing, and USB security research

13 of 33

What is libusb?

8 / 28

The library usb-proxy uses to talk to the real USB device.

Real USB Device

the hardware

libusb

userspace library

usb-proxy

your code

libusb handles the USB protocol details so usb-proxy does not have to.

What it is

▸ An open-source C library (LGPL-2.1) for accessing USB devices from userspace

▸ No custom kernel driver needed; works through the kernel usbfs interface

▸ Cross-platform and widely used: lsusb, OpenOCD, Wireshark all rely on it

What usb-proxy uses it for

▸ Reading the real device’s descriptors so the proxy can clone them

▸ Forwarding every control, interrupt, bulk, and isochronous transfer

▸ Returning the device’s replies so they can be passed back to the host

14 of 33

What is a USB Descriptor?

9 / 28

When a device is plugged in, it tells the host what it is. That self-description is a set of descriptors.

Descriptor Hierarchy

Device

Vendor ID, Product ID, USB version

Configuration

Power needs, number of interfaces

Interface

Device class (HID, storage, audio…)

Endpoint

Address, direction, transfer type

Why it matters here

▸ usb-proxy reads the real device’s descriptors via libusb, then replays them to the host through raw-gadget

▸ The host reads those descriptors and believes it is talking to the original device

▸ Endpoint descriptors tell the proxy which endpoints exist, and injection rules target them by ep_address

▸ Some UDCs cannot support every endpoint address, which is why --auto_remap_endpoints rewrites them

15 of 33

USB Transfer Types

10 / 28

Every endpoint declares one of four transfer types. usb-proxy forwards all four.

Control

Endpoint 0, always present

▸ Descriptor requests, configuration, class commands

▸ Error-checked with guaranteed delivery

▸ Injection matches on setup packet fields

Interrupt

Small and periodic, bounded latency

▸ HID devices: mouse, keyboard, game controllers

▸ Host polls at a fixed interval

▸ Injection matches on ep_address

Bulk

Large payloads, no timing guarantee

▸ Mass storage, printers, network adapters

▸ Error-checked and retried; uses spare bandwidth

▸ Injection matches on ep_address

Isochronous

Continuous stream, reserved bandwidth

▸ Webcam video and USB audio

▸ No retries; late data is simply dropped

▸ Injection matches on ep_address

The endpoint descriptor is what declares the type, so the proxy knows how to handle each endpoint.

16 of 33

What is USB OTG?

11 / 28

Normal USB

Device

(Mouse, Keyboard…)

Host

(Computer)

Fixed roles: Host always controls, Device always responds

USB OTG (On-The-Go)

Raspberry Pi 4

(with USB OTG port)

Host

(Computer)

can act as a USB device

▸ An OTG port can dynamically act as both a USB host and a USB device

▸ This is what lets the Raspberry Pi impersonate the real device to the host computer

▸ On the Raspberry Pi 4, the USB-C power port provides OTG capability via the dwc2 kernel driver

17 of 33

USB Gadget Modes

12 / 28

The kernel gadget framework offers several ways to define a USB device:

Mode / Module

Flexibility

Runtime Handled By

g_* pre-built modules

g_serial, g_ether, g_mass_storage, g_zero

Fixed function

Kernel

gadgetfs

answers some requests itself, using your descriptors

Custom logic

Userspace

raw-gadget

passes every request up to userspace

Full packet control

Userspace

Also worth knowing:

configfs and functionfs offer other ways to compose or customise gadgets, each with different trade-offs.

usb-proxy uses raw-gadget: the lowest-level option, giving full packet control from userspace.

18 of 33

What is raw-gadget?

13 / 28

The kernel interface usb-proxy uses to impersonate the device to the host.

usb-proxy

your code

/dev/raw-gadget

kernel interface

USB Host

the computer

Every request from the host is passed up to usb-proxy to answer.

What it is

▸ A kernel module (CONFIG_USB_RAW_GADGET) that lets userspace emulate USB devices

▸ Merged into mainline Linux 5.7; written by Andrey Konovalov for kernel fuzzing

▸ Driven by ioctl calls on /dev/raw-gadget, not a filesystem interface

What usb-proxy uses it for

▸ Replaying the real device’s descriptors so the host sees the original device

▸ Receiving every host request, including ones gadgetfs would answer itself

▸ Reporting UDC endpoint capabilities, which is what --auto_remap_endpoints relies on

19 of 33

Inside the Proxy Machine

14 / 28

Proxy Machine (Raspberry Pi 4 / Linux + OTG)

Device Side

Application

Host Side

libusb

Userspace

usbfs / USB Core

Kernel

← USB Device (real hardware)

usb-proxy

C++ Application

/dev/raw-gadget

Userspace

Gadget Framework

Kernel

UDC Driver

Kernel

USB Host (computer) →

20 of 33

Packet Flow: Setup and Control

15 / 28

1 Startup: before the host is connected

libusb reads the real

device descriptors

converted into

raw-gadget structs

usb_raw_init + run:

gadget goes live

The gadget’s descriptor structures are built once here, before the host sees anything.

2 Runtime: control transfers on endpoint 0

A single ep0_loop handles both directions. No reader/writer split, no queue.

Real USB Device

hardware

ep0_loop

forwards both ways

USB Host

the computer

libusb

raw-gadget

A GET_DESCRIPTOR from the host is forwarded to the real device via libusb, and the reply is passed straight back. The host sees exactly what the real device would have said.

21 of 33

Packet Flow: Data Endpoints

16 / 28

Interrupt, bulk, and isochronous endpoints each get a reader thread and a writer thread, with a queue between them.

IN endpoint: device → host (e.g. mouse reports, webcam frames)

Real USB

Device

hardware

ep_loop_read

via libusb

queue

per endpoint

ep_loop_write

via raw-gadget

USB Host

the computer

OUT endpoint: host → device (e.g. commands, bulk writes)

Real USB

Device

hardware

ep_loop_write

via libusb

queue

per endpoint

ep_loop_read

via raw-gadget

USB Host

the computer

The same chain, traversed in opposite directions. The queue decouples the two sides, so a slow reader or writer never stalls the other.

22 of 33

Quick Demo

17 / 28

Before we change anything, let us see the plain proxy working.

🖱

# no injection, no remapping, just proxy

sudo ./usb-proxy \

--vendor_id=1b3f --product_id=2247

▸ The host enumerates it as the same mouse: same VID / PID, same descriptors

▸ Move it, click it, scroll: everything behaves exactly as before

▸ Every packet is passing through the Pi, and the host cannot tell

That is what transparent means. Now: what happens when we start changing things?

23 of 33

The Injection Rule Engine

18 / 28

injection.json defines rules for interrupt, bulk, and isochronous endpoints by address, plus control-transfer rules that match on setup packet fields and can modify, ignore, or stall a request.

01

Pattern

Replacement

Match fixed hex byte

sequences and substitute

them. Simple and fast.

02

Declarative

Operations

Arithmetic transforms on

byte offsets: negate,

scale, xor, swap, set…

03

Lua

Scripting

Full programmability for

stateful or conditional

logic across packets.

Steps run in order within a single rule. For int, bulk, and isoc, the first rule that modifies a packet wins and the rest are skipped.

Injection is off unless you pass --enable_injection (uses injection.json) or --injection_file PATH.

24 of 33

Where Injection Happens

19 / 28

Injection is off by default. With --enable_injection, rules hook into the paths from earlier.

IN endpoint: device → host

Real USB

Device

hardware

ep_loop_read

via libusb

queue

per endpoint

ep_loop_write

via raw-gadget

USB Host

the computer

◀ injection rules run here

OUT endpoint: host → device

Real USB

Device

hardware

ep_loop_write

via libusb

queue

per endpoint

ep_loop_read

via raw-gadget

USB Host

the computer

◀ injection rules run here

Either way, rules run on the read side, before the packet is queued.

Control transfers (EP0) are different: rules match on setup packet fields, evaluated inside ep0_loop.

--auto_remap_endpoints is a separate optional layer, patching endpoint addresses in descriptor replies on EP0.

25 of 33

Approach 1: Pattern Replacement

20 / 28

Match a fixed hex byte sequence and substitute it. Ideal for simple, deterministic substitutions.

Example: Swap left and right mouse button clicks

"int": [

{

"ep_address": 81,

"enable": true,

"content_pattern": ["\\x01\\x00\\x00\\x00"], // left click

"replacement": "\\x02\\x00\\x00\\x00" // right click

},

{

"ep_address": 81,

"enable": true,

"content_pattern": ["\\x02\\x00\\x00\\x00"], // right click

"replacement": "\\x01\\x00\\x00\\x00" // left click

}

]

Two mutually exclusive rules: once the first matches and modifies the packet, the second is skipped.

26 of 33

Approach 2: Declarative Operations

21 / 28

Apply arithmetic transforms on byte offsets with no scripting required. Operations run in order.

negate

Two's-complement negate

scale

Multiply by float, clamped

add

Add a signed constant, clamped

clamp

Clamp to min/max range

xor

XOR a byte with a mask

swap

Swap two bytes at given offsets

copy

Copy a byte to another offset

set

Force a byte to a fixed value

negate, scale, add, and clamp also accept "size": 2 for int16 little-endian values.

Example: Flip left/right buttons and halve cursor speed in one rule

"operations": [

{ "type": "xor", "offset": 0, "mask": 3 }, // flip button bits

{ "type": "scale", "offset": 1, "factor": 0.5 }, // halve X speed

{ "type": "scale", "offset": 2, "factor": 0.5 } // halve Y speed

]

27 of 33

Approach 3: Lua Scripting

22 / 28

For logic that cannot be expressed declaratively: conditionals, loops, and state across packets.

Key Properties

▸ Each script gets its own Lua state, persisting across packets

▸ Enables dead zones, speed caps, conditional transforms

▸ Combine with pattern replacement and operations in one rule

▸ script_file paths are resolved relative to the proxy’s working directory, e.g. scripts/mouse_invert.lua

▸ Optional; requires liblua5.4-dev or libluajit-5.1-dev at build time

Example: Dead zone + speed cap

local DEAD_ZONE = 2

local MAX_SPEED = 20

local function process(raw)

local v = (raw > 127) and (raw - 256) or raw

if math.abs(v) <= DEAD_ZONE then return 0 end

v = math.max(-MAX_SPEED,

math.min(MAX_SPEED, v))

return v & 0xFF

end

function transform(data, len)

if len < 3 then return data, len end

data[2] = process(data[2]) -- X axis

data[3] = process(data[3]) -- Y axis

return data, len

end

28 of 33

Live Demo

23 / 28

🖱

1

Run usb-proxy on Raspberry Pi 4 between a USB mouse and host computer

2

Intercept raw HID interrupt packets in real time with -v -v verbose output

3

Apply injection rules: swap left/right clicks, invert axes, halve cursor speed

4

Switch to a Lua script for stateful dead zone and speed cap behavior

Fallback: dummy_hcd software-only demo if OTG hardware is unavailable at the venue

29 of 33

Responsible Use

24 / 28

This tool reads and rewrites traffic between a device and its host. That capability cuts both ways.

✓ Built for this

▸ Hardware you own

▸ Devices you are authorised to test

▸ Protocol reverse engineering for interoperability

▸ Hardware QA and regression testing

▸ Security research with permission

✗ Not this

▸ Devices or systems you do not own or have permission to test

▸ Capturing another person’s keystrokes, credentials, or data

▸ Leaving it inline on hardware that is not yours

Unauthorised interception may be unlawful, and the rules vary by jurisdiction.

usb-proxy needs physical access to the USB path, the same position a malicious cable or dock would occupy. Understanding how that works is part of being able to defend against it.

30 of 33

Hardware Setup & dummy_hcd

25 / 28

With OTG Hardware

# Enable dwc2 (older Pi OS: /boot/config.txt)

echo "dtoverlay=dwc2" | sudo tee -a \

/boot/firmware/config.txt

echo "dwc2" | sudo tee -a /etc/modules

sudo reboot

# Load raw-gadget (mainline 5.7+, or build

# it from github.com/xairy/raw-gadget)

sudo modprobe raw_gadget

# Run

sudo ./usb-proxy \

--device=fe980000.usb \

--driver=fe980000.usb \

--vendor_id=1b3f --product_id=2247

Without OTG (dummy_hcd)

# Load raw-gadget (mainline 5.7+, or build

# it from github.com/xairy/raw-gadget)

sudo modprobe raw_gadget

# Load the virtual host/device pair

sudo modprobe dummy_hcd

# Run (dummy_hcd is used by default)

# when no --device / --driver given

sudo ./usb-proxy \

--vendor_id=1b3f \

--product_id=2247

Automatic Endpoint Remapping

Some UDCs cannot support every endpoint address a real device advertises. --auto_remap_endpoints rewrites config and UVC descriptors so the host sees a compatible set, while the proxy maps internally to the original device addresses.

31 of 33

Useful CLI Arguments

26 / 28

--auto_remap_endpoints

Rewrites endpoint addresses in config and UVC descriptors to match UDC capabilities. Essential when the target device uses endpoint numbers the host UDC does not support.

--iso_batch_size N

Number of isochronous packets per transfer (1-32, default 8). Increase for smoother high-bandwidth streams; decrease to reduce latency for audio or low-rate devices.

--injection_file PATH

Path to a custom injection.json rule file. Defaults to injection.json in the current directory.

--pcapng_file PATH

Capture all proxied traffic to a Wireshark-compatible pcapng file. No libpcap needed, zero overhead when unused. Packets are recorded after injection, so the file shows what was actually transmitted.

--inject_socket PATH

Unix domain socket for runtime injection. Send "INJECT <ep_hex> <hex_bytes>" to push a fabricated packet toward the host (IN endpoint) or the device (OUT endpoint).

-v / --verbose

Repeatable, three levels. -v: endpoint table, injection before/after dumps, queue counts. -v -v: full hex dump of every packet. -v -v -v: libusb transfer sizes and isochronous batch details.

32 of 33

Limitations & Future Work

27 / 28

Current Limitations

▸ Isochronous injection works, but changing packet length inside a rule is unsafe for isochronous endpoints, and injection.json currently ships no isoc example rules

▸ Lua scripting adds per-packet overhead; avoid it for high-bandwidth isochronous streams such as webcam video and prefer declarative operations instead

Future Work

▸ Example isochronous injection rules and per-packet drop support

▸ Dynamic rule reloading without restarting the proxy

▸ Web-based packet inspector and rule editor

▸ Community contributions welcome: issues and PRs open on GitHub

33 of 33

28 / 28

Q & A

Thank you for joining the session!

usb-proxy:

github.com/AristoChen/usb-proxy

raw-gadget:

github.com/xairy/raw-gadget

libusb:

github.com/libusb/libusb

Apache-2.0 · PRs and issues welcome

github.com/AristoChen/usb-proxy