fix(pdu): don't reject a Share Data PDU whose totalLength is under-declared - #1541
fix(pdu): don't reject a Share Data PDU whose totalLength is under-declared#1541GlassOnTin wants to merge 4 commits into
Conversation
…clared
VirtualBox's VRDP sets the Server Font Map PDU's totalLength to 18 — the two
headers — and never counts the 8-byte Font Map body that follows it. The PDU is
complete on the wire; only the declared length is wrong. Decoding it failed with
NotEnoughBytes { received: 18, expected: 26 }
which killed the connection during Connection Finalization, every time, against
that server.
The name of the error is the tell that nothing was missing: the inner PDU had
already decoded successfully, out of bytes that were really there, before the
consistency check on totalLength ran at all. A genuinely short buffer fails
earlier, inside from_type — as the client-font-list truncation test still shows.
So the only case that leaves anything to do is a server declaring *more* than we
consumed, which is the existing Windows trailing-padding path.
That collapses the check to `total_length > header_length`, and the empty-output
special case with it: a zero totalLength now falls through as the no-op it
already was.
Reported against Haven on VirtualBox: GlassHaven/Haven#422
Marc-André Moreau (mamoreau-devolutions)
left a comment
There was a problem hiding this comment.
Request changes: the compatibility relaxation must not accept arbitrary under-declared Data PDUs. The existing zero-length non-output regression test now fails, contrary to the Share Control length consistency requirement in MS-RDPBCGR.
| } | ||
|
|
||
| // Some Windows versions append padding that is not part of the inner unit. | ||
| if total_length > header_length { |
There was a problem hiding this comment.
MS-RDPBCGR 2.2.8.1.1.1.1 defines totalLength as the packet length including the Share Control Header, and 3.2.5.2 requires checking it for consistency (a discrepancy should cause the connection to be dropped). This condition now accepts totalLength < header_length for every Data PDU, including totalLength == 0; the existing rdp::headers::tests::reject_zero_length_non_output_data_pdu fails because a ShutdownDenied PDU with a zero length is accepted. Please retain the prior exception only for empty Update/Pointer PDUs, or narrow the compatibility handling to a complete known VRDP Font Map body rather than accepting arbitrary under-declared lengths.
Review was right: dropping the check wholesale relaxed more than the compatibility case needs. `reject_zero_length_non_output_data_pdu` is an explicit regression test and my change made it pass through. I missed it because I ran `-p ironrdp-testsuite-core` rather than the workspace, and that test lives in ironrdp-pdu's own `mod tests`. The workspace run reproduces it immediately. The distinction that matters: a server undercounting its own body (VirtualBox declaring 18 for a 26-byte Server Font Map) has bytes that really arrived and an inner PDU that already decoded from them. A zero totalLength has none of that — it is a length field never filled in, and the only legitimate use is the no-op empty Update / Pointer PDU. So that case keeps its carve-out and everything else with a zero length is still rejected, exactly as before. Workspace tests: 0 failures. Both behaviours are pinned — `from_buffer_with_under_declared_total_length_parses_rdp_pdu_server_font_map` for the relaxation, `reject_zero_length_non_output_data_pdu` for its limit.
Upstream review (Devolutions#1541) caught that dropping the check wholesale relaxed more than the VirtualBox compatibility case needs: the existing reject_zero_length_non_output_data_pdu regression test passed through. I missed it by running -p ironrdp-testsuite-core instead of the workspace; that test lives in ironrdp-pdu's own mod tests. A server undercounting its own body has bytes that really arrived. A zero totalLength does not — it is a field never filled in, legitimate only for the no-op empty Update/Pointer PDU, which keeps its carve-out. Workspace: 0 failures.
Upstream review of Devolutions/IronRDP#1541 caught that the relaxation shipped in v5.86.38 was wider than the VirtualBox case needs: it also accepted a *zero* totalLength on a non-output Data PDU, which ironrdp-pdu has an explicit regression test forbidding. My own test run missed it because I ran `-p ironrdp-testsuite-core` rather than the workspace, and that test lives in ironrdp-pdu's own `mod tests`. Running the workspace reproduces it immediately. A server undercounting its own body (VirtualBox declaring 18 for a 26-byte Server Font Map) has bytes that really arrived and an inner PDU already decoded from them. A zero totalLength has neither — it is a field never filled in, and the only legitimate use is the no-op empty Update/Pointer PDU, which keeps its carve-out. VirtualBox is unaffected: it declares 18 and 24, not 0. Verified the pin reaches the binaries, not just the manifest: ironrdp-pdu compiles from haven-pin-20260804b#59f1b354 for all three ABIs. IronRDP workspace 0 failures; 87 rdp-transport tests pass.
Marc-André Moreau (mamoreau-devolutions)
left a comment
There was a problem hiding this comment.
Request changes: the new zero-length guard fixes the specific regression, but the generalized under-declared-length acceptance remains too broad for protocol correctness.
| } | ||
|
|
||
| // Some Windows versions append padding that is not part of the inner unit. | ||
| if total_length > header_length { |
There was a problem hiding this comment.
Still request changes: this condition leaves every positive totalLength < header_length accepted. That includes malformed non-output Data PDUs (for example, a header-only PDU declared with 1–17 bytes), not just the complete VRDP Font Map compatibility case. MS-RDPBCGR §3.2.5.2 requires the server to validate totalLength consistency; please narrow the under-declared exception to the validated Server Font Map case, or otherwise prove that the complete inner PDU is present before accepting it.
There was a problem hiding this comment.
The diff restructures totalLength handling in ShareControlHeader::decode to accept a non-zero under-declared value. Traced against the pre-change condition, over-declared padding-skip (still guarded by ensure_size!) and zero-length rejection with its empty Update/Pointer carve-out are preserved exactly, so only the intended relaxation changes. The protocol analysis missed that header_length is header.size(), the re-encoded size, not bytes consumed: for a Font Map it counts a body from_type defaults in, so master rejects a self-consistent 18-byte header-only Font Map. The change fixes that real defect. Narrower alternatives fail: remaining() does not advance the cursor, so consumed-bytes or is_empty() guards misfire on read-to-end variants, and ironrdp-pdu has no tracing. No blocking findings; callers decode one PDU per slice and the fuzz oracle only asserts re-decodability. Two non-blocking items: an untested acceptance boundary, and a comment understating server-role reach.
Protocol analysis: partially_accepted — Kept: the relaxation drops the totalLength examination of 3.2.5.2/3.3.5.2 and applies in the server role despite a comment about servers only — my second finding. Refined: the handoff reads header_length as bytes consumed; it is header.size(), which for a Font Map counts a body from_type defaults in on an empty cursor, so master rejects a conformant 18-byte header-only Font Map. The removed check was not a working consistency check. Rejected as out of scope: the zero-length carve-out, FlowPDU 0x8000, and unchecked non-Data PDUs are pre-existing and behaviourally unchanged (0x8000 fails ensure_size before and after). The residue concern is real but low impact: callers decode one PDU per slice and remaining() does not advance the cursor. I accept one suggested test, refined.
| buf[0] = 18; | ||
|
|
||
| assert_eq!(SERVER_FONT_MAP.clone(), decode(buf.as_ref()).unwrap()); | ||
| } |
There was a problem hiding this comment.
non_blocking / medium: The new test pins only the "body present, totalLength under-declared" case. The subtler half of the fix is untested: because header.size() counts the FontPdu body that ShareDataPdu::from_type defaults in when the cursor is empty (headers.rs:625-633), master rejects an 18-byte header-only Font Map that declares a fully self-consistent totalLength of 18 (18 < 26 hits the removed branch). That is a spec-conformant encoding the old code refused, and it is the case most likely to be hit in the field. The existing from_header_only_buffer_defaults test does not cover it — its fixture still declares 26. Adding a case with an 18-byte buffer and buf[0] = 18 would pin the acceptance boundary and document why comparing against the re-encoded size, rather than bytes consumed, is what made the check unusable.
| // | ||
| // Zero stays rejected. That is not a server undercounting its own body, it is a | ||
| // length field never filled in; the one legitimate use is the no-op empty Update / | ||
| // Pointer PDU, so that case is carved out rather than the check dropped. |
There was a problem hiding this comment.
non_blocking / medium: The comment justifies the relaxation entirely in terms of server misbehaviour ("Servers get totalLength wrong in both directions"), but this decoder is shared with ironrdp-server and ironrdp-acceptor, which decode client-originated Data PDUs through the same path. The leniency therefore also applies to inbound client PDUs such as Input Event PDUs, widening hostile-input tolerance on a length field that MS-RDPBCGR 3.3.5.2 asks receivers to examine. I checked the narrower alternatives and none are workable here: ReadCursor::remaining() does not advance pos, so a consumed-bytes or is_empty() guard would misfire on the read-to-end variants, and ironrdp-pdu has no tracing dependency to record the inconsistency instead. The blast radius is not itself exploitable — the padding branch is unchanged and still guarded by ensure_size!, and bodies stay bounded by the transport frame — so this is a documentation gap rather than a defect: one sentence noting the relaxation is deliberate in the server role too would stop a future reader assuming it is client-only.
|
Thanks — the second round was right, and I've taken the first of your two options: the exception is now keyed on the Server Font Map PDU type rather than on a length comparison. A header-only non-output PDU declaring 1–17 is rejected again, so relative to the branch you reviewed this is a net tightening, not a further relaxation. There is one thing worth surfacing, because it is a defect in
ShareDataPduType::FontMap => {
let font_pdu = if src.is_empty() { FontPdu::default() } else { FontPdu::decode(src)? };So a header-only Server Font Map re-encodes to 26 while only 18 bytes ever existed. A server sending 18 bytes and honestly declaring I did try the derived version you'd expect, accepting The over-declared branch is untouched, Two tests, and I checked both can actually fail rather than assuming:
Removing the Font Map exception fails both Font Map tests; restoring the open-ended acceptance fails both rejection tests, including the existing If you would rather not carry a per-server exception at all, the alternative I can offer is to drop the VRDP tolerance and keep only the header-only fix — that alone makes |
…nt Map
Addresses review: the previous revision left every positive
`totalLength < header_length` accepted, including a header-only non-output PDU
declaring 1..17, which MS-RDPBCGR 3.2.5.2 asks a receiver to reject. The
tolerance is now keyed on the PDU type instead of being open-ended.
What is tolerated is exactly one server bug: VirtualBox's VRDP declares the two
headers of a Server Font Map (18) and never counts the 8-byte body it does
send. Everything else that under-declares is rejected again, so the change is a
net tightening against the branch under review.
Why the exception is keyed on the type rather than on arithmetic, since the
obvious alternative does not work: `header_length` is `header.size()`, the
*re-encoded* size, not the bytes that were on the wire. `ShareDataPdu::from_type`
substitutes `FontPdu::default()` when the cursor is empty, so a header-only Font
Map re-encodes to 26 while only 18 bytes existed. A conformant server declaring
18 was therefore measured against 26 and refused — master rejects a
spec-correct PDU today, and any comparison against the re-encoded size
reproduces that.
I did try deriving the answer instead, accepting `totalLength` when it equalled
the bytes actually consumed. It reads better and it is dead code: the Font Map
is the only PDU whose re-encoded size exceeds what it consumed, so the type
check always got there first. Mutating that clause away changed no test result,
so it is not in this diff.
The over-declared branch is untouched, including its `ensure_size!`, so the
Windows padding case behaves exactly as before.
Two tests, both confirmed able to fail:
from_header_only_buffer_with_matching_total_length... an 18-byte Font Map
declaring 18 — the conformant encoding master refuses.
reject_under_declared_non_output_data_pdu a ShutdownDenied
declaring 17, the case raised in review.
Removing the Font Map exception fails both Font Map tests; restoring the
open-ended acceptance fails both rejection tests, including the existing
`reject_zero_length_non_output_data_pdu`. `cargo test --workspace` passes
(17 suites), clippy clean.
437092c to
79ec46f
Compare
The problem
VirtualBox's built-in RDP server (VRDP) cannot complete a connection with IronRDP. It fails on the last message of the handshake:
Nothing is missing. Those two numbers come from the
total_lengthcross-check at the end ofShareControlHeader::decode, soreceivedis the length the server declared andexpectedis the size the PDU actually decoded to — neither is a byte count off the wire:For the Server Font Map PDU those sizes are 18 and 26:
SHARE_CONTROL_HEADER_SIZE(10) +ShareDataHeader::FIXED_PART_SIZE(8) +FontPdu(8). VRDP declarestotalLengthas the two headers and never counts its own 8-byte body. All 26 bytes arrive; only the number is wrong.The inner PDU has already decoded successfully out of those bytes by the time this check runs, which is what makes the rejection avoidable — the check is a consistency assertion, not a bounds check.
The change
Only a server declaring more than was decoded leaves anything to do, and that case already has a handler — the trailing-padding path added for Windows. So the check collapses to:
The
is_empty_output_pduspecial case goes with it: atotal_lengthof 0 now falls through as the no-op it already was.Genuine truncation is unaffected
This is the part worth checking rather than taking on trust. A short buffer fails earlier — inside the inner PDU's own
Decodeimpl, viaensure_fixed_part_size!— and never reaches this check. The existing test proves it and still passes:Tests
Added one that fails without this change with the reported error verbatim:
It sits next to
from_header_only_buffer_defaults_rdp_pdu_server_font_map, which already tolerates an 18-byte buffer — this covers the neighbouring case of a 26-byte buffer with an 18-byte declaration.cargo test -p ironrdp-testsuite-core: 1049 passed, 0 failed.Where this was found
Downstream in Haven, from a user who could not connect to VirtualBox at all (GlassHaven/Haven#422). The same fix also resolves an earlier report on that issue where VRDP under-declares a Pointer update mid-session as 24 bytes on an 8565-byte frame — same defect, different PDU, and one we had been working around client-side because it happened after connect. This one happens during finalization, where there is no session yet to work around it in.
I have not captured the VRDP bytes myself, so which PDU the server sends at that point rests on sequence position — it follows Granted Control, where the client waits for Font Map — rather than on a capture. The size arithmetic matches Font Map exactly, but a Control PDU is also 26 bytes, so I would not claim more than that. The fix does not depend on which it is.