网络安全

Reality 协议研究:从 Shadowsocks 到 Reality 的演进与 Demo 复现

2026-07-19 #网络协议#vpn

摘要:本文从网络分层与 OSI/TCP-IP 模型讲起,说明 Shadowsocks、VMess、VLESS、Trojan、Reality 各自工作在哪一层。在此基础上研究了代理协议从 Shadowsocks 到 VMess、Trojan 再到 Reality 的演进脉络,并基于 XTLS/REALITY 和 XTLS/Xray-core 的源码,梳理了 Reality 的核心机制。最后给出了一段升级后的 Go Demo,支持协议首包识别、完整 ClientHello 解析、SNI 动态路由,以及 JA3 指纹所需字段提取。代码仅用于技术研究。


前置知识:网络分层与代理协议的位置

要理解 Shadowsocks、VMess、Trojan、Reality 这些协议的区别,需要先搞清楚它们在网络栈中的位置。网络分层不是抽象概念,它直接决定了协议能伪装到什么程度、能被识别在哪里。

一、TCP/IP 四层模型与 OSI 七层模型

两种模型都是把网络通信按功能分层,只是切法不同。对代理协议来说,TCP/IP 四层更实用。

TCP/IP 四层 OSI 七层 代表协议/数据 作用一句话 代理协议例子
应用层 应用层、表示层、会话层 HTTP、HTTPS、FTP、DNS、SOCKS5 解决”某个应用程序想发送什么数据、怎么解析” Trojan 内层认证、VMess 请求头、SOCKS5
传输层 传输层 TCP、UDP、端口 解决”端到端可靠传输,数据交给哪个进程” Shadowsocks 直接跑在 TCP/UDP 上,Reality 在这里接管 TLS
网络层 网络层 IP、ICMP、路由 解决”数据包从哪来、到哪去,走哪条路” 网络层本身不感知代理协议,但 IP 和 SNI 会被审查
链路层 数据链路层、物理层 以太网、Wi-Fi、光纤 解决”比特流在物理介质上怎么跑” 和代理协议关系不大

一句话记忆:链路层传比特,网络层找路径,传输层管端口,应用层定语义。

二、代理协议分别工作在哪个层

这是理解 Shadowsocks、VMess、Trojan、Reality 差异的关键。

协议 主要工作层 说明
SOCKS5 应用层 它只是一套”告诉代理服务器目标地址”的协议,本身不加密。很多代理工具在本地先开个 SOCKS5 服务,再由下层协议把流量传出去。
Shadowsocks 传输层之上/应用层之下 它把 SOCKS5 要转发的数据拿过来,用对称密钥加密后直接塞进 TCP/UDP 发走。它不伪装成 HTTP,也不做 TLS 握手,只是一条加密隧道。
VMess 应用层 VMess 自己定义了请求头和数据格式,再交给 TCP、WebSocket、gRPC 等传输层承载。如果外层是 TLS+WebSocket,那 TLS 是另一层负责伪装的。
VLESS 应用层 和 VMess 类似,是 V2Ray/Xray 的代理协议,但头部更轻、职责更单一。通常配合 TLS/XTLS 作为外层。
Trojan 传输层(TLS) + 应用层 客户端和服务端先完成标准 TLS 握手(传输层),然后在 TLS 加密通道里用 Trojan 自己的密码认证(应用层)。它伪装成 HTTPS,但服务器需要自己的证书。
Reality 传输层(TLS 握手层) Reality 不定义新的代理语义,它只解决”怎么让 TLS 握手看起来像访问真实网站”。真正的代理协议(如 VLESS)跑在 Reality 接管后的隧道里。

重点:Shadowsocks 和 VMess/VLESS 是”应用层代理协议”,它们负责”目标地址是什么”;Trojan 和 Reality 是”传输层伪装方案”,它们负责”让这条连接看起来像普通 HTTPS”。Reality 的创新在于:它连 TLS 证书和握手都懒得自己演,而是直接让真网站演。

三、几个必须理解的概念

1. SNI(Server Name Indication, 服务器名称指示)

一台服务器的一个 IP 上可能同时托管几百个网站。TLS 握手时,客户端需要在 ClientHello 里告诉服务器:”我要访问的是 www.microsoft.com“。这个字段就是 SNI。

  • 没有 SNI,服务器不知道该给哪个证书。
  • 审查者常常用 SNI 黑名单做初步过滤。
  • Reality 的关键:让 SNI 指向真实网站(如 www.microsoft.com),并且真实证书也匹配这个 SNI。

2. TLS 握手与 ClientHello

TLS 握手是 HTTPS 建立安全连接的第一步。客户端先发 ClientHello,包含:

  • TLS 版本
  • 支持的密码套件(Cipher Suites)
  • 支持的扩展(Extensions),包括 SNI、ALPN、SupportedVersions、KeyShare 等
  • 一个 32 字节的随机数 ClientRandom
  • 在 TLS 1.3 中,SessionId 字段保留但不再用于会话恢复

服务器回应 ServerHello,然后发证书、密钥交换等。TLS 1.3 握手完成后,双方派生出会话密钥,后续通信加密。

Reality 的 trick 就是:在这个握手过程里,把客户端的 ClientHello 原样转发给真实目标,再把真实目标的 ServerHello/证书 透传回来。 从链路上看,就像客户端真的在和真实网站握手。

3. 证书与证书链

证书由 CA(证书颁发机构)签发,证明”这个公钥确实属于 www.microsoft.com“。浏览器/操作系统内置了可信 CA 列表,所以访问大网站时证书能验证通过。

