RdpClient

Changelog

All notable changes to this project are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

The CredSSP envelope, which was missing entirely, plus the MCS client data and channel-setup encodings, which xrdp rejected outright. The connection now runs end to end against xrdp: the Client Info PDU - the Secure Settings Exchange that MS-RDPBCGR 1.3.1.1 places between the joins and the Capabilities Exchange - followed by the capability exchange and the connection finalization batch, with every server reply read rather than assumed. The graphics stream is then read and dispatched instead of ignored, and stops short of a picture: the drawing orders xrdp sends do not decode, which is recorded under Known gaps with the measurements that pin it down.

Added

Fixed

text [ERROR] Processing [ITU-T T.25] ChannelJoinRequest failed [ERROR] [MCS Connection Sequence (TLS)] receive channel join request failed [ERROR] xrdp_sec_incoming: xrdp_mcs_incoming failed [ERROR] xrdp_rdp_incoming: xrdp_sec_incoming failed

All four are gone. Measured from xrdp’s handle_channel_join_requests, which expects channel_list->count + 2 joins and sets self->chanid = 1001; static channel ids are positional at 1004 + N, which FreeRDP and xrdp both derive from the global channel id. xrdp’s Expected N channel join PDUs but got N is only a warning, so its absence is what shows the count matched. The documentation claimed this state was expected; it was not, and it said so. - A Channel Join Confirm for the wrong channel was reported as a success. The result octet alone was taken as proof and whatever channel id followed was echoed back. The Confirm now has to name the channel that was requested, which is the same “reported success it never verified” defect this connection sequence was rewritten to remove. - The Channel Join Confirm’s optional channel id was read unconditionally. Opcode bit 0x02 marks it present, and the bit means something different in each Confirm: xrdp sets it for the initiator in the Attach User Confirm and for the channel id in the Channel Join Confirm, having written initiator and requested unconditionally beside it. - The MCS PDUs carried the wrong MS-RDPBCGR section numbers. 2.2.1.3.x are the client data blocks; the domain PDUs are 2.2.1.5 (Erect Domain Request), 2.2.1.6 and 2.2.1.7 (Attach User Request and Confirm) and 2.2.1.8 and 2.2.1.9 (Channel Join Request and Confirm), with 2.2.1.3 the Connect Initial and 2.2.1.4 the Connect Response. The prose had been corrected and the source files had not. Verified against the specification’s own toc.json. - ClientPdu’s class documentation still described TS_UD_HEADER as type/flags/length. There is no flags field, and that wrong description is what the header’s six-octet defect came from in the first place. - TS_UD_CS_NET accepted more than 31 static channels. MS-RDPBCGR 2.2.1.3.4 caps channelCount at 31, and a server is entitled to read only 31 definitions. - The client reported success it had not verified. This was the most serious defect in the project. The Erect Domain, Attach User and Channel Join requests were written and ConnectAsync returned Complete without reading a single reply, so the CLI printed RESULT: MCS channel joined. against a server that had rejected the first PDU. xrdp’s log at the time reads:

text [ERROR] Parsing [MS-RDPBCGR] GCC Conference Create Request client data field Not enough bytes in the stream: expected 32764, remaining 308 [ERROR] Processing [ITU-T T.125] ErectDomainRequest failed [ERROR] [MCS Connection Sequence] receive erect domain request failed

