RdpClient

Protocol notes

Field-by-field notes for every PDU this project emits, with the governing section of MS-RDPBCGR (MS-RDPBCGR). Values are transcribed from the specification rather than from captured traffic, so each can be checked against the published tables.

Byte order

RDP is little-endian with exactly one exception:

Region Endianness
TPKT header length (RFC 1006) Big-endian
Everything inside TPKT Little-endian

Getting the TPKT length wrong looks like an instant connection reset, because MS-RDPBCGR 3.3.5.3.1 requires the server to verify that the embedded length fields agree with the received data and drop the connection when they do not.

1. X.224 Connection Request (2.2.1.1)

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  Version (3)  |  Reserved (0) |         Length (BE)          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  Length ind.  |   Code (0xE0) |     Destination reference     |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|    Source reference          | Class (0)  |    RDP_NEG_REQ     |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          RequestedProtocols (4) ...        | (optional) rdpCorr|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Two ordering rules are easy to get backwards:

The server sends Length indicator = 0x0E regardless of what follows it, so a client must not use it to locate the RDP_NEG_RSP. Skip the fixed header: after the code, destination reference (2), source reference (2) and class (1).

A minimal request is exactly 19 bytes:

03 00 00 13 | 0e e0 00 00 00 00 00 | 01 00 08 00 | 0b 00 00 00
^^^^^^^^^  ^^^^^^^^^^^^^^^^^^^^  ^^^^^^^^^^^^  ^^^^^^^^^^^^
TPKT        X.224 class 0        RDP_NEG_REQ    PROTOCOL_SSL |
                                               HYBRID       |
                                               HYBRID_EX

0x0B is what mstsc.exe advertises; a client offering only 0x03 is narrower but still interoperable.

2. RDP Negotiation Request (2.2.1.1.1)

Field Size Value
type 1 0x01
flags 1 CORRELATION_INFO_PRESENT (0x08) when the correlation block follows
length 2 0x0008
requestedProtocols 4 Protocol bitmask

Appending the optional correlation block makes the PDU 55 bytes and requires raising CORRELATION_INFO_PRESENT.

Correlation block (2.2.1.1.2)

Field Size Value
type 1 0x06 TYPE_RDP_CORRELATION_INFO
flags 1 0x00
length 2 0x0024 — 36, larger than the fields it names
correlationInfo 16 client-chosen vector
type 2 0x0001 RPC_CORRELATION_VECTOR_INFO1
Reserved 2 0x0000

The named fields add up to 24 octets, but the structure declares 36, so the sent bytes must be padded to cover the length the client writes itself. Sending only the 24 named fields was accepted by Windows and rejected by xrdp, which walks 1 + 2 + 16 + 16 octets after the type and aborts the connection:

Parsing [MS-RDPBCGR] RDP_NEG_CORRELATION_INFO Not enough bytes in the stream:
expected 35, remaining 23

With the padding, xrdp answers with a Connection Confirm and selects PROTOCOL_SSL instead of dropping the connection.

Note that mstsc.exe never sends this block at all — a capture of it through tools/rdp-tap.mjs shows the 19-byte request — and neither does FreeRDP, so a Windows host does not exercise the encoding either way.

SelectedProtocol

Bit Protocol Meaning
0x01 PROTOCOL_SSL TLS only, then MCS
0x02 PROTOCOL_HYBRID CredSSP
0x04 PROTOCOL_RDSTLS Reserved for RDSTLS
0x08 PROTOCOL_HYBRID_EX CredSSP, extended
0x10 PROTOCOL_RDSAAD CredSSP, Azure AD

Anything other than PROTOCOL_SSL means a CredSSP handshake runs before MCS. A client that does not implement CredSSP must stop here rather than send an MCS Connect Initial, which the server would treat as a protocol violation. That is what this project does.

3. MCS Connect Initial (2.2.1.3.4)

Carried in the same X.224 envelope as the negotiation request, but the payload is ASN.1, not a flat TLV. It is the T.125 structure:

Connect-Initial ::= [APPLICATION 101] IMPLICIT SEQUENCE
{
    callingDomainSelector   OCTET STRING  -- 0x01
    calledDomainSelector    OCTET STRING  -- 0x01
    upwardFlag              BOOLEAN       -- TRUE, the client is joining
    targetParameters        DomainParameters
    minimumParameters       DomainParameters
    maximumParameters       DomainParameters
    userData                OCTET STRING  -- the GCC Conference Create Request
}

DomainParameters is a SEQUENCE of eight INTEGERs: maxChannelIds, maxUserIds, maxTokenIds, numPriorities, minThroughput, maxHeight, maxMCSPDUsize, protocolVersion.

The tag is split