传统代理服务器自己申请证书时,证书上的域名是代理服务器自己的域名,审查者可以把这个域名/指纹拉黑。Reality 的方案是:不申请证书,直接借用真实网站的证书。

4. 透明转发与接管

  • 透明转发:收到客户端数据后,原样不改地发给另一个目标,再把目标返回的数据原样发回客户端。对双方来说,仿佛直连。
  • 接管:在 TLS 握手的某个节点,代理服务器不再把真实目标的响应透传给客户端,而是自己生成后续 TLS 记录,用派生出的密钥和客户端建立隧道。

Reality 的认证分支就是:认证成功 → 接管;认证失败 → 透明转发给真实网站。

四、为什么分层视角很重要

如果你从分层角度看,就能明白为什么 Shadowsocks 容易被识别、为什么 Trojan 需要域名、为什么 Reality 是突破。

  • Shadowsocks 在传输层直接发加密数据。审查者不需要解密,只看”这是一个非 HTTP/HTTPS 的 TCP 流”,就可能直接阻断。
  • VMess 在应用层自己定义了一套格式。虽然外层可以套 TLS,但 VMess 请求头本身有固定长度和特征,DPI 可以学出来。
  • Trojan 在传输层做得像 HTTPS。但因为它要扮演 HTTPS 服务器,所以审查者可以针对”这个 HTTPS 服务器”做指纹、证书、SNI 黑名单。
  • Reality 在传输层不扮演 HTTPS 服务器,而是把自己变成真实 HTTPS 连接的一部分。审查者能看到的证书和 SNI 都是真网站的,无法从这一层识别出代理。

理解了这一层,再看后面的四协议演进,就会非常清晰。


代理协议的演进:为什么需要 Reality

第一代:Shadowsocks

Shadowsocks 是一个轻量级 SOCKS5 代理,核心设计是”把数据包加密成随机字节流”。

  • 握手:几乎没有显式握手。客户端直接用预共享密钥(PSK)发加密数据。
  • 加密:早期用流密码(AES-CTR、ChaCha20),后期升级到 AEAD(AES-GCM、ChaCha20-Poly1305)。
  • 被识别的原因:虽然载荷不可读,但流密码时代的流量有统计特征;而且端口固定、首包结构有迹可循。升级到 AEAD 后好很多,但仍依赖固定密钥,没有”抗主动探测”机制——审查者可以直接用密钥尝试解密,或者通过时间/长度特征识别。

一句话总结:Shadowsocks 隐藏内容,但没有隐藏”这是一个代理连接”这件事。

第二代:VMess

VMess 是 V2Ray 的核心协议,设计目标是”特征更少、更灵活”。

  • 握手:客户端发送一个加密的请求头,包含 UUID 派生的认证信息、目标地址、加密方式、传输层类型等。
  • 认证:基于 UUID 和动态 ID(时间相关),服务端用正确 UUID 才能解密请求头。
  • 加密:请求头和后续数据都用 AEAD 加密。
  • 灵活:可以承载在 TCP、WebSocket、HTTP/2、gRPC 等多种传输层之上。
  • 被识别的原因:VMess 的固定长度请求头、特定扩展使用方式,以及客户端和服务端实现中的一些指纹, eventually 可以被 DPI 学习。而且它的”伪装”依赖于上层传输(比如 TLS+WebSocket),TLS 层仍然需要自己的证书和域名。

一句话总结:VMess 把协议做得更复杂、更灵活,但本质上还是”自己造一个协议,然后努力伪装”。

第三代:Trojan

Trojan 是一个极简主义设计,它直接放弃”造协议”,而是说:** TLS 加密本来就是互联网常态,我们就在 TLS 里面跑代理。**

  • 握手:客户端先和服务端完成标准 TLS 握手。服务端需要持有证书(通常是真实域名证书或自签证书)。
  • 认证:TLS 握手成功后,客户端在加密通道里发送 hex(SHA224(password)) + SOCKS5 风格的目标地址请求。
  • Fallback:如果认证失败,Trojan 服务端把流量转发给一个正常 HTTP 服务(比如本地 80 端口),让主动探测者看到一个正常网站。
  • 被识别的原因:Trojan 比 VMess 更像 HTTPS,但它仍然需要自己的证书和域名。审查者可以记录证书指纹、SNI、TLS 指纹(JA3),建立”这个证书/指纹属于代理服务器”的黑名单。而且一旦证书是自签的或者域名不常见,就很容易被标记。

一句话总结:Trojan 把代理藏在 HTTPS 里,但”HTTPS 服务器”本身仍然是一个攻击面。

第四代:Reality

Reality 是 XTLS/Xray 项目 2023 年推出的方案。它直接否定了 Trojan 的 premise:为什么要自己当 HTTPS 服务器?为什么不能直接让真实的大网站当服务器?

  • 核心思想:代理服务器自己不持有证书。客户端发起 TLS 握手时,代理服务器把 ClientHello 原样转发给真实目标网站(如 www.microsoft.com),把真实网站返回的 ServerHello + 证书链透传回客户端。
  • 自己人认证:客户端和代理服务器通过 X25519 密钥交换、HKDF、AES-GCM,在 ClientHelloSessionId 字段里藏一个加密的短 ID。服务端认证成功后”接管”握手;认证失败则把流量透明转发给真实网站。
  • 突破点:链路上看到的证书、SNI、TLS 指纹全部来自真实网站,不需要自己的域名和证书,抗主动探测(失败走 fallback 到真实网站)。

