ClientConnector answers Auto-Detect Requests only inside its own ConnectTimeAutoDetection phase, which ends at the first PDU off the MCS message channel. A Windows RDS host we connect to keeps sending SEC_AUTODETECT_REQ probes after that phase has closed. Every later phase hands whatever arrives straight to its own decoder, so the connection dies before the session opens.
mstsc connects to the same host without complaint, and other RDP hosts work — they simply never start a licensing exchange.
Environment
ironrdp-connector 0.10.0, ironrdp-pdu 0.9.0, ironrdp-session 0.11.0, ironrdp-core 0.2.1 (latest published on crates.io)
- Server: Windows host with RDS licensing (CAL) — licensing exchange runs,
mstsc fine
- Client: custom sans-I/O driver on the connector's
Sequence API
Symptom
ConnectFailed("[decode during SERVER_NEW_LICENSE/LicenseExchangeState::UpgradeLicense
@ .../core/src/ops/function.rs:250] decode error")
The licensing exchange runs correctly right up to the last step — ClientNewLicenseRequest sent, ServerPlatformChallenge received, ClientPlatformChallengeResponse sent — and then the next PDU fails to decode.
The SERVER_NEW_LICENSE in that message is only the state's static with_context label; it says nothing about which PDU actually arrived. It sent our first round of diagnosis down the wrong path entirely (we assumed a truncated licensing PDU).
What actually arrives
Upstream prints no bytes with that error, so we had to patch the crate locally just to log them:
user_data_len = 10
user_data = [0, 16, 0, 0, 6, 0, 0, 0, 20, 0]
error = invalid `securityHeaderFlags`
| Field |
Bytes |
Value |
BasicSecurityHeader.flags |
00 10 |
0x1000 — SEC_AUTODETECT_REQ, not SEC_LICENSE_PKT (0x0080) |
headerLength |
06 |
6 |
headerTypeId |
00 |
TYPE_ID_AUTODETECT_REQUEST |
sequenceNumber |
00 00 |
0 |
requestType |
14 00 |
0x0014 — RDP_RTT_REQUEST |
So it is not a licensing PDU at all. license_exchange.rs feeds whatever arrives into LicensePdu::decode without checking the security header, and an unrelated advisory PDU takes down the whole connection.
Fixing that alone only moves the failure: with licensing completing, the same probe then reaches decode_share_control in connection_activation.rs's CapabilitiesExchange.
Suggested shape of a fix
What worked for us, in a local fork of 0.10.0 (~97 changed lines across license_exchange.rs, connection_activation.rs and one pub(crate) in connection.rs):
license_exchange.rs — each of the three receiving states (NewLicenseRequest, PlatformChallenge, UpgradeLicense) checks BasicSecurityHeader.flags for SEC_LICENSE_PKT before decoding. A payload without it is logged at DEBUG and skipped, and the state machine stays in the same state to await the real licensing PDU.
connection_activation.rs — in CapabilitiesExchange, decode the AutoDetectReqPdu and answer it through connection::respond_to_connect_time_autodetect, then stay in the state still waiting for Server Demand Active. This is the same skip-and-stay shape the code already applies a few lines below for ServerDeactivateAll.
Staying put is the load-bearing part. An earlier attempt treated the undecodable packet as the end of licensing and advanced to LicenseExchanged; the exchange was then left half-finished and the connection failed one step later instead. Skipping and waiting matches what MS-RDPBCGR implies: auto-detect is advisory, and a client that never answers an RTT probe is fine — the server proceeds regardless.
Verified against the live host, in order: probe skipped mid-licensing, ServerUpgradeLicense (2196 bytes) received, License verified with success, probe answered during Capabilities Exchange, eight Connection Finalization steps, Connected with success, frames rendering.
We deliberately did not touch ironrdp-pdu — the parser is fine; the issue is purely which PDUs get handed to it.
Relationship to existing work
Happy to open a PR with the patch above if the maintainers agree with the shape — in particular whether answering the probe in CapabilitiesExchange (rather than skipping it as licensing does) is the behaviour you want.
ClientConnectoranswers Auto-Detect Requests only inside its ownConnectTimeAutoDetectionphase, which ends at the first PDU off the MCS message channel. A Windows RDS host we connect to keeps sendingSEC_AUTODETECT_REQprobes after that phase has closed. Every later phase hands whatever arrives straight to its own decoder, so the connection dies before the session opens.mstscconnects to the same host without complaint, and other RDP hosts work — they simply never start a licensing exchange.Environment
ironrdp-connector0.10.0,ironrdp-pdu0.9.0,ironrdp-session0.11.0,ironrdp-core0.2.1 (latest published on crates.io)mstscfineSequenceAPISymptom
The licensing exchange runs correctly right up to the last step —
ClientNewLicenseRequestsent,ServerPlatformChallengereceived,ClientPlatformChallengeResponsesent — and then the next PDU fails to decode.The
SERVER_NEW_LICENSEin that message is only the state's staticwith_contextlabel; it says nothing about which PDU actually arrived. It sent our first round of diagnosis down the wrong path entirely (we assumed a truncated licensing PDU).What actually arrives
Upstream prints no bytes with that error, so we had to patch the crate locally just to log them:
BasicSecurityHeader.flags00 100x1000— SEC_AUTODETECT_REQ, notSEC_LICENSE_PKT(0x0080)headerLength06headerTypeId00TYPE_ID_AUTODETECT_REQUESTsequenceNumber00 00requestType14 000x0014— RDP_RTT_REQUESTSo it is not a licensing PDU at all.
license_exchange.rsfeeds whatever arrives intoLicensePdu::decodewithout checking the security header, and an unrelated advisory PDU takes down the whole connection.Fixing that alone only moves the failure: with licensing completing, the same probe then reaches
decode_share_controlinconnection_activation.rs'sCapabilitiesExchange.Suggested shape of a fix
What worked for us, in a local fork of 0.10.0 (~97 changed lines across
license_exchange.rs,connection_activation.rsand onepub(crate)inconnection.rs):license_exchange.rs— each of the three receiving states (NewLicenseRequest,PlatformChallenge,UpgradeLicense) checksBasicSecurityHeader.flagsforSEC_LICENSE_PKTbefore decoding. A payload without it is logged at DEBUG and skipped, and the state machine stays in the same state to await the real licensing PDU.connection_activation.rs— inCapabilitiesExchange, decode theAutoDetectReqPduand answer it throughconnection::respond_to_connect_time_autodetect, then stay in the state still waiting for Server Demand Active. This is the same skip-and-stay shape the code already applies a few lines below forServerDeactivateAll.Staying put is the load-bearing part. An earlier attempt treated the undecodable packet as the end of licensing and advanced to
LicenseExchanged; the exchange was then left half-finished and the connection failed one step later instead. Skipping and waiting matches what MS-RDPBCGR implies: auto-detect is advisory, and a client that never answers an RTT probe is fine — the server proceeds regardless.Verified against the live host, in order: probe skipped mid-licensing,
ServerUpgradeLicense(2196 bytes) received,License verified with success, probe answered during Capabilities Exchange, eight Connection Finalization steps,Connected with success, frames rendering.We deliberately did not touch
ironrdp-pdu— the parser is fine; the issue is purely which PDUs get handed to it.Relationship to existing work
tolerate unknown security header flags in BasicSecurityHeader) is a different failure on the same server class. That one is a genuine licensing PDU with flags the header rejected; here the PDU is not a licensing PDU at all, so tolerating the flags would just hand auto-detect bytes to the licensing parser. Also worth noting that fix(ironrdp-pdu): tolerate unknown security header flags in BasicSecurityHeader #1458 is merged but not yet published —ironrdp-pduon crates.io is still 0.9.0 (2026-07-10).count the auto-detect header, and only answer connect-time) narrows behaviour inside the auto-detect phase, so that continuous-detection Starts do not open a connect-time window. It does not address PDUs arriving after the phase has closed, which is what this report is about. The two look adjacent but touch different code paths.Happy to open a PR with the patch above if the maintainers agree with the shape — in particular whether answering the probe in
CapabilitiesExchange(rather than skipping it as licensing does) is the behaviour you want.