101 does not fit the five tag bits of one octet, so it is written as all-ones followed by the number: 7f 65. A server that rejects the structure names the identifier it wanted, which is what identified this defect rather than leaving it to guesswork:

[ERROR] Parsed [ITU-T X.690] Identifier: expected 0x7f65, actual 0x0300

0x0300 was the TLV this client used to write: tag 0x03, length 0x00.

Connect Response is the mirror image

[APPLICATION 102], so 7f 66, wrapping result ENUMERATED, calledConnectId INTEGER, domainParameters and userData.

The user data is PER-encoded

The GCC Conference Create Request inside userData is T.125’s ALIGNED variant of BASIC-PER, which is neither BER nor a plain TLV:

00                          From Key: the OBJECT_IDENTIFIER alternative
05 00 14 7C 00 01           T.124 (02/98) object identifier, six octets
<PER length>                connectPDU length - client data + 14
00                          ConnectGCCPDU: conferenceCreateRequest
08                          selection: userData present
<PER numeric string>        the conference name, digits only
00 01                       one set of user data
C0                          value present, h221NonStandard selected
00 "Duca"                   the client-to-server H.221 key
<PER length> <client data>  the TS_UD_CS_* blocks

Two details in that differ from everything else in RDP:

The response side mirrors it, and carries one more trap: the connectPDU length in a Conference Create Response must be ignored. Servers do not agree on what it counts, so a client that trusts it desynchronises.

4. Client data blocks (2.2.1.3.2–.5)

Every block begins with the same 4-byte TS_UD_HEADER (2.2.1.3.1):

type (2) | length (2)

There is no flags field, and TS_UD_CS_CORE carries this header too - it is not the one block that lacks one. Three properties of the header cause disagreement between implementations:

Property Rule
Endianness plain little-endian 16-bit count
Scope the whole block, header included
PER marker none. No 0x8000 marker appears here

The discriminator is 16-bit. TS_UD_CS_NET is 0xC003, which appears on the wire as 03 C0; writing it as a single byte leaves it as 03 and shifts every following field by one.

Sizes, from MS-RDPBCGR:

Block Tag Total
TS_UD_CS_CORE 0xC001 234
TS_UD_CS_SEC 0xC002 12
TS_UD_CS_NET 0xC003 12 * channelCount + 8
TS_UD_CS_CLUSTER 0xC004 12

CHANNEL_DEF (2.2.1.3.4.1) is exactly 12 octets - an 8-octet name and a 4-octet options mask - with no length prefix of its own.

Three fixed-width fields inside CORE are NUL-padded rather than length-prefixed:

Field Size
clientName 32 bytes (16 UTF-16 units)
imeFileName 64 bytes (32 UTF-16 units)
clientDigProductId 64 bytes (32 UTF-16 units)

There is no TS_UD_CS_USER in MS-RDPBCGR. It is a classic-RDP structure that predates the specification; xrdp answers TS_UD_HEADER type 0xc00e is unknown (ignored). Its field lengths are byte counts, so a 5-character user name is declared as 10 - which is the one property of it worth keeping.

Why a wrong length is so expensive

A reader walks the declared length to find the next block. Declaring less than is sent does not produce a parse error, it silently desynchronises everything after it. xrdp makes this explicit by logging the expected and remaining counts:

[ERROR] Parsing [MS-RDPBCGR] GCC Conference Create Request client data field
        Not enough bytes in the stream: expected 32764, remaining 308

5. MCS channel setup (2.2.1.5, 2.2.1.6, 2.2.1.8)

Each of these is a T.125 DomainMCSPDU, and each is framed the same way:

TPKT header (4) | X.224 Data TPDU (3) | opcode (1) | fields

The annotated PDUs in MS-RDPBCGR give the bytes exactly:

Erect Domain      03 00 00 0c | 02 f0 80 | 04 01 00 01 00
Attach User       03 00 00 08 | 02 f0 80 | 28
Channel Join      03 00 00 0c | 02 f0 80 | 38 <initiator:2> <channelId:2>

Two things about the opcode octet are easy to get wrong:

The initiator of a Channel Join Request is the user id from the Attach User Confirm, written as a PER integer relative to 1001. A conventional 1001 is not a substitute: the server rejects a join whose initiator is not the user it attached. This is the one field in the exchange the client cannot know in advance, so the Confirm must be read before the join can be built.

The global channel is 1003, not 1007 - the latter is the H.225.0 RTP channel number.

Payload framing for later data uses MCS Send Data Request, chunked at 1598 bytes, inside the same TPKT + X.224 envelope.

Reference implementation

The encoders are pinned by unit tests to bytes recorded from mstsc.exe through a logging TCP proxy. See tests/RdpClient.Protocol.Tests/ProtocolTests.cs.