fix(pdu): keep undefined channel option bits instead of refusing - #1911
Conversation
`ChannelDef::decode` rejects the whole PDU when `options` carries a bit outside the eleven defined flags. [MS-RDPBCGR] 2.2.1.3.4.1 does not ask a server to validate that field, and for several flags it asks the opposite: CHANNEL_OPTION_INITIALIZED, ENCRYPT_RDP, ENCRYPT_SC and ENCRYPT_CS are each "unused and its value MUST be ignored by the server", and SHOW_PROTOCOL likewise. So the strictness is not the protocol's, and it costs sessions. rdesktop 1.9.0 cannot connect to an IronRDP server at all. It writes this one field big-endian while the rest of TS_UD_CS_NET is little-endian (secure.c:516 uses out_uint32_be where every neighbouring field uses out_uint32_le), so each channel's flags arrive byte-swapped and every set bit lands outside the mask. The connection dies at GCC, before a single channel is joined. Captured from the wire, "cliprdr" arrives as c0 a0 00 00 and read little-endian that is 0x0000a0c0 -- all bits undefined. This is the "peer advertisements" case in STYLE.md's "Decoding unknown values": decode with `from_bits_retain`, which keeps the unknown bits so a decode -> encode round-trip reproduces the wire bytes, rather than `from_bits` (fatal on unknown bits) or `from_bits_truncate` (drops them and breaks the round-trip the replay tooling and fuzz oracle rely on). Three tests: the known bits stay readable while an unknown bit is kept; the wholly-undefined rdesktop value still yields a channel; and the raw bits survive a decode -> encode round-trip.
4d59f4c to
a7bf9b9
Compare
|
I went through this the same way I did on #1837, checking against the spec and the STYLE.md addition rather than taking the PR body's word for it. The MS-RDPBCGR 2.2.1.3.4.1 reading still holds: Five of the eleven documented ChannelOptions flags carry literal "MUST be ignored by the server" language, and the server-side processing rules in 3.3.5.3.3 never ask for validation of this field. The rdesktop 1.9.0 byte-order bug is the same trace as before: secure.c writes this one field big-endian while the rest of TS_UD_CS_NET is little-endian. The change from All three tests exercise what the rationale calls for: Known bits preserved alongside an unknown one, the actual rdesktop wire value still yielding a channel, and a full round trip. This is a sound fix. |
There was a problem hiding this comment.
🟢 Approval recommended
The implementation is covered by focused tests; only a documentation nit remains.
Pull request overview
Updates GCC channel decoding to preserve undefined option bits, improving compatibility with nonconforming clients.
Changes:
- Uses
from_bits_retainfor channel options. - Adds decoding and round-trip regression tests.
- Contains a non-blocking stale
STYLE.mdreference.
File summaries
| File | Description |
|---|---|
crates/ironrdp-pdu/src/gcc/network_data.rs |
Preserves unknown channel option bits and adds interoperability tests. |
Review details
Suppressed comments (1)
crates/ironrdp-pdu/src/gcc/network_data.rs:365
- This test comment also attributes the behavior to a nonexistent section of
STYLE.md. Avoid leaving a second dead reference; the test only needs to state the behavior it verifies.
/// Retaining unknown bits keeps decode -> encode byte-for-byte, which is
/// the reason STYLE.md prefers `from_bits_retain` over `from_bits_truncate`
/// for advertisement fields: replay tooling and the round-trip fuzz oracle
/// depend on it.
- Files reviewed: 1/1 changed files
- Comments generated: 1
- Review effort level: Balanced
| // This is the "peer advertisements" case in STYLE.md's "Decoding | ||
| // unknown values": `from_bits_retain` keeps the unknown bits so that a | ||
| // decode -> encode round-trip reproduces the wire bytes. |
There was a problem hiding this comment.
The section does exist — STYLE.md has a “Decoding unknown values” heading (line 141 on current master), and ChannelOptions is the worked example it uses (line 158), which is why the comment points there instead of restating the rationale. The “peer advertisement” wording comes from that same section.
There was a problem hiding this comment.
The production change is correct and conformant: ChannelDef::decode now retains undefined ChannelOptions bits instead of rejecting the PDU, matching MS-RDPBCGR 2.2.1.3.4.1 (several flags are explicitly 'MUST be ignored by the server', none require validation) and the actual in-repo guidance in crates/ironrdp-pdu/README.md 'On bit flags', restoring the byte-exact decode->encode round-trip and unblocking rdesktop 1.9.0's byte-swapped options field. All five candidates are accepted; each is a low-severity prose or test-quality issue, not a correctness defect: the comment and test doc cite a nonexistent STYLE.md section (real guidance is in the crate README), the test doc misstates what the round-trip fuzz oracle asserts, ChannelOptions lacks the README-prescribed const _ = !0 with a noted activex from_bits ripple, and two minor test simplifications are available (encode_vec helper, dropping a subsumed contains assertion).
| // This is the "peer advertisements" case in STYLE.md's "Decoding | ||
| // unknown values": `from_bits_retain` keeps the unknown bits so that a | ||
| // decode -> encode round-trip reproduces the wire bytes. |
There was a problem hiding this comment.
[skeptical] Comment cites a STYLE.md section that does not exist at this head — low 🟡 — The comment justifies the change via STYLE.md's 'Decoding unknown values' section with ChannelOptions as its 'peer advertisements' example, but that section is not in this repository: STYLE.md has no such heading and no occurrences of 'retain', 'peer', or 'ChannelOptions', the claimed line 141 falls in the Logging examples, and this PR does not modify STYLE.md. The real in-repo guidance is crates/ironrdp-pdu/README.md 'On bit flags', which prescribes from_bits_retain for resilient parsing; the comment should cite that, and the protocol substance of the fix is unaffected.
| /// Retaining unknown bits keeps decode -> encode byte-for-byte, which is | ||
| /// the reason STYLE.md prefers `from_bits_retain` over `from_bits_truncate` | ||
| /// for advertisement fields: replay tooling and the round-trip fuzz oracle | ||
| /// depend on it. |
There was a problem hiding this comment.
[skeptical] Test doc comment misstates what the round-trip fuzz oracle asserts — low 🟡 — The doc comment claims byte-for-byte retention matters because 'the round-trip fuzz oracle depend[s] on it' and STYLE.md prefers from_bits_retain for that reason. Both claims fail at this head: crates/ironrdp-fuzzing/src/oracles/mod.rs (pdu_round_trip_one) documents that byte stability is deliberately NOT asserted and only requires decode -> encode -> re-decode, which from_bits_truncate also satisfies, and STYLE.md contains no such guidance. The assert_eq!(out, bytes) test is fine as a local check, but the stated invariant is false and could mislead someone refactoring the encoder on the assumption the oracle enforces byte equality.
| // This is the "peer advertisements" case in STYLE.md's "Decoding | ||
| // unknown values": `from_bits_retain` keeps the unknown bits so that a | ||
| // decode -> encode round-trip reproduces the wire bytes. | ||
| let options = ChannelOptions::from_bits_retain(src.read_u32()); |
There was a problem hiding this comment.
[skeptical] Resilient parsing adopted without the README-prescribed const _ = !0 — low 🟡 — crates/ironrdp-pdu/README.md ('On bit flags') prescribes using both from_bits_retain and const _ = !0 when resilient parsing is required, so a future accidental switch to from_bits_truncate cannot silently destroy unknown bits and complement behaves as expected for externally defined bits. The ChannelOptions bitflags block (lines 307-319) lacks the marker, unlike most peer-advertised flag types in this crate. Ripple to check before adding it: const _ = !0 makes ChannelOptions::from_bits infallible, which would silently neuter the existing unknown-bits rejection at ironrdp-activex control.rs:12828 (SetVirtualChannelOptions returns E_INVALIDARG).
| let mut out = vec![0u8; bytes.len()]; | ||
| decoded.encode(&mut WriteCursor::new(&mut out)).expect("re-encode"); | ||
| assert_eq!(out, bytes); |
There was a problem hiding this comment.
[code-compressor] Use the existing encode_vec helper instead of a manual WriteCursor round-trip — low 🟡 — The round-trip test hand-rolls buffer allocation and cursor plumbing (vec![0u8; bytes.len()] plus WriteCursor and an expect). The crate already exposes ironrdp_core::encode_vec for exactly this, and it is the established test convention here (gcc/monitor_data.rs and many capability-set tests). Since ChannelDef::size() is the fixed 12-byte CLIENT_CHANNEL_SIZE, encode_vec(&decoded) produces identical bytes while dropping the mutable buffer, the WriteCursor usage, and one expect site.
|
|
||
| let decoded = ChannelDef::decode(&mut ReadCursor::new(&bytes)).expect("undefined bits are not fatal"); | ||
|
|
||
| assert!(decoded.options.contains(known)); |
There was a problem hiding this comment.
[code-compressor] Drop the contains assertion subsumed by the exact-bits equality on the next line — low 🟡 — assert!(decoded.options.contains(known)) is logically implied by the very next assertion, assert_eq!(decoded.options.bits(), known.bits() | 0x0000_0044): any value whose bits equal known | extra necessarily contains known. Removing the weaker assertion preserves the test's pass/fail behavior exactly while shrinking it to a single decisive check; keeping both only changes failure-message clarity, not coverage.
e56bb42
into
Devolutions:master
ChannelDef::decoderejects the whole PDU whenoptionscarries a bit outside the eleven defined flags. [MS-RDPBCGR] 2.2.1.3.4.1 does not ask a server to validate that field, and for several of the flags it asks the opposite —CHANNEL_OPTION_INITIALIZED,ENCRYPT_RDP,ENCRYPT_SCandENCRYPT_CSare each "unused and its value MUST be ignored by the server", andCHANNEL_OPTION_SHOW_PROTOCOLlikewise.So the strictness is not the protocol's, and it costs sessions.
What it costs
rdesktop 1.9.0 cannot connect to an IronRDP server at all. It writes this one field big-endian while the rest of
TS_UD_CS_NETis little-endian —secure.c:516usesout_uint32_bewhere every neighbouring field usesout_uint32_le— so each channel's flags arrive byte-swapped and every set bit lands outside the mask.Captured from the wire against an IronRDP-based server:
Every bit is undefined, so the connection dies at GCC, before a single channel is joined. This is a client bug, not a vendor extension — but it is a client that has shipped for years.
The fix
This is the "peer advertisements" case in the new
STYLE.md"Decoding unknown values" section, which usesChannelOptionsas its worked example: decode withfrom_bits_retain. Unknown bits are kept, never fatal, and the decode → encode round-trip reproduces the wire bytes — which is what the round-trip fuzz oracle relies on, and the reason the guide prefersretainoverfrom_bits_truncate.Two alternative explanations were checked against the same bytes and ruled out: the block is not misaligned (
4 + 5*12 = 64is exactlyblockLen) andchannelCountis not misread.Tests
Three: the known bits stay readable while an unknown bit is kept; the wholly-undefined rdesktop value still yields a channel; and the raw bits survive a decode → encode round-trip. All three fail under
from_bits_truncate.(Reopened after my fork was briefly deleted, which auto-closed the original #1837. Rebased onto current master and switched from
from_bits_truncatetofrom_bits_retainto match the STYLE.md guidance added since.)