一句话总结:Reality 不再伪装成 HTTPS,而是真的成为一次 HTTPS 连接的一部分,然后在关键时刻接管它。

四代对比

协议 伪装思路 认证方式 最大弱点
Shadowsocks 加密成随机流 预共享密钥 流量统计特征,无抗探测能力
VMess 加密请求头 + 多种传输层 UUID + 动态 ID 固定头部指纹,依赖 TLS 伪装
Trojan 标准 TLS + 内层认证 SHA224(password) 需要自己的证书/域名,可被拉黑
Reality 直接用真实网站证书 X25519 + HKDF + AES-GCM dest 网站被封则失效,长连接统计特征仍可能被 AI 识别

Reality 源码核心机制

我下载并阅读了 XTLS/Xray-core 的 transport/internet/reality/reality.gogithub.com/xtls/reality 的核心文件(特别是 tls.go 中的 Server 函数)。下面是关键机制。

1. 客户端:在 SessionId 里藏暗号

Xray-core 的 transport/internet/reality/reality.go 中,UClient 负责构造客户端握手。关键代码逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
hello.SessionId = make([]byte, 32)
copy(hello.Raw[39:], hello.SessionId)
hello.SessionId[0] = core.Version_x
hello.SessionId[1] = core.Version_y
hello.SessionId[2] = core.Version_z
hello.SessionId[3] = 0 // reserved
binary.BigEndian.PutUint32(hello.SessionId[4:], uint32(time.Now().Unix()))
copy(hello.SessionId[8:], config.ShortId)

// 用 X25519 ECDH 派生 AuthKey
publicKey, err := ecdh.X25519().NewPublicKey(config.PublicKey)
ecdhe := uConn.HandshakeState.State13.KeyShareKeys.Ecdhe
uConn.AuthKey, _ = ecdhe.ECDH(publicKey)

// HKDF
hkdf.New(sha256.New, uConn.AuthKey, hello.Random[:20], []byte("REALITY")).Read(uConn.AuthKey)

// AES-GCM 加密 SessionId[:16]
aead := crypto.NewAesGcm(uConn.AuthKey)
aead.Seal(hello.SessionId[:0], hello.Random[20:], hello.SessionId[:16], hello.Raw)
copy(hello.Raw[39:], hello.SessionId)

SessionId 的 32 字节结构:

偏移 长度 内容
0-2 3 Xray 版本号 X.Y.Z
3 1 保留 0x00
4-7 4 Unix 时间戳(大端)
8-23 16 shortId(客户端身份)
24-31 8 其余空间(被加密后覆盖)

前 16 字节被 AES-GCM 加密,整个 SessionId 看起来仍是随机字节,审查者看不出来。

2. 服务端:认证 + 分支

github.com/xtls/reality/tls.go 中的 Server 函数是核心。它一上来就做了三件事:

  1. 连接真实目标 dest 并发送 PROXY protocol 头(可选)。
  2. MirrorConn 包裹客户端连接,让从客户端读到的数据同时被转发给 dest。
  3. 读取 ClientHello 并尝试认证。

认证逻辑的关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
for _, keyShare := range hs.clientHello.keyShares {
if keyShare.group == X25519 && len(keyShare.data) == 32 {
peerPub = keyShare.data
break
}
}
if hs.c.AuthKey, err = curve25519.X25519(config.PrivateKey, peerPub); err != nil {
break
}
hkdf.New(sha256.New, hs.c.AuthKey, hs.clientHello.random[:20], []byte("REALITY")).Read(hs.c.AuthKey)

block, _ := aes.NewCipher(hs.c.AuthKey)
aead, _ := cipher.NewGCM(block)

// 用 AES-GCM 解密 SessionId
aead.Open(plainText[:0], hs.clientHello.random[20:], ciphertext, hs.clientHello.original)

// 校验版本、时间差、shortId
if (version OK) && (time diff OK) && (config.ShortIds[hs.c.ClientShortId]) {
hs.c.conn = conn // 认证成功: 接管连接
}

3. 分支行为

  • 认证失败: hs.c.conn != conn, 服务端直接 io.Copy(target, underlying)io.Copy(underlying, target), 把客户端和真实网站双向打通。审查者看到的是正常网站响应。
  • 认证成功: hs.c.conn = conn, 服务端继续调用 hs.handshake()hs.readClientFinished(), 自己伪造 TLS 1.3 握手记录,接管密钥,后续走代理隧道。

这就是 Reality 抗主动探测的核心:对陌生人,你就是一个正常网站的反向代理。

4. 服务端伪造握手记录

认证成功后,服务端不能直接把 dest 的响应原样发回客户端(那样客户端和 dest 会用自己的密钥完成握手,代理服务器就被架空了)。服务端会:

  • 从 dest 读取 ServerHelloChangeCipherSpecEncryptedExtensionsCertificateCertificateVerifyFinished 等记录。
  • 根据这些记录的结构,自己生成对应的 TLS 1.3 记录,但用自己的密钥。
  • 让客户端以为它就是 dest 的响应,从而用代理服务器的密钥继续通信。

这部分代码在 handshake_server_tls13.go 里,与标准 Go crypto/tls 的服务器握手高度相似,但关键点被 Reality 接管。


升级 Demo 的目标

