Skip to content

fix(rdpeudp): accept version 1/2 SYN offers on the server side too - #1965

Open
Greg Lamberson (glamberson) wants to merge 3 commits into
Devolutions:masterfrom
lamco-admin:fix/rdpeudp-server-accept-v1v2
Open

Greg Lamberson (glamberson) wants to merge 3 commits into
Devolutions:masterfrom
lamco-admin:fix/rdpeudp-server-accept-v1v2

Conversation

@glamberson

Copy link
Copy Markdown
Contributor

Summary

accept() rejected any client offering a protocol version below 3 outright, with a comment noting this crate only implemented the MS-RDPEUDP2 (version 3) data transfer. That gap closed for the client-connects-out role (ConnectionConfig::offer_version, connect(), handle_syn_ack()'s negotiation), but accept(), the server-accept role, was never updated to match. Every real-world Windows client I have observed offers version 1 or 2 in its SYN, so a server built on this crate could never actually negotiate the sideband UDP transport with such a client, only ever fall back to the main transport.

Changes

  • accept() now mirrors handle_syn_ack()'s already-proven version selection: settle on the client's offered version when it is 1 or 2, otherwise settle on our own highest (3), per MS-RDPEUDP 1.7's negotiate-down MUST clause. WireFormat is selected via UdpVersion::uses_v2_wire_format(), the same helper the client side already uses.
  • enqueue_syn_ack() now echoes the negotiated version instead of unconditionally claiming 3.
  • Fixes an adjacent gap in the same code: 3.1.5.1.1 says an invalid cookieHash on a version 3 SYN MUST drop the connection to version 2 rather than refuse it, which the crate could not do until now either.
  • Rewrote the two existing tests that encoded the old refuse-on-low-version behavior. Added coverage for settling on version 1, rejecting a genuinely unrecognized low version, and a full handshake plus bidirectional data exchange with the server on the accept side settling on version 2.

Test plan

cargo xtask check fmt/lints/tests/typos/locks all pass.

accept() rejected any client offering a protocol version below 3
outright, with a comment noting this crate only implemented the
MS-RDPEUDP2 (version 3) data transfer. That implementation gap closed
for the client-connects-out role (ConnectionConfig::offer_version,
connect()'s SYN, handle_syn_ack()'s negotiation), but accept() itself,
the server-accept role, was never updated to match: every real-world
Windows client I have observed offers version 1 or 2 in its SYN, so a
server built on this crate could never actually negotiate the sideband
UDP transport with it, only ever falling back to the main transport.

Mirrors handle_syn_ack()'s already-proven version selection exactly:
settle on the client's offered version when it's 1 or 2, otherwise
settle on our own highest (3), per MS-RDPEUDP 1.7's negotiate-down MUST
clause; select WireFormat via UdpVersion::uses_v2_wire_format() the
same way. enqueue_syn_ack() now echoes the negotiated version instead
of unconditionally claiming 3. Also fixes an adjacent gap in the same
code: 3.1.5.1.1 says an invalid cookieHash on a version 3 SYN MUST drop
the connection to version 2 rather than refuse it, which the crate
could not do until now either; it does that too.

Rewrote the two existing tests that encoded the old refuse-on-low-
version behavior to assert the new negotiate-down one instead, and
added coverage for: settling on version 1, rejecting a genuinely
unrecognized low version (there is nothing to negotiate down to below
1), and a full handshake plus bidirectional data exchange with the
server on the accept side settling on version 2, exercising the
already-existing version 1/2 data path (handle_v1_datagram and its
neighbors) from the server-accept role for the first time.
@github-actions github-actions Bot added kind/protocol Affects RDP or related protocol behavior risk/medium Behavioral change that does not substantially alter a core public API size/M Size: up to 449 counted lines and 10 files; exceeds S in either measure labels Sep 13, 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 PR rewrites accept() to negotiate versions per MS-RDPEUDP: offers at/above 0x0101 settle on 3, exact 1/2 settle there, other sub-3 values are refused; a v3 SYN with a bad cookieHash downgrades to v2 per 3.1.5.1.1, and enqueue_syn_ack echoes the negotiated version. It mirrors the proven handle_syn_ack() selection, reuses uses_v2_wire_format(), and adds solid tests including a bidirectional v2 handshake. Verified independently: v3 behavior is byte-identical, RTO/ACK-delay floors follow the negotiated version, retransmits resend the stored SYN+ACK. Publishing two candidates: the unchecked uUdpVer read when RDPUDP_VERSION_INFO_VALID is clear (protocol-1) and the duplicated wire-format mapping (dup-wire-format-selection). The test-literal dedup candidate is rejected as optional style churn against the file's literal-per-test convention.

  1. [protocol] accept trusts uUdpVer without RDPUDP_VERSION_INFO_VALID, bypassing the mandatory version-1 fallback — medium 🟠 — crates/ironrdp-rdpeudp/src/connection.rs
    MS-RDPEUDP 2.2.2.9 defines RDPUDP_VERSION_INFO_VALID as the flag that makes uUdpVer indicate a supported version; 1.7 makes indicating a version optional and 3.1.5.1.3 fixes version 1 as the settlement when one is not indicated. The rewritten negotiation reads syn_data_ex.udp_ver unconditionally: a SYNEX with the flag clear and 0x0000 is refused where version 1 is mandated, and any other unvalidated value can settle the connection at a version the peer never advertised. The same unchecked read exists in handle_syn_ack, so a fix should cover both roles.

Comment thread crates/ironrdp-rdpeudp/src/connection.rs Outdated
@github-actions github-actions Bot added the ai-reviewed/1 One automated review completed label Sep 13, 2026
accept() and handle_syn_ack() each independently reimplemented the
same negotiated-version-to-WireFormat mapping. Both call sites only
ever see a version already restricted to V1/V2/V3, so a private
WireFormat::for_version helper is total over every reachable value
and behavior-preserving.

@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.

PR #1965 makes the server-accept path negotiate protocol versions instead of refusing sub-3 offers: accept() settles on V3 for offers >= 0x0101, on exactly 1 or 2 otherwise, refuses unrecognized low offers, checks cookieHash only on V3 SYNs with the 3.1.5.1.1 downgrade-to-V2 on mismatch, and enqueue_syn_ack() now echoes the negotiated version. Independent inspection confirms the logic mirrors handle_syn_ack(), WireFormat::for_version() is equivalent at both call sites, the hash is correctly confined to client V3 SYNs per 2.2.2.9, and the new tests cover server-side v1/v2 handshake and data flow. No protocol conformance or correctness defect found. All four valid candidates are published unchanged: one low-severity leftover (accept() still unconditionally requires a configured cookie_hash even for v1/v2 offers that carry none) and three low-severity dedup/simplification nits in accept() and the new tests.

  1. [skeptical] accept() still requires a configured cookie_hash for version 1/2 offers that carry and check none — low 🟡 — crates/ironrdp-rdpeudp/src/connection.rs
    The new negotiation lets accept() settle on version 1 or 2, for which 2.2.2.9 forbids a cookieHash and the added code deliberately skips the check, yet the unconditional config.cookie_hash extraction above the block still errors first with the misleading message 'needed to check the client's SYN'. connect() keys this requirement on offer_version == V3, so the asymmetry is an incomplete removal of the v3-only assumption, and the ConnectionConfig::cookie_hash doc claiming a connection without it cannot be built is stale. Impact is limited today because the only in-repo caller, accept_udp, always sets the hash, but a sans-I/O server serving only v1/v2 clients cannot build a config without a dummy secret.
  2. [code-compressor] accept() re-tests for V3 and uses a mutable temp; fold the hash check into the >=V3 arm — low 🟡 — crates/ironrdp-rdpeudp/src/connection.rs
    The negotiated_version == V3 guard is only true when the >= V3 arm took the branch, so the second decision re-derives information the first computed, and negotiated_version is mut only for the V2 downgrade. Folding the hash check into the first arm as an expression removes one branch, the mut binding, and the nested block with identical outcomes: missing hash on a v3 SYN still errors, mismatched hash still downgrades to V2, v1/v2 offers still skip the check, unrecognized offers still error before hash work. Tradeoff: the spec's two-step negotiate-then-downgrade narrative is less literally mirrored.

Comment thread crates/ironrdp-testsuite-core/tests/rdpeudp/connection.rs
Comment thread crates/ironrdp-testsuite-core/tests/rdpeudp/connection.rs
@github-actions github-actions Bot added ai-reviewed/2 Final automated review completed and removed ai-reviewed/1 One automated review completed labels Sep 13, 2026
…iteral

full_handshake_and_data_exchange_when_the_server_settles_on_version_2
re-enacted establish_pair()'s SYN -> accept -> SYN+ACK -> final-ACK exchange
by hand, differing only in the client config. Extracted handshake(client_config,
server_isn), which drives the exchange through the final ACK without draining
either side's event queue (poll_event() is a separate queue from
poll_transmit(), so callers are free to drain or assert on it however they
need). establish_pair() is now a thin wrapper over handshake(default_config(100), 200)
that also drains both sides' events, keeping its existing no-argument signature
intact for its ~20 existing call sites.

Seven tests hand-built the same ~20-line V1Datagram SYN literal, differing
only in syn_data_ex's udp_ver and cookie_hash. Extracted
syn_datagram(udp_ver, cookie_hash), folding test_syn_data_ex() into a call
with UdpVersion::V3 and TEST_COOKIE_HASH. Per-site spec citation comments
moved to the call site they annotate.

No behavior change: same assertions, same wire bytes, same timing.
@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 and removed risk/medium Behavioral change that does not substantially alter a core public API labels Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-reviewed/2 Final automated review completed kind/protocol Affects RDP or related protocol behavior maintainer-required Maintainer review or intervention is required risk/high Substantial core public API impact, or fail-closed triage; needs maintainer-level scrutiny 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.

1 participant