diff --git a/README.zh.md b/README.zh.md index 255f4ee..1493891 100644 --- a/README.zh.md +++ b/README.zh.md @@ -40,6 +40,7 @@ Telegram 群组: https://t.me/OpGFW - [EasyTier 分析器](docs/easytier.zh.md) - [TCP 窗口操纵规避与检测](docs/tcp-window-evasion.zh.md) - [TLS record 分片规避与检测](docs/tls-record-fragmentation.zh.md) +- [拦截 TLS、放行 RDP](docs/rdp-tls.zh.md) ## 使用场景 diff --git a/analyzer/tcp/tls.go b/analyzer/tcp/tls.go index 44dc1b7..c9d1c92 100644 --- a/analyzer/tcp/tls.go +++ b/analyzer/tcp/tls.go @@ -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 } diff --git a/analyzer/tcp/tls_rdp.go b/analyzer/tcp/tls_rdp.go new file mode 100644 index 0000000..4e0356c --- /dev/null +++ b/analyzer/tcp/tls_rdp.go @@ -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=") and the load balancer + // routing token ("Cookie: msts=") 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 +} diff --git a/analyzer/tcp/tls_rdp_test.go b/analyzer/tcp/tls_rdp_test.go new file mode 100644 index 0000000..56e8919 --- /dev/null +++ b/analyzer/tcp/tls_rdp_test.go @@ -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) + } + } +} diff --git a/docs/rdp-tls.zh.md b/docs/rdp-tls.zh.md new file mode 100644 index 0000000..e3d6479 --- /dev/null +++ b/docs/rdp-tls.zh.md @@ -0,0 +1,93 @@ +# 拦截 TLS、放行 RDP + +## 问题 + +RDP 不是一连上就开始 TLS:客户端先在 TPKT 里发一个 X.224 Connection Request,服务端回一个 Connection Confirm,双方借此协商安全协议([MS-RDPBCGR] 1.3.1.1),协商完才在同一条连接上开始 TLS 握手。 + +`tls` 分析器以前要求流的第一个字节就是 TLS record(`0x16`),碰到 RDP 开头的 `03 00` 直接放弃。结果是: + +- RDP 里的 TLS 对 `tls` 规则完全不可见,没法按 RDP 单独写规则; +- 反过来,任何流量只要在 TLS 前面垫一段假的 X.224 协商,就能绕过"拦截所有 TLS"的规则。 + +## 做法 + +`tls` 分析器现在把 RDP 协商当作 hello 之前可选的一段前导来解析: + +- 第一个字节是 `0x16`:和以前一样,直接当 TLS 解析。 +- 第一个字节是 `0x03`:按 TPKT + X.224 解析。客户端方向要求是 Connection Request,服务端方向要求是 Connection Confirm;长度、class、协商结构任何一处不对都视为非法,分析器取消。 +- 前导解析完后照常解析 ClientHello / ServerHello,record 分片重组等逻辑完全一样。 + +协商一解析出来就上报,不用等 TLS: + +| 属性 | 类型 | 说明 | +| --- | --- | --- | +| `tls.rdp_req.cookie` | string | Connection Request 里的 cookie,去掉 `Cookie: ` 前缀和 CRLF,如 `mstshash=alice`;没有 cookie 时不存在 | +| `tls.rdp_req.protocols` | int | RDP_NEG_REQ 里客户端请求的协议位图;没有 RDP_NEG_REQ 时为 0 | +| `tls.rdp_resp.protocol` | int | RDP_NEG_RSP 里服务端选定的协议;没有 RDP_NEG_RSP 时为 0 | +| `tls.rdp_resp.failure` | int | 服务端回的是 RDP_NEG_FAILURE 时的失败码(此时没有 `protocol`) | + +协议取值: + +| 值 | 名称 | 是否走 TLS | +| --- | --- | --- | +| 0 | PROTOCOL_RDP(标准 RDP 安全,RC4) | 否 | +| 1 | PROTOCOL_SSL | 是 | +| 2 | PROTOCOL_HYBRID(CredSSP / NLA) | 是 | +| 4 | PROTOCOL_RDSTLS | 是 | +| 8 | PROTOCOL_HYBRID_EX | 是 | +| 16 | PROTOCOL_RDSAAD | 是 | + +## 规则 + +规则按顺序匹配、第一条命中的生效,所以放行 RDP 的规则要写在拦截 TLS 的前面: + +```yaml +- name: allow-rdp-tls + action: allow + log: true + expr: > + tls != nil && tls.req != nil && + tls.rdp_req != nil && tls.rdp_resp != nil && + tls.rdp_resp.protocol in [1, 2, 4, 8, 16] + +- name: block-tls + action: block + log: true + expr: tls != nil && tls.req != nil +``` + +放行条件同时要求: + +- **`tls.req != nil`**:ClientHello 已经完整解析。只看到协商就放行的话,协商之后跟什么都行。 +- **`tls.rdp_resp != nil` 且 `protocol` 是走 TLS 的协议**:真实的 RDP 只有在服务端选了 TLS 类协议之后才会发 ClientHello。服务端选了 PROTOCOL_RDP 或者回了协商失败,后面却出现 TLS,说明是伪造的,交给 `block-tls` 拦掉。 + +正常 RDP 的时序是 CR → CC → ClientHello,ClientHello 到的时候 `rdp_resp` 早已上报,不存在规则先看到 `req` 再看到 `rdp_resp` 的竞争。 + +标准 RDP 安全(`protocol == 0`)的连接里根本没有 TLS,两条规则都不会命中,按默认放行。 + +### 收紧放行条件 + +上面的规则只验证"协商格式正确、协商结果和后续 TLS 一致"。客户端和服务端串通的话,完全可以按规范发一套协商再跑任意 TLS,所以按需叠加: + +```yaml +# 只放行标准端口 +expr: ... && port.dst == 3389 + +# 只放行指定服务器 +expr: ... && ip.dst in ["10.0.0.10", "10.0.0.11"] + +# 要求客户端带 mstshash cookie(mstsc 通常会带,但不保证,先观察日志再启用) +expr: ... && tls.rdp_req.cookie != nil && tls.rdp_req.cookie startsWith "mstshash=" +``` + +## 行为变化 + +**以前按 `tls != nil && tls.req != nil` 写的规则现在会命中 RDP。** 以前 RDP 里的 TLS 看不到,所以不受影响;现在看得到了。不想拦 RDP 的话,必须像上面那样在前面加一条放行规则。 + +## 局限 + +**只认 RDP 这一种前导。** 其他协议里的 STARTTLS(SMTP、IMAP、XMPP 等)和任何非 TLS、非 TPKT 开头的前缀仍然会让 `tls` 分析器直接放弃。要做到"拦截所有 TLS",还需要配合 `fet` 等不依赖协议开头的检测。 + +**RD Gateway 不在此列。** 经由 RD 网关的 RDP 跑在 443 端口的 HTTPS 上,开头就是普通 TLS,没有 X.224 协商,只能按 SNI / IP 放行。 + +**RDP 的 UDP 传输不在此列。** RDP-UDP 用的是 DTLS,走 UDP,与 `tls` 分析器无关。