Skip to content

fix(pdu): don't reject a Share Data PDU whose totalLength is under-declared - #1541

Open
GlassOnTin wants to merge 4 commits into
Devolutions:masterfrom
GlassOnTin:fix/under-declared-share-control-length
Open

fix(pdu): don't reject a Share Data PDU whose totalLength is under-declared#1541
GlassOnTin wants to merge 4 commits into
Devolutions:masterfrom
GlassOnTin:fix/under-declared-share-control-length

Conversation

@GlassOnTin

Copy link
Copy Markdown
Contributor

The problem

VirtualBox's built-in RDP server (VRDP) cannot complete a connection with IronRDP. It fails on the last message of the handshake:

ironrdp_connector::connection_finalization: Server Control (Granted Control)
ironrdp_blocking::connector: Wait for PDU connector.state="ConnectionFinalization" hint=X224Hint
RDP protocol decode error: Error {
    context: "<ironrdp_pdu::rdp::headers::ShareControlHeader as ironrdp_core::decode::Decode<'_>>::decode",
    kind: NotEnoughBytes { received: 18, expected: 26 },
}

Nothing is missing. Those two numbers come from the total_length cross-check at the end of ShareControlHeader::decode, so received is the length the server declared and expected is the size the PDU actually decoded to — neither is a byte count off the wire:

if total_length < header_length {
    return Err(not_enough_bytes_err!(total_length, header_length));
}

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 declares totalLength as 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:

if total_length > header_length {
    let padding = total_length - header_length;
    ensure_size!(in: src, size: padding);
    read_padding!(src, padding);
}

The is_empty_output_pdu special case goes with it: a total_length of 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 Decode impl, via ensure_fixed_part_size! — and never reaches this check. The existing test proves it and still passes:

fn from_header_only_buffer_rejects_rdp_pdu_client_font_list() {
    assert!(decode::<ShareControlHeader>(&CLIENT_FONT_LIST_BUFFER[..18]).is_err());
}

Tests

Added one that fails without this change with the reported error verbatim:

fn from_buffer_with_under_declared_total_length_parses_rdp_pdu_server_font_map() {
    let mut buf = SERVER_FONT_MAP_BUFFER;
    buf[0] = 18;
    assert_eq!(SERVER_FONT_MAP.clone(), decode(buf.as_ref()).unwrap());
}

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.

…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
@github-actions github-actions Bot added maintainer-required Maintainer review or intervention is required risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny labels Aug 4, 2026

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.

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 {

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.

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.

@github-actions github-actions Bot added risk/medium Behavioral change that does not substantially alter a core public API scope/core Touches the core architectural tier size/S Size: 30-149 lines of code and removed risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny labels Aug 4, 2026
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.
GlassOnTin added a commit to GlassOnTin/IronRDP that referenced this pull request Aug 4, 2026
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.
@github-actions github-actions Bot added risk/unknown size/XS Size: Under 30 lines of code and removed size/S Size: 30-149 lines of code risk/medium Behavioral change that does not substantially alter a core public API labels Aug 4, 2026
GlassOnTin added a commit to GlassHaven/Haven that referenced this pull request Aug 4, 2026
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.

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.

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 {

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.

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.

@github-actions github-actions Bot added kind/protocol Changes how we encode/decode or interpret RDP wire packets risk/medium Behavioral change that does not substantially alter a core public API ai-reviewed/1 One automated review completed and removed risk/unknown maintainer-required Maintainer review or intervention is required labels Aug 9, 2026

@github-actions github-actions Bot 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.

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());
}

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.

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.

Comment thread crates/ironrdp-pdu/src/rdp/headers.rs Outdated
//
// 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.

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.

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.

@GlassOnTin

Copy link
Copy Markdown
Contributor Author

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 master independent of VRDP.

header_length is header.size() — the re-encoded size, not the bytes that were on the wire. For a Font Map the two differ, because ShareDataPdu::from_type substitutes FontPdu::default() when the cursor is empty:

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 totalLength = 18 is measured against 26, and master refuses it. That is a spec-conformant encoding being rejected, and it is why I could not narrow this by comparing lengths — the quantity being compared against is not the one totalLength describes.

I did try the derived version you'd expect, accepting totalLength when it equals the bytes actually consumed. It is not in the diff, because it turned out to be dead code: the Font Map is the only PDU whose re-encoded size exceeds what it consumed, so the type check always reached the case first. I mutated the clause away and no test changed result, so I removed it rather than ship an unreachable branch with a confident comment on it.

The over-declared branch is untouched, ensure_size! included.

Two tests, and I checked both can actually fail rather than assuming:

  • from_header_only_buffer_with_matching_total_length_parses_rdp_pdu_server_font_map — the 18-byte Font Map declaring 18, i.e. the conformant case master refuses. This is the test your automation suggested; it was a good catch.
  • reject_under_declared_non_output_data_pdu — a ShutdownDenied declaring 17, the exact example from your 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 that my first revision broke. cargo test --workspace passes (17 suites) and clippy is clean.

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 master accept the conformant 18-byte Font Map, and it leaves VirtualBox broken. I have a preference but not a strong one; it is your call which side of that line the crate should sit on.

@github-actions github-actions Bot added the maintainer-required Maintainer review or intervention is required label Aug 9, 2026
@github-actions github-actions Bot added size/S Size: 30-149 lines of code and removed size/XS Size: Under 30 lines of code labels Aug 9, 2026
…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.
@GlassOnTin
GlassOnTin force-pushed the fix/under-declared-share-control-length branch from 437092c to 79ec46f Compare August 9, 2026 20:50
@github-actions github-actions Bot added breaking-change Includes a breaking change, and requires special scrutiny at the boundaries risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny and removed risk/medium Behavioral change that does not substantially alter a core public API labels Aug 9, 2026
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 breaking-change Includes a breaking change, and requires special scrutiny at the boundaries kind/protocol Changes how we encode/decode or interpret RDP wire packets maintainer-required Maintainer review or intervention is required risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny scope/core Touches the core architectural tier size/S Size: 30-149 lines of code

Development

Successfully merging this pull request may close these issues.

2 participants