usbip-rs/docs/superpowers/specs/2026-03-25-fuzzing-setup-design.md
Davíð Steinn Geirsson 1cb95dd57c docs: add spec for fuzzing setup targeting host codepaths
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:49:39 +00:00

10 KiB
Raw Blame History

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 descriptors
  • OP_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 UsbIpRetSubmit or UsbIpRetUnlink header)
  • Command code is RET_SUBMIT (0x00000003) or RET_UNLINK (0x00000004)
  • Direction field is 0 or 1
  • For RET_SUBMIT: actual transfer buffer data length matches the actual_length field in the header
  • For RET_SUBMIT with ISO: number_of_packets × 16 bytes of ISO descriptors present after header

Semantic checks:

  • ISO packet descriptors: offset + actual_length does not exceed transfer buffer length
  • No duplicate seqnums across all responses
  • RET_UNLINK status 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_SUBMIT error 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 targets
  • nix run .#fuzz-usbip -- fuzz_urb_hid — fuzz HID target, single process
  • nix 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 Arbitrary on UsbIpCommand, 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