Every stage now waits for the reply that proves it. The Attach User Confirm is not optional bookkeeping: it is the only place the server states the user id it assigned, and the Channel Join Request has to carry that id. - The TS_UD_HEADER was 6 bytes instead of 4. MS-RDPBCGR 2.2.1.3.1 defines it as type(2) and length(2) with no flags field, and every client data block carries one. Writing an extra flags octet shifted every payload field by two. - TS_UD_CS_CORE was written as though it had no header at all. It has one like every other block, which is what left it 36 octets short of the 234 the specification lays out. The missing octets were the header, the 64-byte clientDigProductId (written as a 4-byte field), and the trailing orientation and scale factors. Two fields that do not exist in the structure - clientColorDepth and clientPostBeta2ColorDepth - were written in their place. CORE is now the 234-octet block a real client sends, pinned against FreeRDP’s gcc_write_user_data_header(s, CS_CORE, 234). - Every declared TS_UD_HEADER.length covered only a fixed portion. The length is the size of the whole block including the header, and a reader walks it to find the next block, so declaring less than is sent desynchronises everything after it with no diagnostic. NET declared 8 while sending 114. Lengths are now patched after the payload is written, so the two cannot drift apart again. - CHANNEL_DEF was written with a spurious length prefix. It is a fixed 12-octet structure - an 8-octet name and a 4-octet options mask - with no length octet, so every channel entry was 13 bytes and NET was 8 bytes longer than the count it declared. It is now 12 * channelCount + 8. - The MCS domain PDUs were sent bare. Erect Domain, Attach User and Channel Join are T.125 DomainMCSPDUs and each travels in TPKT + X.224 Data TPDU, as the annotated PDUs in MS-RDPBCGR 4.1.5 and 4.1.6 show. Sent bare, xrdp could not find a TPKT header at all. - The opcode octet was the PDU code rather than the shifted CHOICE index. It is (choice << 2) | options, with the choice in the top six bits. Erect Domain is choice 1 and so is 0x04, not 0x05. The old values came from a different numbering in MS-RDPBCGR 3.3.5.2 and landed on unrelated PDUs. - Erect Domain wrote its two fields as raw values rather than PER integers. A zero is the two octets 01 00, because a PER integer carries its own length determinant. - The MCS global channel was 1007. It is 1003; 1007 is the H.225.0 RTP channel number, and a client that joins it joins a channel no server created. - The Connect Response walk skipped the ignored connectPDU length as a single octet. It must be ignored as a value but still occupies one or two octets, so it is now stepped over as a variable-width PER field. - RDP_CORRELATION_INFO was shorter than the length it declared. The block declares 36 octets but only the 24 named fields were sent. Windows tolerates that; xrdp walks 1 + 2 + 16 + 16 octets after the type and aborts the connection before negotiation. The block is now padded, and Negotiation.CorrelationInfoLength records the value. A capture of mstsc.exe shows it never sends this block, which is why the defect survived: nothing in the Windows path exercises the encoding. - The NTLM field-descriptor offset was written as 16 bits. Each descriptor is { Length(2), MaxLen(2), BufferOffset(4) }, so the BufferOffset occupies four octets, exactly as ReadField reads it. The writer emitted only two, and a BufferOffset above 65535 would have been truncated without the reader ever noticing. - Slice could overflow while checking a bounds offset. The check added offset + length in 32-bit arithmetic, which wraps for a large offset and can pass a slice the message cannot contain. The bounds are now compared without the sum. - Removed the unused Ntlm.Append helper; it had no callers. - DER length and INTEGER octets were collected back-to-front. The CredSSP encoder built each octet list with Insert(0, …), which shifts the whole list on every insert. It now collects least-significant first and reverses once. The bytes produced are unchanged. - The CredSSP TSRequest envelope was absent. The client wrote a bare token straight onto the TLS channel. CredSSP (MS-CSSP 2.2.1) requires every message after the handshake to be an ASN.1 TSRequest carrying the token in negoTokens. CredSsp.cs encodes and decodes the structure. - The version field did not wrap an INTEGER. TSRequest.version is declared [0] INTEGER, which encodes as a0 03 02 01 06. The encoder wrote a0 01 06, declaring a single content byte holding a bare 0x06, which is not a valid INTEGER. The request was therefore not valid DER, which is what SEC_E_INVALID_TOKEN was reporting. This was the actual blocker. - NTLM field descriptors were read from the wrong offsets. The descriptor is { Length(2), MaxLen(2), BufferOffset(4) } with a 32-bit offset. Reading Length from [4..6] and Offset from [6..8] takes the two halves of the offset, so the CHALLENGE’s TargetInfo came back as the first 82 bytes of the message - the NTLMSSP signature included - and the AUTHENTICATE embedded a whole NTLM header. - The NEGOTIATE flag set now matches a known-good client: NEGOTIATE_SIGN, NEGOTIATE_SEAL and NEGOTIATE_LM_KEY are offered, and NEGOTIATE_OEM_WORKSTATION_SUPPLIED is set only when the workstation name is actually present in the payload. - NEGOTIATE_TARGET_INFO (0x00800000) was being set by the client. That flag announces that a message carries TargetInfo and is server-side. - NEGOTIATE_VERSION was set while the 8-octet Version field at offset 32 was left all zero, which is not a valid version. - The GCC connectPDU length added a flat 14 octets of overhead. It counts the client data, twelve octets of fixed framing, and the client-data length field itself, and that field is one octet while the client data stays below 0x80. The declared length was therefore one too large for any client data under 128 bytes, which does not occur in a real connection but is wrong. It is now clientData.Length + 12 + PerLengthOctets(clientData.Length). Measured in mstscax.dll, which builds it as (0xe - (uint)(uVar3 < 0x80)) + uVar3 - +14 at or above 0x80 and +13 below - which agrees with that formula at every input, including the narrow band where the total crosses 0x80 while the client data does not.