基于上面的源码理解,我把 Demo 升级到 v2,新增以下能力:

  1. 协议首包识别:判断 incoming 连接是 TLS、HTTP 还是未知协议。
  2. 完整 ClientHello 解析:版本、随机数、SessionID、密码套件、压缩方法、扩展列表、SNI、ALPN、SupportedVersions、KeyShare groups、SignatureSchemes。
  3. SNI 动态路由:根据解析出的 SNI 选择不同的真实目标网站。
  4. JA3 指纹字段提取:收集 JA3 计算所需的字段,但暂不引入外部依赖计算完整 hash。
  5. 保留 Reality 认证插槽:在代码中明确标注 X25519 + HKDF + AES-GCM 认证逻辑应插入的位置,但本 Demo 不实现(避免变成可部署翻墙工具)。
  6. 更详细的日志:方便观察不同客户端(OpenSSL、浏览器、curl)的握手特征差异。

完整代码

项目目录:

1
2
3
4
reality-study/
├── go.mod
├── main.go
└── reality-study.exe

main.go:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
package main

import (
"bytes"
"encoding/binary"
"encoding/hex"
"flag"
"fmt"
"io"
"log"
"net"
"strings"
"time"
)

// 升级后的 Reality 研究 Demo(v2)。
//
// 相比 v1 版本,本版本增加了:
// 1. 协议首包识别(TLS / HTTP / 未知)
// 2. 更完整的 TLS ClientHello 解析(版本、密码套件、扩展、ALPN、Supported Versions、KeyShare groups)
// 3. SNI 到真实目标网站的动态路由映射
// 4. 提取 JA3 指纹所需字段(暂不计算完整 JA3 hash,避免引入外部依赖)
// 5. 在日志中直观展示不同协议、不同客户端的握手特征
//
// 本 Demo 仍然只演示 TLS 握手字节流的操控与透传,不实现 Reality 的认证、
// 密钥派生与隧道接管。代码中保留了明确的"认证插槽"注释,标示 Reality 源码
// 中 X25519+HKDF+AES-GCM 的接入点。

const (
defaultListen = "127.0.0.1:8443"
defaultDest = "www.microsoft.com:443"
)

// ForwardRule 描述一条 SNI -> dest 的映射规则。
type ForwardRule struct {
SNI string
Dest string
}

// ClientHelloInfo 保存解析后的 ClientHello 关键信息。
type ClientHelloInfo struct {
RecordVersion uint16
HandshakeVersion uint16
Random [32]byte
SessionID []byte
CipherSuites []uint16
CompressionMethods []uint8
Extensions []Extension
SNI string
ALPNs []string
SupportedVersions []uint16
KeyShareGroups []uint16
SignatureSchemes []uint16
}

// Extension 保存单个 TLS 扩展的原始信息。
type Extension struct {
Type uint16
Len uint16
Data []byte
}

// ProtocolType 表示首包识别结果。
type ProtocolType int

const (
ProtoUnknown ProtocolType = iota
ProtoTLS
ProtoHTTP
)

func (p ProtocolType) String() string {
switch p {
case ProtoTLS:
return "TLS"
case ProtoHTTP:
return "HTTP"
default:
return "UNKNOWN"
}
}

func main() {
listen := flag.String("listen", defaultListen, "本地监听地址")
dest := flag.String("dest", defaultDest, "默认真实目标网站地址")
sniMap := flag.String("sni-map", "", "SNI 路由映射,逗号分隔,例如: 'www.microsoft.com:microsoft.com:443,www.apple.com:apple.com:443'")
show := flag.Bool("show", false, "打印详细握手信息")
flag.Parse()

rules := parseSniMap(*sniMap)

ln, err := net.Listen("tcp", *listen)
if err != nil {
log.Fatalf("监听失败: %v", err)
}
defer ln.Close()

log.Printf("[+] 监听 %s, 默认目标 %s", *listen, *dest)
if len(rules) > 0 {
log.Printf("[+] 已加载 %d 条 SNI 路由规则", len(rules))
for _, r := range rules {
log.Printf(" %s -> %s", r.SNI, r.Dest)
}
}
log.Println("[+] 测试命令: openssl s_client -connect 127.0.0.1:8443 -servername www.microsoft.com")

for {
conn, err := ln.Accept()
if err != nil {
log.Printf("accept 失败: %v", err)
continue
}
go handleConn(conn, *dest, rules, *show)
}
}

func parseSniMap(s string) []ForwardRule {
if strings.TrimSpace(s) == "" {
return nil
}
var rules []ForwardRule
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
kv := strings.SplitN(part, ":", 2)
if len(kv) != 2 {
log.Printf("[-] 忽略非法 SNI 映射: %s", part)
continue
}
rules = append(rules, ForwardRule{SNI: kv[0], Dest: kv[1]})
}
return rules
}

func routeBySNI(sni, defaultDest string, rules []ForwardRule) string {
for _, r := range rules {
if r.SNI == sni {
return r.Dest
}
}
return defaultDest
}

func handleConn(clientConn net.Conn, defaultDest string, rules []ForwardRule, show bool) {
defer clientConn.Close()
remoteAddr := clientConn.RemoteAddr().String()

header := make([]byte, 5)
if _, err := io.ReadFull(clientConn, header); err != nil {
log.Printf("[%s] 读取首包失败: %v", remoteAddr, err)
return
}

proto := detectProtocol(header)
log.Printf("[%s] 协议识别: %s", remoteAddr, proto)

switch proto {
case ProtoTLS:
handleTLS(clientConn, header, defaultDest, rules, show, remoteAddr)
case ProtoHTTP:
handlePlainHTTP(clientConn, header, defaultDest, remoteAddr)
default:
log.Printf("[%s] 未知协议,按默认目标 %s 透明转发", remoteAddr, defaultDest)
forwardRaw(clientConn, header, defaultDest)
}
}

