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¶
- Connection finalization is implemented and verified against xrdp
(MS-RDPBCGR 2.2.1.14-2.2.1.18). The client’s Synchronize, Control (Cooperate
and Request Control) and Font List PDUs go out as one batch - the clause in
1.3.1.1 that permits this is load-bearing, because reading between sends
blocks on a reply the server cannot produce while it is waiting for the rest
of the batch - and the server’s Synchronize, Control (Cooperate and Granted
Control) and Font Map replies are all read and parsed.
pduSourceis the MCS user channel 1001, not the global channel these PDUs travel on. - Graphics updates are read, parsed and dispatched.
BitmapUpdate.cswalks an update body by itsupdateType,UncompressedBitmap.csandRleBitmap.csdecode the two bitmap forms,BulkDecompressor.csinflates the bulk-compressed RDP 6.0 stream, andScreenSurface.cscomposites rectangles, copies regions and blits cached bitmaps into an in-memory desktop thatToBmpwrites out. This is verified only as far as reading; see Known gaps for why no picture has come out of it yet. --dump-bmp <path>writes the composited surface to a BMP so a run can be inspected after the fact rather than only while it is running.--dump-graphics <path>appends every graphics update body to a file as a four-octet little-endian length followed by the body. It exists because--hexcannot serve the same purpose:Hex()breaks after sixteen octets, the logger keeps the first line, and a 639-octet Orders Update was therefore being logged as its first sixteen octets - precisely the part that was already understood. Walking a misparsing order stream against MS-RDPEGDI needs all of them.-
OrderDecoderTestspins the three order-stream defects found by tracing a live update: the order-class test, the alternate secondary length off-by-one, andnumberOrdersbeing read frompad2OctetsA’s offset. Each is asserted as “the decoder stopped exactly at the end of the order data”, because an order stream one octet out of step still names plausible orders for as long as the bytes ahead of it decode. -
The connection log can be copied. The header now carries
Copy logandClear. The copied text starts with the target, stage and account, because a pasted log that says which host and stage it came from is what makes a report actionable. The password is never in the log and never in the copy; the authenticator’s protocol hex is, so the button says so in its tooltip.Clearexisted in the view model but had no way to be pressed. SendClassicUserBlockexposes the classic-RDP user data block, off by default. See the security note below.- Parsing for the server’s replies.
AttachUserConfirmandChannelJoinConfirmare read and interpreted, and the client stops with the server’s reason when either refuses. - The Client Info PDU is sent after the channel joins. That is the missing
handshake step: MS-RDPBCGR 1.3.1.1 places the Secure Settings Exchange there,
and xrdp’s slow-path dispatch reaches “send demand active” only from its
SEC_INFO_PKTbranch.ClientInfo.csbuilds the PDU (TS_INFO_PACKET, 2.2.1.11) with the INFO flags xrdp requires on TLS connections and violates nothing: no share-control wrap, sent on the global channel, always empty password field unless the caller supplies one -InfoAutoLogonfollows the password. The password travels UTF-16 inside this one PDU, protected by TLS; it is never hex-logged. xrdp answers with its Licensing PDU, which the client skips by structure (SEC_LICENSE_PKTflag withflagsHi == 0, then thewMessageSizepreamble). - The mandatory capability exchange is implemented (MS-RDPBCGR 2.2.1.13).
Capability.csbuilds and parses the Share Control Header, the General Capability Set, the server’s Demand Active PDU and the client’s Confirm Active PDU, andMcs.ParseSendDataIndicationreads the MCS fragments a server PDU arrives in.HandshakeStagegainsCapabilityExchangedandActiveConfirmed. Verified against xrdp on 2026-10-17: after the Client Info PDU the server answered with a 20-octet Licensing PDU and a Demand Active naming 14 capability sets (Share, General, Bitmap, Font, Order, bitmap codecs, colour cache, Pointer, Input, bitmap-cache v3, Multitransport, large pointer, frame acknowledge, surface commands), and the Confirm Active the client sent back drew noTS_CONFIRM_ACTIVE_PDUerror from xrdp - the noise at the end of its log is the probe’s own disconnect. - The handshake is bounded.
RdpConnectionOptions.Timeout, 30 seconds by default,rdpprobe --timeout <seconds>. Every read in the sequence is a blocking socket read, so a server that stopped mid-sequence left the client waiting forever: it had to be killed rather than returning, and the server log was completely clean, so there was nothing to diagnose. A timeout turns the same situation intothe server stopped responding after 12s; the last thing that worked was the channel joins; it never sent the Demand Active PDU. --skip-channel-joinannouncesRNS_UD_CS_SUPPORT_SKIP_CHANNELJOINand sends no Channel Join Requests. Off by default, and not verified to help; see the note at the end of this release.
Fixed¶
- A secondary order was tested as a primary one.
controlFlags = 0x03sets bothTS_STANDARDandTS_SECONDARY, and testingTS_STANDARDfirst sent every secondary order down the primary path, which then read the secondary header’s own bytes as field flags and walked onward without ever failing. The class test now checks the secondary bit first (MS-RDPEGDI 2.2.2.1.1). Commit848e8edclaimed this fix and did not contain it - see the convention in AGENTS.md about a commit message describing the tree - so it is re-applied here and pinned by a test. - Alternate secondary orders skipped one octet too many. The lengths in MS-RDPEGDI 2.2.2.2.1.3 count the header octet, which the caller has already consumed by the time it asks; returning the specification’s figure walked the stream one octet forward per order and never produced an error of its own.
- Create Offscreen Bitmap read flags as two u16 values. It OR-ed a byte and
a byte into
flags, over-reading two octets of the two-octetflagsfield it was given, so the followingdeltaWidthwas taken from the next order’s header. - The T.125 SendDataRequest/SendDataIndication length was a little-endian u16
“including itself”. The real field is an [ITU-T X.691] length determinant,
in both directions: one octet at or below 127, otherwise two big-endian
octets with the
0x8xmarker and a value counting only the octets that follow. The wrong encoding desynchronised xrdp by one byte; the stray length byte joined the security header xrdp read next, the shifted flags carriedSEC_EXCHANGE_PKT, and the server answeredxrdp_sec_recv : error - unexpected length 264448-0x00040900, the little-endian value of four bytes starting one octet into the payload. - The Share Control Header packed the PDU type into the top of pduType.
MS-RDPBCGR 2.2.8.1.1.1.1 keeps the type in the four least significant bits
with PDUVersion (TS_PROTOCOL_VERSION = 0x1) in the twelve above them, so a
Demand Active is
11 00on the wire. xrdp maskspdu_code & 0xfwhen it reads and FreeRDP writestype | 0x10; the old reading turned every real Demand Active into “type 0” and the old builder spoke a format no peer reads. AtotalLengthof0x8000now names itself: it introduces a T.128 FlowPDU, not a slow-path PDU. - The client joined one channel where the server expected ten. A client sends one Channel Join Request per static virtual channel it offered, plus one for the MCS user channel and one for the global channel. This client sent only the global channel, so xrdp was left reading a join that would never arrive. Its whole error chain was the symptom:
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¶
- RESOLVED 2026-10-17: the missing step after the joins was the Client Info
PDU. Earlier rounds tried a larger join count and then
RNS_UD_CS_SUPPORT_SKIP_CHANNELJOIN; under both, xrdp stayed silent, because what xrdp waits for is not a join at all. Its slow-path dispatch wants the Client Info PDU (the Secure Settings Exchange, MS-RDPBCGR 1.3.1.1), andxrdp_sec_recvreaches “send demand active” only from theSEC_INFO_PKTbranch that consumes it. With the PDU sent on the MCS global channel the capability exchange completes end to end; see the added and fixed entries. Themstsctap capture stayed unreadable under TLS 1.3, so this answer came from xrdp’s own sources plus a live probe against them. - RESOLVED 2026-10-18: connection finalization is implemented. The Synchronize, Control and Font List send sequence and the Font Map reply, which the previous draft listed here as missing, are now sent as one batch and read back against xrdp.
- OPEN 2026-10-18: the drawing order stream from xrdp does not decode, so
there is still no desktop. The graphics phase is reached and every Orders
Update body is read and captured, but
OrderDecodercannot walk xrdp’s order stream. Against one 639-octet update the facts are measured rather than inferred:numberOrdersis 193; the first order is a Cache Bitmap - Revision 2 whoseorderLengthsays 17 octets while the next order starts 70 octets in; what follows is eleven tiles of exactly 51 octets, and 70 + 11 x 51 = 631, which is the whole order data - but that layout explains only 1 + 11 x 16 = 177 orders. Those four numbers do not reconcile, and MS-RDPEGDI 2.2.2.2.1.2.1.1 saysorderLength“MUST be 13 bytes less than the actual order length”, which 4 + 13 = 17 does not satisfy. The measurements, the capture procedure and the rule against guessingbitmapLength’s offset are in AGENTS.md. This is reported as a failure rather than as a partial success: a run ends with1 graphics update(s) read, 0 applied. - No live server has exercised the bitmap path. The captures taken so far hold only an Orders Update and a Synchronize Update, and no rendered surface has ever been written to disk from a real session, so the bitmap and bulk compression code is compiled and unit-level only.
- Input and pointer PDUs are unimplemented, so even a decoding desktop could not be used: nothing can be sent back.
- xrdp also sends capability sets the client does not interpret (bitmap codecs, surface commands, frame acknowledge among the 14); the client’s Confirm Active advertises only the General set, which xrdp accepts, but real downgrading behaviour is unverified.
TS_UD_CS_COREis 234 octets on the strength of the specification and FreeRDP, not a capture.mstscax.dllbuilds the client data blocks through a COM vtable one hop beyond its only caller. It is pinned by a test now, which pins the arithmetic and not a measurement.- The static channel list is a guess.
rdpdr,cliprdranddrdynvcexist inmstscax.dllas UTF-16 strings;rdpgfx,urbout,encomspandaudinare absent from that binary entirely. - NTLM end to end is still unverified, and needs a Windows host or a server that offers CredSSP.
Security¶
- The classic-RDP user data block is no longer sent by default. It puts a
password on the wire in UTF-16 in the clear, and MS-RDPBCGR does not define it
at all - xrdp answers
TS_UD_HEADER type 0xc00e is unknown (ignored), so the client was transmitting a credential the server was about to discard.SendClassicUserBlockmakes it opt-in, which is the point at which the README’s warning about cleartext credentials applies. TS_UD_CS_COREreported zero physical desktop dimensions, which is invalid rather than merely ignored: xrdp loggedPhysical desktop dimensions (0x0) are invalidand sized the session window to nothing. It now reports a 4:3 desktop in millimetres.
Changed¶
- The MCS global channel is joined as 1003, and the join is only reported complete once the server’s Channel Join Confirm has been read. Every channel is now joined and every Confirm checked, not just this one.
- The static channel list is now documented as unverified. Searching
mstscax.dllfindsrdpdr,cliprdranddrdynvcas UTF-16 strings, butrdpgfx,urbout,encomspandaudinappear nowhere in that binary in either encoding, so the other clients are registered from somewhere a string search does not reach. The list is a guess and now says so. - Authentication traces what it sends and receives, so a failure names the stage and the token type instead of surfacing as a flattened TLS error.
- The exchange now advances. The host accepts the NEGOTIATE and returns an NTLM CHALLENGE, which was never true before.
- Tests are pinned to captured bytes rather than to hand-typed hex: the CHALLENGE
fixture is written to
Fixtures/challenge.binfrom a live run.
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.
- MD4 was wrong in three separate ways, and a wrong NT hash makes every
password fail with
STATUS_LOGON_FAILURE, which is indistinguishable from a wrong password at the protocol level. It had a leftover register shuffle from a different formulation of the round step, four rounds of sixteen operations where RFC 1320 has three, and neither of the additive constants0x5A827999and0x6ED9EBA1. Each defect on its own still produced a plausible-looking 128-bit digest. - The NTLMv2 response now uses the server’s
MsvAvTimestampwhen the challenge carries one (MS-NLMP 3.2.2.2.1) and converts to a FILETIME otherwise, rather than writingDateTime.Ticks, which counts from year 1 rather than 1601. - The AUTHENTICATE carries what
NEGOTIATE_KEY_EXCHANGErequires (MS-NLMP 3.1.5.1.2, 3.4.4): a 16-byte random session key, its RC4-wrappedEncryptedRandomSessionKey, and a message integrity code over all three messages. Rc4is implemented directly, since .NET no longer ships one and NTLM uses it as a raw key schedule with no derivation.- The CredSSP version 6 channel binding (MS-CSSP 3.1.5 step 3) is computed and
sent with the AUTHENTICATE: a 32-byte client nonce, a SHA-256 over the magic
string, the nonce and the server’s
SubjectPublicKey, sealed with the client-to-server sealing keyMD5(SessionKey || magic).
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¶
Spnego.cs. CredSSP does not wrap NTLM in SPNEGO, so the encoder had no callers and was worse than unused: its doc comment described exactly the layering the protocol does not use, which invited a future reader to “correct” the authenticator by using it. sspi’s documentation onClientModeis explicit - “mstsc and other RDP clients send plain NTLM messages (without SPNEGO wrappers) inside CredSSP when Kerberos is not possible” - and its referenceTSRequestvector begins with a bareNTLMSSPsignature insidenegoToken.
Tests¶
- Two regression tests pin the fixes above. One builds an AUTHENTICATE whose
user name pushes the following offsets past 65535 and asserts the
BufferOffsetoctets aree0 02 01 00, which the old 16-bit write turned into 736. The other builds a 300-byte CredSSP token, which forces the two-octet DER length form82 01 45at the outermost level and reads it back. Both were confirmed to fail against the unfixed encoders.
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:
- The RDP negotiation is byte-identical to what
mstsc.exesends. - The TLS handshake completes on both TLS 1.2 and TLS 1.3.
- The host waits for the client’s token and then resets the connection.
- Schannel reports that reset as “decryption operation failed”, which is misleading — there is no decryption failure, the peer simply went away.
- The generated token is internally consistent DER: the GSS-API wrapper, the
NegTokenInitand 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:
credssp.dllis the CredSSP security provider (53 KB, “Microsoft CredSSP Security Provider”).SpInitializeSecurityContextWin it assembles the outgoing message, and its string table namesNegotiate, the SSPI package CredSSP negotiates through. That is where theTSRequestenvelope is built.msv1_0.dllvalidates NTLM messages and rejects anything that fails with0x80090308; it also caps a message at0xB48bytes.secur32.dllcontains no NTLM and no CredSSP, only 123 strings. An earlier search returning nothing there was correct, not a tool failure.- The host logs the real reason. Event 139 in
Microsoft-Windows-RemoteDesktopServices-RdpCoreTS/OperationalreadsThe server security layer detected an error (0x80090308) in the protocol stream, which names the rejection that Schannel was hiding behind a TLS error. This is the diagnostic to read before forming any theory.
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¶
- TLS channel (
Security/TlsChannel.cs): the handshake MS-RDPEA 3.1.5.1 places on the same TCP connection, withTERMSRV/<host>SNI. The self-signed host certificate is accepted and then reported, because RDP does not chain-validate and the default policy would reject every real host. - Security mode selection (
Security/RdpSecurity.cs): maps the negotiatedSelectedProtocolto TLS, CredSSP or none, and refuses an unknown protocol rather than downgrading. - NTLM (
Security/Ntlm.cs,Security/Md4.cs,Security/NtlmAuthenticator.cs): NEGOTIATE, CHALLENGE and AUTHENTICATE messages with an NTLMv2 response. MD4 is implemented because .NET does not ship it and NTLM specifies it. - SPNEGO (
Security/Spnego.cs): BER encoding and decoding for the GSS-APINegTokenInit/NegTokenTargwrappers, plus SPNEGO error decoding. - CredSSP renegotiation (
RdpConnection.RenegotiateInsideCredSspAsync): the secondRDP_NEG_REQthat MS-RDPEA 3.2.1.5.1.5 requires inside the authenticated channel. rdpprobeflags--tls12,--tls-onlyand--credssp-onlyto pin the security mode while diagnosing a host.- Wrapped TLS and SPNEGO failures now report their root cause instead of the outermost message.
Verified¶
Against a Windows Server 2025 host (fDenyTSConnections=0, port 3389):
- RDP negotiation selects
PROTOCOL_HYBRID_EXand the client reports CredSSP. - The TLS handshake completes on both TLS 1.3 and TLS 1.2, presenting the server’s self-signed certificate.
- Requesting
PROTOCOL_SSLalone is refused withRDP_NEG_FAILUREcode 5 (SSL_REQUIRED_BY_SERVER), so the host mandates NLA.
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:
- CredSSP RSA key exchange and session key derivation (MS-RDPBCGR 5.3.2)
- Encryption of MCS traffic (RC4 / AES)
- Capability exchange (2.2.7), including the
Demand Active/Confirm Activepairs that begin the connection - Early User Authorization (2.2.10.2) and the logon-information PDUs
- The graphics pipeline and input needed to draw a framebuffer at all
Fixed¶
- The SPNEGO mechanism OID was double-wrapped and declared a five-octet length for six octets of content, which made every token malformed.
thisMechin the GSS-API wrapper is tagged APPLICATION (0x60), not CONTEXT-specific; the two are not interchangeable.
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¶
- X.224 / T.125 connection request (MS-RDPBCGR 2.2.1.1) with TPKT framing.
- RDP negotiation exchange (2.2.1.1.1, 2.2.1.2.1):
RDP_NEG_REQ/RDP_NEG_RSP, optional correlation block withCORRELATION_INFO_PRESENT. - MCS Connect Initial / Connect Response (2.2.1.3.4, 2.2.1.3.5) including the GCC Conference Create Request.
- Client data blocks (2.2.1.3.4.1–.5):
TS_UD_CS_CORE,TS_UD_CS_SECURITY,TS_UD_CS_NET,TS_UD_CS_CLUSTER,TS_UD_CS_USER. - MCS domain and channel setup (2.2.1.3.6–.8): Erect Domain Request, Attach User Request, Channel Join Request, Send Data Request framing.
- T.125 Disconnect Request for orderly close.
- WinUI 3 desktop app with a connection form and a timestamped handshake log.
rdpprobeconsole harness for exercising the handshake without the GUI; reads the password fromRDP_PASSWORDand never echoes it.- Protocol-layer unit tests pinned to bytes captured from
mstsc.exe. - Documentation site, logo, and application icon set.
Platform¶
- Windows on x64, ARM64 and x86.
- Windows only: WinUI 3 has no macOS or Linux implementation. The protocol core targets plain .NET and carries no UI dependency, so it can be reused from a cross-platform front end if one is written.
Not implemented¶
Stated plainly so nobody mistakes this for a working desktop viewer:
- RSA key exchange and RC4/AES session keys (2.2.1.4.3, 5.3.2)
- CredSSP / NLA — TLS + SPNEGO + NTLM (MS-RDPEA)
- Capability set exchange (2.2.7)
- Bitmap, pointer and order graphics pipeline (2.2.9)
- Keyboard and pointer input (2.2.8)
- Virtual channel data: clipboard, drive, audio (2.2.6)
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.
- TPKT length is big-endian while the rest of the connection sequence is little-endian.
- The X.224 length indicator precedes the TPDU code; TPKT orders its length last.
TS_UD_CS_*headers are 16-bit little-endian values, not single bytes.TS_UD_CS_*TLV headers are 6 bytes: type(2), flags(2), length(2).- MCS integers are plain fixed-width values with no T.125 selector bits.
- The GCC conference name length is counted in UTF-16 code units, not bytes.