Known gaps

Security

Changed

Verified

Against xrdp 0.10.1 in WSL (Debian 13, port 3390, --tls-only):

negotiated version 0x00000001, SelectedProtocol 0x00000001
Tls13, subject 'CN=runnervm0xl3a.localdomain' (self-signed certificate accepted)
sent MCS Connect Initial (561 bytes)
MCS accepted (conference 0), UserID 31219, ServerNodeID 31219
sent Erect Domain Request (12 bytes)
sent Attach User Request (8 bytes)
attached as MCS user 1012
joined MCS channel 1003

xrdp’s log for the same run contains no parse error and no unknown block; the first MCS-related line is the Channel Join reading the client’s next request and hitting the client’s own disconnect, which is the boundary before the capability exchange exists.

Known issue

NTLM authentication against a Windows host reaches STATUS_LOGON_FAILURE, and NTLM is not exercised against xrdp, which does not offer CredSSP here.

Verification note

Authentication could not be confirmed end to end. The only reachable host is an ngrok tunnel, and mstsc.exe itself cannot complete a connection through it: it holds an established TCP connection and sits at “Configuring remote session…” indefinitely. A local Windows host is available and is used as the test target, but its RDP account has a different password, so it can only confirm rejection, never acceptance.

Settled

NtlmFlags values were suspected and cleared: MS-NLMP 2.2.2.5 labels the bits (W=56-bit at 31, H=NTLM at 9, L=OEM workstation at 13) and every declared value matches.

Removed

Tests

Investigation

A TLS-terminating proxy (credssp-tap.mjs) was built to expose the CredSSP exchange in plaintext, because the SPNEGO tokens live inside TLS and no external tap can see them. It forwards the cleartext X.224 negotiation, then upgrades both sides to TLS and logs the decrypted traffic.

What it established, in order:

  1. The RDP negotiation is byte-identical to what mstsc.exe sends.
  2. The TLS handshake completes on both TLS 1.2 and TLS 1.3.
  3. The host waits for the client’s token and then resets the connection.
  4. Schannel reports that reset as “decryption operation failed”, which is misleading — there is no decryption failure, the peer simply went away.
  5. The generated token is internally consistent DER: the GSS-API wrapper, the NegTokenInit and the NTLM message each declare lengths matching their content, with the correct NTLM OID and a well-formed NEGOTIATE message.

Six hypotheses were tested and eliminated in the process: TPKT endianness, X.224 field order, the SPNEGO OID encoding, GSS-API tagging, the TLS version, and the NTLM field offsets. Each was a real defect, and each was fixed, but none was the cause.

The cause was found by reading the right binary and then asking the server directly, rather than by a seventh guess about the token:

Security

The interception certificate and its CA were installed into CurrentUser\Root during this investigation and have been removed. All six certificates added (CurrentUser\Root and CurrentUser\My) are deleted and the trust stores are back to their prior state.

[1.1.0] - 2026-10-16

External Security Protocol work. TLS now completes against a real RDP host; the NTLM exchange that CredSSP needs is implemented but does not yet complete.

Added

Verified

Against a Windows Server 2025 host (fDenyTSConnections=0, port 3389):

Not working

The SPNEGO/NTLM exchange does not complete. The first token write is followed by a TLS decryption failure, which indicates the host answered with an alert rather than a challenge. The cause is not yet identified, so this is reported as a failure rather than papered over.

Not implemented

Unchanged from 1.0.0, and all still ahead of the login screen:

Fixed

1.0.0 - 2026-10-16

First release. The RDP connection sequence, implemented from Microsoft’s public Open Specifications, verified byte-for-byte against a live Windows RDP server.

Added

Platform

Not implemented

Stated plainly so nobody mistakes this for a working desktop viewer:

Security

Credentials are transmitted as UTF-16 in TS_UD_CS_USER over the classic RDP handshake, in clear. CredSSP is not implemented, so use only against hosts you control or over a trusted, encrypted transport.

Fixed during development

These were found by comparing emitted bytes against a recorded mstsc.exe exchange; each produced a malformed PDU that a server answered with an immediate reset.