func detectProtocol(header []byte) ProtocolType {
if len(header) < 5 {
return ProtoUnknown
}
if header[0] == 0x16 {
ver := binary.BigEndian.Uint16(header[1:3])
if ver == 0x0301 || ver == 0x0302 || ver == 0x0303 || ver == 0x0304 || ver == 0x0300 {
return ProtoTLS
}
}
methods := []string{"GET ", "POST", "HEAD", "PUT ", "DELE", "OPTI", "PATC", "TRAC", "CONN"}
for _, m := range methods {
if bytes.HasPrefix(header, []byte(m)) {
return ProtoHTTP
}
}
return ProtoUnknown
}

func handleTLS(clientConn net.Conn, header []byte, defaultDest string, rules []ForwardRule, show bool, remoteAddr string) {
recLen := int(binary.BigEndian.Uint16(header[3:5]))
record := make([]byte, 5+recLen)
copy(record, header)
if _, err := io.ReadFull(clientConn, record[5:]); err != nil {
log.Printf("[%s] 读取 TLS record 体失败: %v", remoteAddr, err)
return
}

info, err := parseClientHello(record)
if err != nil {
log.Printf("[%s] 解析 ClientHello 失败: %v", remoteAddr, err)
return
}

dest := routeBySNI(info.SNI, defaultDest, rules)
if info.SNI == "" {
info.SNI = "(none)"
}

log.Printf("[%s] TLS ClientHello: SNI=%s, Version=0x%04x, Ciphers=%d, Exts=%d, KeyShareGroups=%v, ALPN=%v, Dest=%s",
remoteAddr, info.SNI, info.HandshakeVersion, len(info.CipherSuites), len(info.Extensions),
info.KeyShareGroups, info.ALPNs, dest)

if show {
printClientHelloDetails(remoteAddr, info)
}

// =====================================================
// Reality 认证插槽(本 Demo 未实现):
// 在真实 Reality 中,此时会:
// 1. 从 info.KeyShares 中提取 X25519 客户端公钥
// 2. 用服务端 privateKey 做 X25519 ECDH,得到 sharedSecret
// 3. hkdf.New(sha256.New, sharedSecret, info.Random[:20], []byte("REALITY")).Read(authKey)
// 4. 用 AES-GCM 解密 info.SessionID[:16] -> 版本/时间戳/shortId
// 5. 校验版本范围、时间差、shortId 白名单
// 认证失败 -> 透明转发给 dest(抗主动探测)
// 认证成功 -> 接管握手,自行生成 TLS 1.3 记录,后续走代理隧道
// 本 Demo 为了边界清晰,只执行透明转发。
// =====================================================

forwardTLS(clientConn, record, dest, remoteAddr)
}

func printClientHelloDetails(remoteAddr string, info *ClientHelloInfo) {
var cipherNames []string
for _, c := range info.CipherSuites {
cipherNames = append(cipherNames, fmt.Sprintf("0x%04x", c))
}
var extTypes []string
for _, e := range info.Extensions {
extTypes = append(extTypes, fmt.Sprintf("0x%04x", e.Type))
}
log.Printf("[%s] CipherSuites: %s", remoteAddr, strings.Join(cipherNames, ", "))
log.Printf("[%s] Extension Types: %s", remoteAddr, strings.Join(extTypes, ", "))
log.Printf("[%s] SupportedVersions: %v", remoteAddr, versionsToStrings(info.SupportedVersions))
log.Printf("[%s] SignatureSchemes: %v", remoteAddr, schemesToStrings(info.SignatureSchemes))
log.Printf("[%s] SessionID: %s", remoteAddr, hex.EncodeToString(info.SessionID))
}

func versionsToStrings(vers []uint16) []string {
var out []string
for _, v := range vers {
switch v {
case 0x0304:
out = append(out, "TLS 1.3")
case 0x0303:
out = append(out, "TLS 1.2")
case 0x0302:
out = append(out, "TLS 1.1")
case 0x0301:
out = append(out, "TLS 1.0")
default:
out = append(out, fmt.Sprintf("0x%04x", v))
}
}
return out
}

func schemesToStrings(schemes []uint16) []string {
var out []string
for _, s := range schemes {
out = append(out, fmt.Sprintf("0x%04x", s))
}
return out
}

func forwardTLS(clientConn net.Conn, record []byte, dest, remoteAddr string) {
destConn, err := net.Dial("tcp", dest)
if err != nil {
log.Printf("[%s] 连接目标 %s 失败: %v", remoteAddr, dest, err)
return
}
defer destConn.Close()

if _, err := destConn.Write(record); err != nil {
log.Printf("[%s] 转发 ClientHello 失败: %v", remoteAddr, err)
return
}

log.Printf("[%s] 开始双向透传 <-> %s", remoteAddr, dest)
relay(clientConn, destConn)
}

func forwardRaw(clientConn net.Conn, header []byte, dest string) {
remoteAddr := clientConn.RemoteAddr().String()
destConn, err := net.Dial("tcp", dest)
if err != nil {
log.Printf("[%s] 连接目标 %s 失败: %v", remoteAddr, dest, err)
return
}
defer destConn.Close()
if _, err := destConn.Write(header); err != nil {
log.Printf("[%s] 转发首包失败: %v", remoteAddr, err)
return
}
relay(clientConn, destConn)
}

