Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
10 KiB
Fuzzing Setup for usbip-rs Host Codepaths
Motivation
The host process sits between untrusted client data (from a VM) and the host kernel's vhci_hcd driver. Malformed USB/IP protocol input could crash the host process or, worse, produce malformed responses that confuse the kernel. Fuzzing the host-side parsing, dispatch, and handler codepaths catches both classes of bug.
Security Model Context
- Client (untrusted): runs inside a VM, sends raw USB/IP protocol bytes
- Host (trusted): parses client input, dispatches URBs to device handlers, sends responses back
- Host kernel: downstream consumer of USB/IP responses — robustness to malformed responses is not guaranteed
The fuzz targets simulate an untrusted client by feeding arbitrary bytes into the host-side entry points.
Approach
Raw-bytes fuzzing via cargo-fuzz (libfuzzer). Fuzz input is fed through MockSocket into the host-side protocol parsing and URB handling functions. Responses written to the output side of MockSocket are validated for well-formedness.
Structured fuzzing (via Arbitrary-derived protocol types) is out of scope for this initial setup but can be added later.
Fuzz Targets
Five targets, each a [[bin]] in lib/fuzz/Cargo.toml:
| Target | Entry Point | Device | What It Exercises |
|---|---|---|---|
fuzz_parse_command |
UsbIpCommand::read_from_socket() |
None | Raw protocol deserialization — headers, bounds checks, ISO descriptors |
fuzz_handle_client |
handler() |
HID (lightweight) | Negotiation phase (DEVLIST, IMPORT) then URB loop |
fuzz_urb_hid |
handle_urb_loop() |
HID keyboard | Interrupt + control transfers, single interface |
fuzz_urb_uac |
handle_urb_loop() |
UAC1 loopback | ISO transfers, alt settings, 3 interfaces |
fuzz_urb_cdc |
handle_urb_loop() |
CDC ACM | Bulk + interrupt transfers |
Target Details
fuzz_parse_command — the lightweight target. Wraps fuzz bytes in a MockSocket, calls UsbIpCommand::read_from_socket(), asserts no panic. No device construction, no response validation. Tests the protocol deserialization layer in isolation.
fuzz_handle_client — exercises the full connection lifecycle. Constructs a UsbIpServer with a HID device registered, feeds fuzz bytes through handler(). This covers the negotiation phase (OP_REQ_DEVLIST, OP_REQ_IMPORT) and, if the fuzzer produces a valid import sequence, transitions into the URB loop. Validates all responses.
fuzz_urb_hid — constructs a HID keyboard UsbDevice, feeds fuzz bytes directly into handle_urb_loop(). Exercises interrupt transfers (keyboard reports), control transfers (GET_DESCRIPTOR, SET_IDLE), single interface. Validates all responses.
fuzz_urb_uac — constructs a UAC1 loopback UsbDevice via build_uac_loopback_device(), feeds fuzz bytes into handle_urb_loop(). Exercises isochronous transfers, alternate interface settings, 3 interfaces (control, stream out, stream in). Validates all responses.
fuzz_urb_cdc — constructs a CDC ACM UsbDevice, feeds fuzz bytes into handle_urb_loop(). Exercises bulk transfers (serial data), interrupt transfers, single interface. Validates all responses.
Harness Pattern
All URB loop and handler targets follow the same pattern:
#![no_main]
use libfuzzer_sys::fuzz_target;
use std::sync::Arc;
fuzz_target!(|data: &[u8]| {
let rt = tokio::runtime::Builder::new_current_thread().build().unwrap();
rt.block_on(async {
let device = /* construct device */;
let mock = usbip_rs::util::mock::MockSocket::new(data.to_vec());
let output = mock.output_handle();
let _ = usbip_rs::handle_urb_loop(mock, Arc::new(device)).await;
let output_bytes = output.lock().unwrap();
usbip_rs::fuzz_helpers::assert_usbip_responses_valid(&output_bytes);
});
});
fuzz_handle_client is similar but constructs a UsbIpServer, adds a device, and calls handler() instead.
Response Validation (fuzz_helpers)
A module in the lib crate (lib/src/fuzz_helpers.rs), gated behind #[cfg(feature = "fuzz")].
assert_usbip_responses_valid(output_bytes: &[u8])
Parses all complete responses from the output buffer. Silently returns if the buffer is empty or contains only a partial response (expected when the fuzzer hits EOF mid-stream).
The USB/IP protocol has two response formats with different header layouts:
Negotiation-phase responses (used by fuzz_handle_client):
- Header: version (2 bytes, must be
0x0111) + command (2 bytes) + status (4 bytes) OP_REP_DEVLIST(0x0005): status 0, followed by device count (4 bytes) and device descriptorsOP_REP_IMPORT(0x0003): status 0 (success) or non-zero (failure), followed by device descriptor on success
URB-phase responses (used by all targets except fuzz_parse_command):
Structural checks:
- Each response has at least a 48-byte header (full
UsbIpRetSubmitorUsbIpRetUnlinkheader) - Command code is
RET_SUBMIT(0x00000003) orRET_UNLINK(0x00000004) - Direction field is 0 or 1
- For
RET_SUBMIT: actual transfer buffer data length matches theactual_lengthfield in the header - For
RET_SUBMITwith ISO:number_of_packets× 16 bytes of ISO descriptors present after header
Semantic checks:
- ISO packet descriptors:
offset + actual_lengthdoes not exceed transfer buffer length - No duplicate seqnums across all responses
RET_UNLINKstatus is 0 (success) or-ECONNRESET(URB already completed)
Intentionally not checked:
- Whether every request received a response (loop may exit early on malformed input)
- USB descriptor content validity
- Specific
RET_SUBMITerror status codes (many valid negative errno values)
Lib Crate Changes
lib/Cargo.toml
Add optional dependency and feature:
[dependencies]
arbitrary = { version = "1", features = ["derive"], optional = true }
[features]
fuzz = ["arbitrary"]
lib/src/lib.rs
Conditionally expose the module:
#[cfg(feature = "fuzz")]
pub mod fuzz_helpers;
lib/src/fuzz_helpers.rs
New file containing assert_usbip_responses_valid() as described above.
lib/src/util.rs — MockSocket Visibility
Currently MockSocket lives inside #[cfg(test)] pub(crate) mod tests. Move it to a separate conditional block so fuzz targets can use it:
#[cfg(any(test, feature = "fuzz"))]
pub mod mock {
// MockSocket struct, new(), output_handle(), AsyncRead, AsyncWrite impls
}
#[cfg(test)]
pub(crate) mod tests {
pub(crate) use super::mock::MockSocket;
// get_free_address, poll_connect, setup_test_logger, IsoLoopbackHandler stay here
}
This makes MockSocket available as usbip_rs::util::mock::MockSocket when the fuzz feature is enabled, without duplicating code or exposing test-only utilities.
Fuzz Crate Structure
lib/fuzz/
├── Cargo.toml
├── .gitignore
└── fuzz_targets/
├── fuzz_parse_command.rs
├── fuzz_handle_client.rs
├── fuzz_urb_hid.rs
├── fuzz_urb_uac.rs
└── fuzz_urb_cdc.rs
lib/fuzz/Cargo.toml
[package]
name = "usbip-rs-fuzz"
version = "0.0.0"
publish = false
edition = "2024"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
tokio = { version = "1", features = ["rt", "sync", "time", "io-util"] }
[dependencies.usbip-rs]
path = ".."
features = ["fuzz"]
[workspace]
members = ["."]
[[bin]]
name = "fuzz_parse_command"
path = "fuzz_targets/fuzz_parse_command.rs"
doc = false
[[bin]]
name = "fuzz_handle_client"
path = "fuzz_targets/fuzz_handle_client.rs"
doc = false
[[bin]]
name = "fuzz_urb_hid"
path = "fuzz_targets/fuzz_urb_hid.rs"
doc = false
[[bin]]
name = "fuzz_urb_uac"
path = "fuzz_targets/fuzz_urb_uac.rs"
doc = false
[[bin]]
name = "fuzz_urb_cdc"
path = "fuzz_targets/fuzz_urb_cdc.rs"
doc = false
lib/fuzz/.gitignore
target/
corpus/
artifacts/
Cargo.lock
Nix Integration
flake.nix Input
Add rust-overlay:
inputs = {
# ... existing inputs ...
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
fuzz devShell
devShells.fuzz = let
rust-nightly = rust-overlay.packages.${system}.rust-nightly;
in pkgs.mkShell {
buildInputs = [
rust-nightly
pkgs.cargo-fuzz
pkgs.libusb1
] ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [
pkgs.udev
];
nativeBuildInputs = [ pkgs.stdenv.cc pkgs.pkg-config ];
};
fuzz-usbip App
Wrapper script with --fork=N support for parallel overnight fuzzing:
apps.fuzz-usbip = let
rust-nightly = rust-overlay.packages.${system}.rust-nightly;
fuzz-usbip = pkgs.writeShellScriptBin "fuzz-usbip" ''
set -euo pipefail
export PATH="${rust-nightly}/bin:${pkgs.cargo-fuzz}/bin:${pkgs.stdenv.cc}/bin:${pkgs.pkg-config}/bin:$PATH"
export PKG_CONFIG_PATH="${pkgs.libusb1.dev}/lib/pkgconfig:${pkgs.udev.dev}/lib/pkgconfig:''${PKG_CONFIG_PATH:-}"
cd "$(${pkgs.git}/bin/git rev-parse --show-toplevel)/lib"
if [ $# -eq 0 ]; then
cargo fuzz list
else
target="$1"
shift
fork=0
args=()
for arg in "$@"; do
case "$arg" in
--fork=*) fork=''${arg#--fork=} ;;
*) args+=("$arg") ;;
esac
done
if [ "$fork" -gt 0 ]; then
while true; do
cargo fuzz run "$target" -- -max_len=1048576 "-fork=$fork" "''${args[@]}" || true
echo "--- fuzzer exited, restarting (artifacts saved) ---"
done
else
cargo fuzz run "$target" -- -max_len=1048576 "''${args[@]}"
fi
fi
'';
in {
type = "app";
program = "${fuzz-usbip}/bin/fuzz-usbip";
};
Usage:
nix run .#fuzz-usbip— list available fuzz targetsnix run .#fuzz-usbip -- fuzz_urb_hid— fuzz HID target, single processnix run .#fuzz-usbip -- fuzz_urb_hid --fork=8— fuzz HID target with 8 parallel processes, auto-restart on crash (for overnight runs)
Future Work (Out of Scope)
- Structured fuzzing: derive
ArbitraryonUsbIpCommand,SetupPacket, etc. to generate structurally valid protocol messages that reach deeper into handler logic - USB descriptor validation: verify GET_DESCRIPTOR responses produce structurally valid USB descriptors
- Corpus seeding: extract protocol traces from existing tests as seed corpus