feat(tls): add support for RDP negotiation before TLS handshake

This commit is contained in:
2026-09-30 16:54:53 +08:00
parent ae7135ab64
commit c879e10ac1
5 changed files with 377 additions and 16 deletions
+29 -16
View File
@@ -51,9 +51,10 @@ type tlsHello struct {
msg utils.ByteBuffer // handshake layer bytes collected so far
lsm *utils.LinearStateMachine
msgType byte
minLen int // smallest valid message body
parse func(*utils.ByteBuffer) analyzer.PropMap // parses the message body
msgType byte
x224Code byte // X.224 connection PDU that may precede the hello on RDP
minLen int // smallest valid message body
parse func(*utils.ByteBuffer) analyzer.PropMap // parses the message body
recLeft int // body bytes of the current record not consumed yet
msgLen int // message body length, -1 until the handshake header is read
@@ -63,6 +64,9 @@ type tlsHello struct {
m analyzer.PropMap
updated bool
done bool
rdp analyzer.PropMap // RDP negotiation, nil if there was none
rdpUpdated bool
}
func newTLSStream(logger analyzer.Logger) *tlsStream {
@@ -70,18 +74,18 @@ func newTLSStream(logger analyzer.Logger) *tlsStream {
// minimal ClientHello body: protocol version (2 bytes) + random (32 bytes) +
// + session ID (1 byte) + cipher suites (4 bytes) +
// + compression methods (2 bytes) + no extensions
s.req.init(internal.TypeClientHello, 41, internal.ParseTLSClientHelloMsgData)
s.req.init(internal.TypeClientHello, x224CodeConnectionRequest, 41, internal.ParseTLSClientHelloMsgData)
// minimal ServerHello body: server version (2 bytes) + random (32 bytes) +
// + session ID (>=1 byte) + cipher suite (2 bytes) +
// + compression method (1 byte) + no extensions
s.resp.init(internal.TypeServerHello, 38, internal.ParseTLSServerHelloMsgData)
s.resp.init(internal.TypeServerHello, x224CodeConnectionConfirm, 38, internal.ParseTLSServerHelloMsgData)
return s
}
func (h *tlsHello) init(msgType byte, minLen int, parse func(*utils.ByteBuffer) analyzer.PropMap) {
h.msgType, h.minLen, h.parse = msgType, minLen, parse
func (h *tlsHello) init(msgType, x224Code byte, minLen int, parse func(*utils.ByteBuffer) analyzer.PropMap) {
h.msgType, h.x224Code, h.minLen, h.parse = msgType, x224Code, minLen, parse
h.msgLen = -1
h.lsm = utils.NewLinearStateMachine(h.readMessage, h.parseMessage)
h.lsm = utils.NewLinearStateMachine(h.readPrelude, h.readMessage, h.parseMessage)
}
func (s *tlsStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyzer.PropUpdate, done bool) {
@@ -114,17 +118,26 @@ func (s *tlsStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyz
// update returns the properties to publish for this direction, or nil if
// there is nothing to publish. The parsed message goes under key, the record
// count under key + "_records". The record count is kept out of the message
// map so that a non-nil message map still means a fully parsed hello.
// map so that a non-nil message map still means a fully parsed hello. An RDP
// negotiation goes under "rdp_" + key as soon as it is parsed, whatever
// publish says.
func (h *tlsHello) update(key string, publish bool) analyzer.PropMap {
if !publish || h.records == 0 {
m := analyzer.PropMap{}
if h.rdpUpdated {
m["rdp_"+key] = h.rdp
h.rdpUpdated = false
}
if publish && h.records > 0 {
m[key+"_records"] = h.records
if h.updated {
m[key] = h.m
h.updated = false
}
h.reported = h.records
}
if len(m) == 0 {
return nil
}
m := analyzer.PropMap{key + "_records": h.records}
if h.updated {
m[key] = h.m
h.updated = false
}
h.reported = h.records
return m
}
+123
View File
@@ -0,0 +1,123 @@
package tcp
import (
"bytes"
"encoding/binary"
"github.com/apernet/OpenGFW/analyzer"
"github.com/apernet/OpenGFW/analyzer/internal"
"github.com/apernet/OpenGFW/analyzer/utils"
)
// RDP does not start TLS right away: the client first sends an X.224
// Connection Request and the server answers with a Connection Confirm, each in
// a TPKT packet, to agree on a security protocol ([MS-RDPBCGR] 1.3.1.1). Only
// then does the TLS handshake begin, on the same connection.
//
// The tls analyzer used to give up on the first byte of such a stream, which
// left RDP invisible to TLS rules, and with it anything that puts a fake RDP
// negotiation in front of its TLS. So the negotiation is parsed as an optional
// prelude of the hello, and published as rdp_req / rdp_resp.
const (
tpktVersion = 0x03
tpktHeaderSize = 4 // version (1 byte) + reserved (1 byte) + length (2 bytes)
// x224FixedSize is length indicator (1 byte) + code (1 byte) +
// DST-REF (2 bytes) + SRC-REF (2 bytes) + class option (1 byte).
x224FixedSize = 7
x224CodeConnectionRequest = 0xe0
x224CodeConnectionConfirm = 0xd0
rdpNegReq = 0x01
rdpNegRsp = 0x02
rdpNegFailure = 0x03
rdpNegSize = 8
)
var rdpCookiePrefix = []byte("Cookie: ")
// readPrelude consumes the X.224 connection PDU that precedes the hello on an
// RDP connection, if there is one.
func (h *tlsHello) readPrelude() utils.LSMAction {
b, ok := h.rec.GetByte(false)
if !ok {
return utils.LSMActionPause
}
if b == internal.RecordTypeHandshake {
return utils.LSMActionNext
}
if b != tpktVersion {
return utils.LSMActionCancel
}
header, ok := h.rec.Get(tpktHeaderSize, false)
if !ok {
return utils.LSMActionPause
}
n := int(binary.BigEndian.Uint16(header[2:4]))
if header[1] != 0 || n < tpktHeaderSize+x224FixedSize {
return utils.LSMActionCancel
}
pdu, ok := h.rec.Get(n, true)
if !ok {
return utils.LSMActionPause
}
m := parseX224Connection(pdu[tpktHeaderSize:], h.x224Code)
if m == nil {
return utils.LSMActionCancel
}
h.rdp = m
h.rdpUpdated = true
return utils.LSMActionNext
}
// parseX224Connection parses an X.224 Connection Request or Confirm TPDU
// carrying the RDP negotiation, or returns nil if tpdu is not one.
func parseX224Connection(tpdu []byte, code byte) analyzer.PropMap {
// The length indicator does not count itself. The low nibble of the code
// is the credit, and the high nibble of the class option the class, which
// is always 0 for RDP.
if int(tpdu[0]) != len(tpdu)-1 || tpdu[1]&0xf0 != code || tpdu[6]&0xf0 != 0 {
return nil
}
rest := tpdu[x224FixedSize:]
m := analyzer.PropMap{}
if code == x224CodeConnectionRequest {
// Both the cookie ("Cookie: mstshash=<user>") and the load balancer
// routing token ("Cookie: msts=<token>") are terminated by CRLF.
if bytes.HasPrefix(rest, rdpCookiePrefix) {
i := bytes.Index(rest, []byte("\r\n"))
if i < 0 {
return nil
}
m["cookie"] = string(rest[len(rdpCookiePrefix):i])
rest = rest[i+2:]
}
// A client that sends no RDP_NEG_REQ can only do standard RDP
// security (PROTOCOL_RDP).
m["protocols"] = 0
} else {
m["protocol"] = 0
}
if len(rest) == 0 {
return m
}
// Anything after the negotiation structure, such as the correlation info
// a client may append, is ignored.
if len(rest) < rdpNegSize || binary.LittleEndian.Uint16(rest[2:4]) != rdpNegSize {
return nil
}
v := int(binary.LittleEndian.Uint32(rest[4:8]))
switch {
case code == x224CodeConnectionRequest && rest[0] == rdpNegReq:
m["protocols"] = v
case code == x224CodeConnectionConfirm && rest[0] == rdpNegRsp:
m["protocol"] = v
case code == x224CodeConnectionConfirm && rest[0] == rdpNegFailure:
delete(m, "protocol")
m["failure"] = v
default:
return nil
}
return m
}
+131
View File
@@ -0,0 +1,131 @@
package tcp
import (
"reflect"
"testing"
"github.com/apernet/OpenGFW/analyzer"
)
// rdpConnectionRequest is what mstsc sends first: a cookie with the user name
// and an RDP_NEG_REQ asking for PROTOCOL_SSL | PROTOCOL_HYBRID |
// PROTOCOL_HYBRID_EX.
var rdpConnectionRequest = []byte{
0x03, 0x00, 0x00, 0x2c, // TPKT, length 44
0x27, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, // X.224 CR
'C', 'o', 'o', 'k', 'i', 'e', ':', ' ', 'm', 's', 't', 's', 'h', 'a', 's', 'h', '=', 'e', 'l', 't', 'o', 'n', 's', '\r', '\n',
0x01, 0x00, 0x08, 0x00, 0x0b, 0x00, 0x00, 0x00, // RDP_NEG_REQ
}
func rdpConnectionConfirm(negType byte, v byte) []byte {
return []byte{
0x03, 0x00, 0x00, 0x13, // TPKT, length 19
0x0e, 0xd0, 0x00, 0x00, 0x12, 0x34, 0x00, // X.224 CC
negType, 0x00, 0x08, 0x00, v, 0x00, 0x00, 0x00,
}
}
var rdpTLSClientHelloProps = analyzer.PropMap{"cookie": "mstshash=eltons", "protocols": 11}
func TestTlsRDPClientHello(t *testing.T) {
data := append(append([]byte{}, rdpConnectionRequest...), tlsExampleClientHello...)
for name, segs := range map[string][][]byte{
"one segment": {data},
"byte by byte": splitEvery(data, 1),
"pdu, hello": {rdpConnectionRequest, tlsExampleClientHello},
} {
props, _ := feedTLS(newTLSStream(nil), false, segs...)
if got := props.Get("rdp_req"); !reflect.DeepEqual(got, rdpTLSClientHelloProps) {
t.Errorf("%s: rdp_req = %v, want %v", name, got, rdpTLSClientHelloProps)
}
if got := props.Get("req"); !reflect.DeepEqual(got, tlsExampleClientHelloProps) {
t.Errorf("%s: req = %v, want %v", name, got, tlsExampleClientHelloProps)
}
}
}
// The negotiation is published before any TLS shows up, so that rules can
// already see the stream is RDP.
func TestTlsRDPNegotiationReportedEarly(t *testing.T) {
s := newTLSStream(nil)
props, done := feedTLS(s, false, rdpConnectionRequest)
if done {
t.Fatal("stream done after connection request")
}
if got := props.Get("rdp_req"); !reflect.DeepEqual(got, rdpTLSClientHelloProps) {
t.Errorf("rdp_req = %v, want %v", got, rdpTLSClientHelloProps)
}
props, done = feedTLS(s, true, rdpConnectionConfirm(0x02, 0x02))
if done {
t.Fatal("stream done after connection confirm")
}
if got, want := props.Get("rdp_resp"), (analyzer.PropMap{"protocol": 2}); !reflect.DeepEqual(got, want) {
t.Errorf("rdp_resp = %v, want %v", got, want)
}
}
func TestTlsRDPConnectionConfirm(t *testing.T) {
for name, tc := range map[string]struct {
pdu []byte
want analyzer.PropMap
}{
"ssl": {rdpConnectionConfirm(0x02, 0x01), analyzer.PropMap{"protocol": 1}},
"hybrid_ex": {rdpConnectionConfirm(0x02, 0x08), analyzer.PropMap{"protocol": 8}},
"failure": {rdpConnectionConfirm(0x03, 0x05), analyzer.PropMap{"failure": 5}},
"no neg rsp": {[]byte{0x03, 0x00, 0x00, 0x0b, 0x06, 0xd0, 0x00, 0x00, 0x12, 0x34, 0x00}, analyzer.PropMap{"protocol": 0}},
} {
props, _ := feedTLS(newTLSStream(nil), true, tc.pdu)
if got := props.Get("rdp_resp"); !reflect.DeepEqual(got, tc.want) {
t.Errorf("%s: rdp_resp = %v, want %v", name, got, tc.want)
}
}
}
// Standard RDP security has no TLS: the client goes on with MCS, which is not
// a TLS record, so the analyzer stops with only the negotiation published.
func TestTlsRDPWithoutTLS(t *testing.T) {
mcs := []byte{0x03, 0x00, 0x01, 0xa0, 0x02, 0xf0, 0x80, 0x7f, 0x65}
props, done := feedTLS(newTLSStream(nil), false, rdpConnectionRequest, mcs)
if !done {
t.Error("stream not done")
}
if props.Get("rdp_req") == nil || props.Get("req") != nil {
t.Errorf("props = %v, want rdp_req only", props)
}
}
// truncatedNegReq is rdpConnectionRequest with the last two bytes of the
// RDP_NEG_REQ cut off and the lengths adjusted to match.
func truncatedNegReq() []byte {
pdu := append([]byte{}, rdpConnectionRequest[:len(rdpConnectionRequest)-2]...)
pdu[3] -= 2
pdu[4] -= 2
return pdu
}
func TestTlsRDPRejectsMalformedPrelude(t *testing.T) {
mutate := func(i int, b byte) []byte {
pdu := append([]byte{}, rdpConnectionRequest...)
pdu[i] = b
return pdu
}
for name, pdu := range map[string][]byte{
"reserved set": mutate(1, 0x01),
"wrong code": mutate(5, 0xd0),
"length mismatch": mutate(4, 0x26),
"class 2": mutate(10, 0x20),
"cookie w/o crlf": mutate(len(rdpConnectionRequest)-10, 'x'),
"bad neg type": mutate(len(rdpConnectionRequest)-8, 0x02),
"tpkt too short": {0x03, 0x00, 0x00, 0x05, 0x00},
"not tpkt nor tls": {0x05, 0x01, 0x00},
"truncated neg req": truncatedNegReq(),
} {
props, done := feedTLS(newTLSStream(nil), false, pdu)
if !done {
t.Errorf("%s: stream not done", name)
}
if len(props) != 0 {
t.Errorf("%s: props = %v, want none", name, props)
}
}
}