func handlePlainHTTP(clientConn net.Conn, header []byte, defaultDest, remoteAddr string) {
log.Printf("[%s] 明文 HTTP, 按默认目标 %s 转发", remoteAddr, defaultDest)
forwardRaw(clientConn, header, defaultDest)
}

func relay(a, b net.Conn) {
done := make(chan struct{}, 2)
go func() {
_, _ = io.Copy(b, a)
done <- struct{}{}
}()
go func() {
_, _ = io.Copy(a, b)
done <- struct{}{}
}()
<-done
}

func parseClientHello(record []byte) (*ClientHelloInfo, error) {
if len(record) < 5 || record[0] != 0x16 {
return nil, fmt.Errorf("不是 TLS Handshake record")
}
recLen := int(binary.BigEndian.Uint16(record[3:5]))
if len(record) != 5+recLen {
return nil, fmt.Errorf("TLS record 长度不匹配")
}
body := record[5:]
if len(body) < 5 || body[0] != 1 {
return nil, fmt.Errorf("不是 ClientHello")
}

info := &ClientHelloInfo{}
info.RecordVersion = binary.BigEndian.Uint16(record[1:3])

pos := 4
if len(body) < pos+34 {
return nil, fmt.Errorf("ClientHello 主体过短")
}
info.HandshakeVersion = binary.BigEndian.Uint16(body[pos : pos+2])
pos += 2
copy(info.Random[:], body[pos:pos+32])
pos += 32

sidLen := int(body[pos])
pos++
if pos+sidLen > len(body) {
return nil, fmt.Errorf("Session ID 数据越界")
}
info.SessionID = make([]byte, sidLen)
copy(info.SessionID, body[pos:pos+sidLen])
pos += sidLen

csLen := int(binary.BigEndian.Uint16(body[pos : pos+2]))
pos += 2
if pos+csLen > len(body) || csLen%2 != 0 {
return nil, fmt.Errorf("Cipher Suites 数据越界")
}
for i := 0; i < csLen; i += 2 {
info.CipherSuites = append(info.CipherSuites, binary.BigEndian.Uint16(body[pos+i:pos+i+2]))
}
pos += csLen

compLen := int(body[pos])
pos++
if pos+compLen > len(body) {
return nil, fmt.Errorf("Compression Methods 数据越界")
}
for i := 0; i < compLen; i++ {
info.CompressionMethods = append(info.CompressionMethods, body[pos+i])
}
pos += compLen

extLen := int(binary.BigEndian.Uint16(body[pos : pos+2]))
pos += 2
if pos+extLen > len(body) {
return nil, fmt.Errorf("Extensions 数据越界")
}
parseExtensions(body[pos:pos+extLen], info)
return info, nil
}

func parseExtensions(exts []byte, info *ClientHelloInfo) {
pos := 0
for pos+4 <= len(exts) {
extType := binary.BigEndian.Uint16(exts[pos : pos+2])
extLen := int(binary.BigEndian.Uint16(exts[pos+2 : pos+4]))
pos += 4
if pos+extLen > len(exts) {
break
}
data := exts[pos : pos+extLen]
info.Extensions = append(info.Extensions, Extension{
Type: extType,
Len: uint16(extLen),
Data: data,
})
switch extType {
case 0x0000:
info.SNI = parseSNIExt(data)
case 0x0010:
info.ALPNs = parseALPN(data)
case 0x002b:
info.SupportedVersions = parseSupportedVersions(data)
case 0x0033:
info.KeyShareGroups = parseKeyShareGroups(data)
case 0x000d:
info.SignatureSchemes = parseSignatureSchemes(data)
}
pos += extLen
}
}

func parseSNIExt(data []byte) string {
if len(data) < 2 {
return ""
}
listLen := int(binary.BigEndian.Uint16(data[0:2]))
if len(data) != 2+listLen || listLen < 3 {
return ""
}
data = data[2:]
if data[0] != 0 {
return ""
}
nameLen := int(binary.BigEndian.Uint16(data[1:3]))
if len(data) < 3+nameLen {
return ""
}
return string(data[3 : 3+nameLen])
}

func parseALPN(data []byte) []string {
if len(data) < 2 {
return nil
}
listLen := int(binary.BigEndian.Uint16(data[0:2]))
if len(data) != 2+listLen {
return nil
}
data = data[2:]
var alpn []string
for len(data) >= 1 {
l := int(data[0])
if len(data) < 1+l {
break
}
alpn = append(alpn, string(data[1:1+l]))
data = data[1+l:]
}
return alpn
}

func parseSupportedVersions(data []byte) []uint16 {
if len(data) < 3 {
return nil
}
listLen := int(data[0])
if len(data) != 1+listLen || listLen%2 != 0 {
return nil
}
var vers []uint16
for i := 1; i < 1+listLen; i += 2 {
vers = append(vers, binary.BigEndian.Uint16(data[i:i+2]))
}
return vers
}

func parseKeyShareGroups(data []byte) []uint16 {
if len(data) < 2 {
return nil
}
listLen := int(binary.BigEndian.Uint16(data[0:2]))
if len(data) != 2+listLen {
return nil
}
data = data[2:]
var groups []uint16
for len(data) >= 4 {
group := binary.BigEndian.Uint16(data[0:2])
keyLen := int(binary.BigEndian.Uint16(data[2:4]))
if len(data) < 4+keyLen {
break
}
groups = append(groups, group)
data = data[4+keyLen:]
}
return groups
}

