Skip to content

test(core): add a rdpeusb (URBDRC) decode fuzz target - #1690

Merged
Marc-André Moreau (mamoreau-devolutions) merged 1 commit into
Devolutions:masterfrom
clintcan:feat/fuzz-rdpeusb-decode
Aug 19, 2026
Merged

test(core): add a rdpeusb (URBDRC) decode fuzz target#1690
Marc-André Moreau (mamoreau-devolutions) merged 1 commit into
Devolutions:masterfrom
clintcan:feat/fuzz-rdpeusb-decode

Conversation

@clintcan

Copy link
Copy Markdown
Contributor

Summary

ironrdp-rdpeusb has no fuzz coverage, even though it decodes
attacker-controlled client→server PDUs off two DVC channels. The device
channel in particular carries URB completions, IO-control completions and
interface-info results whose length-prefixed payloads must be bounds-checked
before they're read
— historically a good place for unchecked-read panics.
(A downstream server built on ironrdp-server, macrdp,
hit exactly that class of panic in these decoders in the past, which is what
motivated adding coverage here.)

This adds a fuzz target for the two top-level client PDU families a server
decodes off the wire.

What's added

  • Oracle oracles::rdpeusb_decode — decodes the two client→server entry
    points via ironrdp_core::decode:
    • UrbdrcClientControlPdu (main-channel family)
    • UrbdrcClientDevicePdu<Raw> (per-device family — recursively exercises the
      URB / IO-control / interface-info result payloads)
  • Fuzz target fuzz/fuzz_targets/rdpeusb_decode.rs (auto-discovered by
    cargo xtask fuzz list, so it joins the CI matrix with no further wiring).
  • Regression check check_rdpeusb_decode in fuzz_regression.rs, plus the
    rdpeusb_decode/ corpus folder, so any crash found becomes a permanent
    replay test.

Verification

  • cargo build -p ironrdp-fuzzing — the oracle compiles against the real API.
  • cargo test -p ironrdp-testsuite-core check_rdpeusb_decode — passes.
  • cargo fmt --all -- --check (stable + nightly) clean; cargo clippy -p ironrdp-fuzzing clean.
  • cargo xtask fuzz list lists rdpeusb_decode.

Purely additive (a new oracle, target, regression check + the ironrdp-rdpeusb
dev-dependency of ironrdp-fuzzing); no existing code changes. Complements the
in-flight rdpeusb server work by giving the crate's decode surface ongoing
fuzzing.

@github-actions github-actions Bot added the maintainer-required Maintainer review or intervention is required label Aug 17, 2026
@clintcan
clintcan force-pushed the feat/fuzz-rdpeusb-decode branch from 3c3ca41 to de8b296 Compare August 17, 2026 23:14
@clintcan clintcan changed the title test(fuzz): add a rdpeusb (URBDRC) decode fuzz target test(core): add a rdpeusb (URBDRC) decode fuzz target Aug 17, 2026
@github-actions github-actions Bot added kind/protocol Affects RDP or related protocol behavior risk/low Self-contained change with no cross-crate behavioral effect size/M Size: up to 449 counted lines and 10 files; exceeds S in either measure labels Aug 17, 2026
@clintcan
clintcan force-pushed the feat/fuzz-rdpeusb-decode branch from de8b296 to 25e03e8 Compare August 17, 2026 23:19
@CBenoit
Benoît Cortier (CBenoit) requested a balanced review from Copilot August 18, 2026 08:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds fuzz coverage for attacker-controlled RDPEUSB client PDUs.

Changes:

  • Adds an RDPEUSB decode oracle and fuzz target.
  • Adds regression-test corpus integration.
  • Adds required crate dependencies and lockfile updates.

Reviewed changes

Copilot reviewed 5 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
fuzz/fuzz_targets/rdpeusb_decode.rs Invokes the new oracle.
fuzz/Cargo.toml Registers the fuzz binary.
fuzz/Cargo.lock Updates fuzz dependencies.
crates/ironrdp-testsuite-core/tests/fuzz_regression.rs Adds regression replay test.
crates/ironrdp-testsuite-core/test_data/fuzz_regression/rdpeusb_decode/seed-empty.bin Initializes the regression corpus.
crates/ironrdp-fuzzing/src/oracles/mod.rs Adds RDPEUSB decoding oracle.
crates/ironrdp-fuzzing/Cargo.toml Adds the RDPEUSB dependency.
Cargo.lock Records the dependency edge.

Comment on lines +586 to +591
use ironrdp_core::decode;
use ironrdp_rdpeusb::pdu::completion::ts_urb_result::Raw;
use ironrdp_rdpeusb::pdu::{UrbdrcClientControlPdu, UrbdrcClientDevicePdu};

