fix(async): reject a zero-length unmatched PDU in read_by_hint - #1556
fix(async): reject a zero-length unmatched PDU in read_by_hint#1556clintcan wants to merge 1 commit into
Conversation
`Framed::read_by_hint` skips a PDU that does not match the requested hint by reading `length` bytes and looping. When a hint reports `Some((false, 0))` — an unmatched, zero-length PDU — `read_exact(0)` consumes nothing and performs no I/O, so the loop spins forever at 100% CPU: it never yields to the runtime and never observes EOF, which also starves the executor driving it (e.g. an acceptor's connection loop, taking the whole server down). This is reachable from a single malformed frame. Devolutions#1515 hardened `find_size` so the built-in X.224 / Fast-Path hint no longer returns a zero-length PDU, but `read_by_hint` accepts arbitrary `dyn PduHint` implementations; guard the framing primitive itself so it always makes forward progress or fails, rather than relying on every hint to avoid the degenerate case.
There was a problem hiding this comment.
Single changed file: a zero-length/unmatched guard in `Framed::read_by_hint` (ironrdp-async). The hazard is real — `read_exact(0)` returns without touching the stream, so the loop re-evaluates the same buffer forever and never reaches the EOF check. Nothing blocking: small, local, wire-neutral, no API break. Non-blocking concerns: (1) the comment claims reachability from a malformed frame, but every in-tree hint either always reports matched or takes a length already floored above zero by `find_size`, and `ErrorKind::InvalidData` inherits that mislabelling; (2) the identical loop in ironrdp-blocking is left unguarded, so the goal holds for one of two mirror impls, and the invariant belongs on `PduHint::find_size` docs; (3) `&& !matched` lets `Some((true, 0))` return an empty frame where plain `length == 0` is simpler and stronger; (4) the guard is untested and in-tree unreachable, though `[lib] test = false` makes a test costly.
Protocol analysis: partially_accepted — Kept mapping 2: `Some((true, 0))` still takes `read_exact(0)` and returns an empty frame; I report that asymmetry. Refined mapping 1: the MS-RDPBCGR 3.2.5.2/3.3.5.2 length-consistency duty is already discharged by `find_size` and `TpktHeader::read`, which floor TPKT at 7 and fast-path at the header size, so calling this guard spec conformance overstates it — it hardens a `PduHint` contract no wire input can violate in-tree. Kept mapping 3 as accurate but immaterial. Rejected both potential discrepancies: absent T123/T125 coverage and the lack of an explicit MUST against zero-length fast-path frames are corpus caveats about bounds this diff never changes. No protocol concern was missed; no wire format or state transition is touched.
- non_blocking / medium — crates/ironrdp-async/src/framed.rs:173-184
The comment claims the condition "is reachable from a single malformed frame", which no in-tree evidence supports. `RdpHint`, `X224Hint` and `FastPathHint` all derive their length from `ironrdp_pdu::find_size`, which rejects TPKT `packet_length < 7` (ironrdp-pdu/src/tpkt.rs:67) and fast-path lengths below the header (ironrdp-pdu/src/lib.rs:158); the CredSSP hints and both `RDCleanPathHint` copies always return `matched = true`. So no byte sequence from a peer can produce `Some((false, 0))` — only a faulty out-of-tree `PduHint` can. That mislabelling propagates into the chosen `io::ErrorKind::InvalidData`, which tells callers and telemetry the peer sent malformed data when the actual fault is a local trait-contract violation. Please state the real trigger in the comment and reconsider the error kind, so this reads as hardening of a public trait contract rather than a remotely triggerable DoS fix. - non_blocking / medium — crates/ironrdp-async/src/framed.rs:179-184
The stated motivation is to not rely on every hint behaving, but `crates/ironrdp-blocking/src/framed.rs:91-112` contains the byte-for-byte identical loop with the identical `read_exact(0)` non-progress path and is left unguarded, so a third-party hint returning `Some((false, 0))` still spins there via `ironrdp-blocking/src/connector.rs`. As shipped, the invariant holds for one of two mirror `Framed` implementations, which is the weaker half of a guarantee and duplicates the same responsibility at two call sites. Either mirror the guard in the blocking crate in this PR (two lines), or state the non-zero-length requirement once on the `PduHint::find_size` doc comment (ironrdp-pdu/src/lib.rs:174-178) so implementors learn the contract instead of each consumer defending against it. - non_blocking / low — crates/ironrdp-async/src/framed.rs:179-179
The `&& !matched` qualifier means a hint reporting `Some((true, 0))` still takes `read_exact(0)` and hands the caller an empty `Bytes`, which then fails somewhere downstream with a less specific decode error. A zero-length frame is not meaningful in either case, so the plain `if length == 0` guard is both simpler and strictly stronger, and avoids an undocumented asymmetry a reader must reverse-engineer. If the matched case is deliberately tolerated, say why in the comment. - non_blocking / low — crates/ironrdp-async/src/framed.rs:179-184
The guard is unreachable through any in-tree hint, so no existing or future test exercises it and a later refactor can delete it with nothing failing. A regression test is not free here: `ironrdp-async/Cargo.toml` sets `[lib] test = false` and the crate has no dev-dependencies, so covering this needs either flipping that flag or a `tests/` file plus an executor dependency. Given that cost, documenting the invariant on the `PduHint` trait (see the previous finding) is the cheaper way to keep the guard from silently rotting; a stub-hint test asserting `InvalidData` without looping remains the stronger option if the crate's test configuration is revisited.
| io::ErrorKind::InvalidData, | ||
| "PduHint reported a zero-length unmatched PDU; cannot make progress", | ||
| )); | ||
| } |
There was a problem hiding this comment.
non_blocking / medium: The comment claims the condition "is reachable from a single malformed frame", which no in-tree evidence supports. `RdpHint`, `X224Hint` and `FastPathHint` all derive their length from `ironrdp_pdu::find_size`, which rejects TPKT `packet_length < 7` (ironrdp-pdu/src/tpkt.rs:67) and fast-path lengths below the header (ironrdp-pdu/src/lib.rs:158); the CredSSP hints and both `RDCleanPathHint` copies always return `matched = true`. So no byte sequence from a peer can produce `Some((false, 0))` — only a faulty out-of-tree `PduHint` can. That mislabelling propagates into the chosen `io::ErrorKind::InvalidData`, which tells callers and telemetry the peer sent malformed data when the actual fault is a local trait-contract violation. Please state the real trigger in the comment and reconsider the error kind, so this reads as hardening of a public trait contract rather than a remotely triggerable DoS fix.
| io::ErrorKind::InvalidData, | ||
| "PduHint reported a zero-length unmatched PDU; cannot make progress", | ||
| )); | ||
| } |
There was a problem hiding this comment.
non_blocking / medium: The stated motivation is to not rely on every hint behaving, but `crates/ironrdp-blocking/src/framed.rs:91-112` contains the byte-for-byte identical loop with the identical `read_exact(0)` non-progress path and is left unguarded, so a third-party hint returning `Some((false, 0))` still spins there via `ironrdp-blocking/src/connector.rs`. As shipped, the invariant holds for one of two mirror `Framed` implementations, which is the weaker half of a guarantee and duplicates the same responsibility at two call sites. Either mirror the guard in the blocking crate in this PR (two lines), or state the non-zero-length requirement once on the `PduHint::find_size` doc comment (ironrdp-pdu/src/lib.rs:174-178) so implementors learn the contract instead of each consumer defending against it.
| // the runtime and never observing EOF. This is reachable from a single | ||
| // malformed frame if any `PduHint` reports `Some((false, 0))`, so fail | ||
| // instead of hanging rather than relying on every hint to avoid it. | ||
| if length == 0 && !matched { |
There was a problem hiding this comment.
non_blocking / low: The `&& !matched` qualifier means a hint reporting `Some((true, 0))` still takes `read_exact(0)` and hands the caller an empty `Bytes`, which then fails somewhere downstream with a less specific decode error. A zero-length frame is not meaningful in either case, so the plain `if length == 0` guard is both simpler and strictly stronger, and avoids an undocumented asymmetry a reader must reverse-engineer. If the matched case is deliberately tolerated, say why in the comment.
| io::ErrorKind::InvalidData, | ||
| "PduHint reported a zero-length unmatched PDU; cannot make progress", | ||
| )); | ||
| } |
There was a problem hiding this comment.
non_blocking / low: The guard is unreachable through any in-tree hint, so no existing or future test exercises it and a later refactor can delete it with nothing failing. A regression test is not free here: `ironrdp-async/Cargo.toml` sets `[lib] test = false` and the crate has no dev-dependencies, so covering this needs either flipping that flag or a `tests/` file plus an executor dependency. Given that cost, documenting the invariant on the `PduHint` trait (see the previous finding) is the cheaper way to keep the guard from silently rotting; a stub-hint test asserting `InvalidData` without looping remains the stronger option if the crate's test configuration is revisited.
|
Real-world impact + reproduction (from a downstream server) Adding context that this is a live, exploitable issue rather than just a defensive nicety — I hit it in macrdp, a downstream IronRDP-based RDP server. Impact: unauthenticated, pre-TLS, remote. A single 2-byte fast-path frame ( Verified on unfixed vs fixed builds: firing that exact 2-byte trigger at the unpatched server pegs a worker at ~100% CPU and wedges the accept loop (needs a restart to recover); with this guard, CPU stays at 0%, the accept loop stays live, and each malformed connection is cleanly rejected. Re #1515: that hardened macrdp is shipping this as a vendored patch in the meantime. Happy to add a unit test here — a stub |
Problem
Framed::read_by_hintloops to skip PDUs that don't match the requested hint:If a
PduHintreportsSome((false, 0))— an unmatched, zero-length PDU — thenread_exact(0)consumes nothing and performs no I/O, so the loop spins forever at 100% CPU: it never.awaits a real read, so it never yields to the runtime and never observes EOF. Because it doesn't yield, it also starves whatever task drives it (e.g. an acceptor's connection loop), so a single malformed frame can take a whole server down.I hit this in practice via the built-in X.224 hint: a 2-byte fast-path-shaped header (first byte with
& 0b11 == 0, e.g.04 00) madefind_sizereturn a zero-length fast-pathPduInfo,read_by_hinttreated it as an unexpected PDU, and spun — unauthenticated, before TLS, from one connection.#1515 hardened
find_sizeso the built-in X.224 / fast-path hint no longer returns a zero-length PDU, which resolves that concrete trigger. This PR is defense-in-depth for the primitive itself:read_by_hintaccepts arbitrarydyn PduHintimplementations, so it shouldn't rely on every hint avoiding the degenerate case to avoid an infinite loop.Fix
Guard the single non-progress case — an unmatched zero-length PDU can't be skipped, so fail instead of looping:
The invariant becomes:
read_by_hintalways makes forward progress or returns an error.Testing
ironrdp-asyncandironrdp-tokioare bothtest = false, and there are currently no async-framing unit tests in the repo (find_size, where the parsing lives, has the regression coverage added in #1515). Verified manually that04 00/00 00no longer spin. Happy to add aread_by_hintregression test inironrdp-testsuite-core(it would needironrdp-async/ironrdp-tokiodev-deps + a small mockPduHint) if you'd prefer one included.