func parseSignatureSchemes(data []byte) []uint16 {
if len(data) < 2 {
return nil
}
listLen := int(binary.BigEndian.Uint16(data[0:2]))
if len(data) != 2+listLen || listLen%2 != 0 {
return nil
}
var schemes []uint16
for i := 2; i < 2+listLen; i += 2 {
schemes = append(schemes, binary.BigEndian.Uint16(data[i:i+2]))
}
return schemes
}

func init() {
_ = time.Now
}

运行与验证

1. 编译

1
2
cd reality-study
go build -o reality-study.exe .

2. 运行(带 SNI 路由映射)

1
./reality-study.exe -listen 127.0.0.1:8444 -dest www.microsoft.com:443 -show -sni-map "www.microsoft.com:microsoft.com:443,www.apple.com:apple.com:443"

启动日志:

1
2
3
4
2026/07/19 07:49:15 [+] 监听 127.0.0.1:8444, 默认目标 www.microsoft.com:443
2026/07/19 07:49:15 [+] 已加载 2 条 SNI 路由规则
2026/07/19 07:49:15 www.microsoft.com -> microsoft.com:443
2026/07/19 07:49:15 www.apple.com -> apple.com:443

3. 用 openssl 测试

1
openssl s_client -connect 127.0.0.1:8444 -servername www.microsoft.com -showcerts </dev/null

服务端日志:

1
2
3
4
5
6
7
8
2026/07/19 07:49:15 [127.0.0.1:9243] 协议识别: TLS
2026/07/19 07:49:15 [127.0.0.1:9243] TLS ClientHello: SNI=www.microsoft.com, Version=0x0303, Ciphers=30, Exts=12, KeyShareGroups=[4588 29], ALPN=[], Dest=microsoft.com:443
2026/07/19 07:49:15 [127.0.0.1:9243] CipherSuites: 0x1302, 0x1303, 0x1301, 0xc02c, 0xc030, ...
2026/07/19 07:49:15 [127.0.0.1:9243] Extension Types: 0xff01, 0x0000, 0x000b, 0x000a, 0x0023, 0x0016, 0x0017, 0x000d, 0x002b, 0x002d, 0x0033, 0x001b
2026/07/19 07:49:15 [127.0.0.1:9243] SupportedVersions: [TLS 1.3 TLS 1.2]
2026/07/19 07:49:15 [127.0.0.1:9243] SignatureSchemes: [0x0905 0x0906 ...]
2026/07/19 07:49:15 [127.0.0.1:9243] SessionID: d2e39b06eab89ac5b1afdd95cadad5d51100c59a489c18536d0f2bb8ef62e365
2026/07/19 07:49:16 [127.0.0.1:9243] 开始双向透传 <-> microsoft.com:443

openssl 输出关键行:

1
2
3
4
5
6
7
8
9
10
Connecting to 127.0.0.1
depth=3 C=US, O=DigiCert Inc, OU=www.digicert.com, CN=DigiCert Global Root G3
verify return:1
depth=2 C=US, O=Microsoft Corporation, CN=Microsoft TLS ECC Root G2
verify return:1
depth=1 C=US, O=Microsoft Corporation, CN=Microsoft TLS G2 ECC CA OCSP 06
verify return:1
depth=0 C=US, ST=WA, L=Redmond, O=Microsoft Corporation, CN=*.azureedge.net
verify return:1
CONNECTED(00000238)

注意:由于 SNI 映射把 www.microsoft.com 转发到了 microsoft.com:443,而 microsoft.com 实际指向 Azure CDN,所以证书 CN 是 *.azureedge.net,但证书链仍是微软 CA 签发。这正好说明 Demo 的 SNI 路由生效了。

4. Windows PowerShell 用户注意事项

博客前面的命令都是 bash 语法。在 Windows PowerShell 里运行,要注意三个问题。

4.1 端口被占用

如果看到:

1
listen tcp 127.0.0.1:8444: bind: Only one usage of each socket address ...

说明之前启动的 reality-study.exe 还在后台。先杀掉再启动:

1
taskkill /F /IM reality-study.exe

或者查找具体占用 8444 的进程:

1
Get-Process -Id (Get-NetTCPConnection -LocalPort 8444).OwningProcess | Stop-Process -Force

4.2 Windows 默认没有 openssl

PowerShell 里直接敲 openssl 会报 The term 'openssl' is not recognized。最常见的解决办法:

方案 A:用 Git Bash 自带的 openssl

Git for Windows 安装后,openssl.exe 通常在:

1
C:\Program Files\Git\usr\bin\openssl.exe

在 PowerShell 里可以用完整路径调用,并把它加到 PATH:

1
2
3
$openssl = "C:\Program Files\Git\usr\bin\openssl.exe"
$env:Path += ";C:\Program Files\Git\usr\bin"
"Q" | & $openssl s_client -connect 127.0.0.1:8444 -servername www.microsoft.com -showcerts

"Q" | 是为了让 openssl 在打印完证书后退出。PowerShell 不支持 bash 的 </dev/null 输入重定向。

方案 B:单独安装 OpenSSL

slproweb.com/products/Win32OpenSSL.html 下载 Win64 OpenSSL Light,安装时勾选加入 PATH,重启 PowerShell 后就能直接用 openssl

方案 C:用 Git Bash 终端