let _ = decode::<UrbdrcClientControlPdu>(data);
let _ = decode::<UrbdrcClientDevicePdu<Raw>>(data);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — you're right, and this is exactly the class macrdp hit historically. UrbdrcClientDevicePdu<Raw> only slurps each URB result body into Raw; the length-prefixed reinterpretation happens later in the stateful handlers via the pub(crate) into_expected, so <Raw> alone never reaches those decoders.

Fixed in the latest push: the oracle now also drives the public result decoders directly on the raw input — TsUrbSelectConfigResult, TsUrbSelectInterfaceResult, TsUrbGetCurrFrameNumResult, TsUrbIsochTransferResult (inherent decode), plus TsUsbdInterfaceInfoResult / TsUsbdPipeInfoResult (via Decode). Those are the length-prefixed / count-prefixed reads (TsUsbdInterfaceInfoResult's Length-slice, TsUrbSelectConfigResult's NumInterfaces loop, TsUrbIsochTransferResult's NumberOfPackets loop) that are the motivating attack surface. Driving the stateful completion path would also work but needs the request-ID correlation state, which doesn't fit a stateless byte-slice target — so I decode the payload types directly instead.

@CBenoit Benoît Cortier (CBenoit) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thank you for this. I left a question-comment, and Copilot also left an interesting comment. Could you consider it before we merge?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need an empty seed here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's needed for a mechanical reason: the check! macro does std::fs::read_dir(folder).unwrap(), so the corpus folder has to exist in-tree — and git won't track an empty directory, so it needs at least one file. seed-empty.bin is the existing convention for a target with no crash reproducer yet: egfx_round_trip, egfx_multi_frame, egfx_avc420_decode, bulk_decompress_ncrush, bulk_decompress_xcrush, bulk_round_trip, and pdu_round_trip all carry the same placeholder. Empty input is also a legitimate trivial case (decode-of-nothing must not panic). Happy to drop it and .gitkeep instead, or leave the whole regression check out until there's a real crash to replay — whichever you prefer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would lean towards .gitkeep, thank you!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — swapped seed-empty.bin.gitkeep (the check! macro just needs the corpus dir to exist in-tree; .gitkeep reads as the empty-input case exactly the same, and check_rdpeusb_decode still passes). Also rebased onto master to clear the conflict with #1707 (its rdpeudp/rdpemt fuzz targets touched the same fuzz/Cargo.toml / ironrdp-fuzzing dep list — additive, kept both). PR is green + mergeable again; oracle still compiles against the rebased tree (incl. #1683's usb module).

@github-actions github-actions Bot added the ai-reviewed/1 One automated review completed label Aug 18, 2026
@clintcan
clintcan force-pushed the feat/fuzz-rdpeusb-decode branch from 25e03e8 to f28e433 Compare August 18, 2026 10:16
ironrdp-rdpeusb had no fuzz coverage despite decoding attacker-controlled
client->server PDUs off two DVC channels — the device channel's URB /
IO-control / interface-info result payloads are length-prefixed and must be
bounds-checked before they're read.

Add an rdpeusb_decode oracle, the fuzz target (auto-discovered by
`cargo xtask fuzz list`), and the regression check + corpus folder.

The oracle decodes the two top-level client PDU families
(UrbdrcClientControlPdu, UrbdrcClientDevicePdu<Raw>) and also drives the
length-prefixed URB / interface-info *result* decoders directly
(TsUrbSelectConfigResult, TsUrbSelectInterfaceResult,
TsUrbGetCurrFrameNumResult, TsUrbIsochTransferResult,
TsUsbdInterfaceInfoResult, TsUsbdPipeInfoResult). Decoding
UrbdrcClientDevicePdu<Raw> only slurps each URB result body into Raw — the
operation-specific reinterpretation happens later in the stateful server
handlers via into_expected, so <Raw> alone never reaches those decoders,
which are the historical home of unchecked-read panics.
@clintcan
clintcan force-pushed the feat/fuzz-rdpeusb-decode branch from f28e433 to bf0266c Compare August 19, 2026 17:22
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) merged commit 04320a0 into Devolutions:master Aug 19, 2026
44 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-reviewed/1 One automated review completed kind/protocol Affects RDP or related protocol behavior maintainer-required Maintainer review or intervention is required risk/low Self-contained change with no cross-crate behavioral effect size/M Size: up to 449 counted lines and 10 files; exceeds S in either measure

Development

Successfully merging this pull request may close these issues.

4 participants