直接打开 Git Bash,所有博客里的命令都能原样运行,包括 </dev/null

4.3 PowerShell 不支持 < /dev/null

bash 写法:

1
openssl s_client -connect 127.0.0.1:8444 -servername www.microsoft.com -showcerts </dev/null

在 PowerShell 里会报错:

1
The '<' operator is reserved for future use.

改成管道:

1
"Q" | openssl s_client -connect 127.0.0.1:8444 -servername www.microsoft.com -showcerts

5. 用浏览器验证(最直观)

启动 Demo 后,直接用浏览器打开:

1
https://127.0.0.1:8444

浏览器会提示证书不安全(因为访问的是 IP 而不是域名,和证书 CN 不匹配),点击”高级”查看证书,应该能看到:

  • 颁发对象: www.microsoft.com
  • 组织: Microsoft Corporation
  • 颁发者: Microsoft TLS G2 RSA CA OCSP 04 之类的微软 CA

这就证明 Demo 已经把真实网站的证书透传到了你的浏览器。页面可能显示 Invalid URL 或 edgesuite 的错误,这是正常的——这个 Demo 只研究 TLS 层,不处理 HTTP 代理逻辑。


关键实现点解析

1. 协议首包识别

TLS 的 record 头固定为 5 字节:

字节 含义
0 ContentType, Handshake = 0x16
1-2 记录版本
3-4 载荷长度

HTTP 请求则以方法名开头。Demo 据此把连接分为 TLS / HTTP / 未知三类。

2. ClientHello 的完整解析

parseClientHello 中,我们按 RFC 5246/8446 的结构顺序解析:

1
2
3
4
5
Version(2) + Random(32)
+ SessionIDLen(1) + SessionID
+ CipherSuitesLen(2) + CipherSuites
+ CompressionMethodsLen(1) + CompressionMethods
+ ExtensionsLen(2) + Extensions

扩展部分再逐个解析:

  • 0x0000 server_name: 提取 SNI
  • 0x0010 ALPN: 提取应用层协议
  • 0x002b supported_versions: 提取 TLS 版本偏好
  • 0x0033 key_share: 提取密钥交换 group ID
  • 0x000d signature_algorithms: 提取签名算法

3. JA3 指纹字段

JA3 是一种 TLS 客户端指纹算法,它把 ClientHello 中的以下字段拼成一个字符串,然后 MD5:

  • TLS Version
  • Cipher Suites
  • Extensions
  • Supported Groups(椭圆曲线)
  • EC Point Formats

我们的 Demo 已经收集了这些字段,如果需要,可以进一步用 crypto/md5 计算完整 JA3 字符串。但这里为了代码简洁和避免外部依赖,只打印原始字段,便于手动观察指纹差异。

4. SNI 动态路由

通过 -sni-map 参数,可以指定:

1
www.microsoft.com:microsoft.com:443,www.apple.com:apple.com:443

代码根据解析出的 SNI 选择目标地址。这对应 Reality 配置中的 serverNamesdest 映射。

5. Reality 认证插槽

代码中保留了一段注释,明确标示了 Reality 认证应发生的位置。这个插槽如果要实现,需要:

  • info.KeyShareGroups 和原始 record 中取出 X25519 公钥
  • 用服务端 privateKeycurve25519.X25519
  • 用 HKDF 派生 authKey
  • 用 AES-GCM 解密 SessionID[:16]
  • 校验版本、时间、shortId

本 Demo 没有实现,保持技术研究边界。


局限与下一步

当前局限

  1. 未实现 Reality 认证和隧道接管:Demo 只能透明转发 TLS 握手,不能真正成为代理隧道。
  2. 未处理 TLS 1.3 的 0-RTT / 重协商:只处理最简单的情况。
  3. JA3 未计算完整 hash:只打印字段,可进一步用 MD5 计算。
  4. 非 TLS 协议只做透明转发:没有针对 Shadowsocks/VMess/Trojan 做解析或识别。

下一步可深入

  1. 实现 Reality 认证插槽:把 X25519 + HKDF + AES-GCM 逻辑填进去,做成本地验证 demo。
  2. 接管 TLS 1.3 握手:参考 github.com/xtls/reality/handshake_server_tls13.go,自己生成 ServerHello、EncryptedExtensions、Certificate 等记录。
  3. 引入 uTLS:让客户端模拟真实浏览器指纹,而不是用 OpenSSL 的默认指纹。
  4. 做协议识别器:对 Shadowsocks/VMess/Trojan 的首包做启发式识别(注意:只能识别”疑似”,不能精确解密)。

法律声明

本文及代码仅供网络协议、TLS 握手和代理协议演进的技术研究。理解 Shadowsocks、VMess、Trojan、Reality 的设计原理,有助于学习网络安全、DPI 与反检测技术,本身是中性的知识。

但将这些技术用于搭建或运营可规避网络审查的代理服务,在中国大陆可能违反《网络安全法》《计算机信息网络国际联网管理暂行规定》等法规。技术研究归技术研究,实际部署的法律边界需要自己把握。


参考资料

  • XTLS/Xray-core: https://github.com/XTLS/Xray-core
  • XTLS/REALITY: https://github.com/xtls/reality
  • Trojan 协议规范: https://trojan-gfw.github.io/trojan/protocol
  • VMess 协议 DeepWiki: https://deepwiki.com/v2fly/v2ray-core/3.1-vmess-protocol
  • Shadowsocks AEAD 协议: https://shadowsocks.org/guide/aead

评论
分享