XTLS的REALITY如何突破白名单? REALITY源码剖析

超越常规TLS的安全性? 无需买域名、配置TLS? "黑科技"REALITY如何实现这些?

目录

ℹ️前言

XTLS/Xray-core,又称Project X,是一个开源的审查规避工具,在中文反审查领域中以其新颖前卫而实用的各种概念性技术 还有曾经神秘失踪让人以为跑路了的项目创建者&维护者RPRX 而闻名,这些技术有VLESS,XTLS-Vision,XUDP…总有一个你听说过或者用过的。

自从中国大陆的一些地区开始大范围部署一种全新的审查策略–SNI白名单,在REALITY和ShadowTLS出现以前的所有基于TLS的规避工具,不论是直接连接或是通过中转或CDN连接,一夜之间全都在这些地区变得不可用。

(已知中国大陆福建省泉州市中国移动是大陆最早部署这一审查策略的运营商)

先前ihciah开发的规避工具ShadowTLS得到广泛关注,然而此时ShadowTLS尚处于v1版本,代码库不完善,审查抗性弱;后来RPRX开发的REALITY由于同样具有规避SNI白名单审查策略的能力,以及其与成熟的规避工具Xray-core的高度集成,或许也有RPRX回归给用户们带来的惊喜,在中文反审查领域引起大量关注。

那么REALITY是如何规避这一审查策略的? 从技术角度如何理解其细节? 这两个问题将是本文接下来探讨的重点,通过解读REALITY的源代码为读者梳理REALITY的具体实现。

(小插曲: ShadowTLS有v1,v2,v3三个不相兼容的版本,其中v2修复了v1存在的主动探测漏洞,见论文 Chasing Shadows: A security analysis of the ShadowTLS proxy;而v3的设计旨在对流行的TLS库实现之一 Rustls 作最小的必要修改,使得服务端有了正常响应TLS Alert(应对MITM流量篡改)的能力,相较v2更加隐蔽;当然这都是后话了,未来可能会出一期文章讲ShadowTLS,此处不再赘述)

👀 “SNI白名单"是什么? SNI和TLS是什么关系?

你可能知道,现在广泛使用的应用层安全协议,HTTPS的基石 —— TLS协议在发起连接时有自己的"握手流程”。什么? 你不知道"握手"是啥? 你可以看看我的往期文章 “流量分类识别加密代理? 初探TLS in Any问题” 中的这一段

TLS自第一个版本设计之初就是"混合加密系统"。这意味着TLS既使用非对称加密算法,也使用对称加密算法。对称加密算法需要通信双方持有完全相同的密钥,加解密开销较小;而非对称加密只需要双方交换各自持有的密钥对中的公钥,但是非对称加密在交换公钥时需要验证公钥未被替换或篡改,因而催生了数字证书机制。并且非对称加解密开销较大。因此,TLS使用非对称加密传输用于对称加密的密钥,为了交换用于非对称加密的公钥,从而催生了TLS握手机制。

在理解SNI白名单这种审查策略之前,我们先来看看一个未使用ECH的常见TLS1.3连接的握手流程:

(这里引用一张来自Cloudflare Blog的图片,版权归Cloudflare lnc.所有。)

首先是客户端发起TLS连接。在正确完成TCP握手后,客户端生成一对密钥对,通过打开的TCP连接向服务器发送 TLS Client Hello 消息: 客户端的所有握手参数,包括各个扩展字段 (extensions) 以及key_share (刚刚客户端生成的密钥对中的公钥)。这些参数无论敏感与否,都在 TLS Client Hello 中发送。

其次,服务器使用 TLS Client Hello 中允许的密钥交换算法来生成一个密钥对,发送 TLS Server Hello: TLS1.3的 Server Hello消息不同于TLS1.2,它只包含了服务器的所有不敏感的握手参数,比如key_share (刚刚服务器生成的密钥对中的公钥)

客户端在接收到了 TLS Server Hello 后,将其中的key_share (刚刚服务器生成的密钥对中的公钥) 提取出来,与最初客户端生成的密钥对中的私钥一同输入Diffle-Hellman密钥交换函数中。

(Diffle-Hellman密钥交换算法简称DH算法,DHE算法是通过轮换密钥实现前向安全性的DH变种,ECDHE算法是基于椭圆曲线的DHE变种。截至文章发布,最新版本的crypto/tls在处理TLS1.3公钥交换时仅支持ECDHE密钥交换算法。)

DH及其衍生算法有一个共同的性质: 将两对适用算法要求的密钥对的公钥或私钥交换,再将交换后的两对密钥对分别输入算法,一定能得到完全相同的值。即: 生成适用于算法要求的密钥对A (含公钥pub_A和私钥sec_A) 和密钥对B (含公钥pub_B和私钥sec_B),将 (pub_A, sec_B) 输入算法,得到的值一定与将 (pub_B, sec_A) 输入算法的得到的值完全相同

在这里,客户端使用 来自服务器的公钥 和 自己的私钥 输入DH算法得到的值来生成密钥,生成的密钥一定与服务器使用 来自客户端的公钥 和 自己的私钥 计算得到的密钥相同。这里生成的密钥被称作 preMasterKey,仅用于加解密接下来的握手消息。

接下来,服务器使用 preMasterKey 加密尚未发送的敏感的握手参数,包括用于验证服务器身份的数字证书 (因为数字证书包含了对应的域名/IP信息),将其封装为 TLS Application Data 消息 (也称Encrypted Extensions, 但是这个名称只在解密后可见),并附加在 Change Cipher Spec 后再发送给客户端。客户端在收到 Change Cipher Spec 后,使用 preMasterKey 解密附加在其后的 TLS Application Data,通过其中的数字证书验证服务器身份 (即证明服务器持有特定域名/IP),提取握手参数,构造 TLS Finished 消息并使用 preMasterKey 加密后发往服务器,以表示TLS握手成功完成。同时,客户端将此前在 TLS Server Hello 和 Change Cipher Spec后的附加消息中提取的完整握手参数输入DH算法计算 MasterKey,用于加解密接下来的所有消息 (即接下来的TLS Application Data 包)。服务器在收到 TLS Finished 消息之后也以同样逻辑计算 MasterKey

(在实际的TLS库实现中,客户端常常会将第一个包含应用数据的 TLS Application Data 在服务器使用TCP ACK数据包应答 TLS Finished 前发送以减少延迟。上面的示意图中最后的"GET /index.html HTTP/1.1"即是指这种情况。)

其中,TLS握手开始时,客户端在 TLS Client Hello 消息中使用SNI 扩展 (Server Name Indicator)来向服务器指示它想要访问的网站。这对于现代互联网来说至关重要,因为现在许多源服务器位于单个TLS服务器后面是很常见的,比如内容分发网络(CDN)。服务器使用 SNI 来确定谁将对连接进行身份验证:没有它,就无法知道要向客户端提供哪个网站的 TLS 证书。

审查者实施基于SNI的审查策略的关键在于,SNI对网络中的任何路由(流量传输时经过的端点)都是可见明文的,因此将泄露客户端想要连接的原始服务器。同时审查者通过控制所有的流量出口路由,并限制允许通过的 TLS Client Hello 的SNI扩展的值,以此来阻止不受审查者信任的TLS流量通过。我们称这种审查策略为"SNI白名单"。

Questions:

为什么上面选择TLS1.3作例子而不是TLS1.2? 在这里为什么不启用TLS1.3的ECH特性?

Answers:

  1. TLS1.3很大程度上就是TLS1.2简化后的版本,它修复了前代协议设计中的诸多安全漏洞、设计得更加简洁好用,并且自第一个正式版本在2018年发布以来,其部署规模正在不断扩大。其次,XTLS实现下的REALITY服务器不支持REALITY客户端使用除TLS1.3以外的TLS版本连接以传输规避流量。

  2. 截至文章发布,XTLS实现下的REALITY默认并不启用TLS1.3的ECH特性。额,其实配置文件里根本没有相关选项。而且REALITY设计的核心就是向审查者(中间人)“表演"使用被允许的SNI的扩展的值的有效TLS握手,并通过由该握手打开的TLS通道传输规避流量。

🤔 我们来想想如何改变SNI

  1. 在规避工具的客户端向服务端发起TLS握手时,直接修改 TLS Client Hello 的SNI扩展的值。

    这种方法来得简单粗暴,但是很显然,它不具有良好的审查抗性并且会破坏TLS的认证机制。TLS握手时用于验证服务端身份的数字证书需要由受客户端信任的上级CA *(证书权威机构)*使用私钥进行签名,包括证书附属信息以及其中的域名信息,因此在没有由上级CA对修改后的证书进行签名的情况下,不可能修改来自其他网站的有效的证书同时保持其有效性。实际上,这种情况下即使是该证书的所有者也无法修改他所持有的有效数字证书同时保持其有效性。

    而 TLS Client Hello 的SNI扩展的值,必须与来自服务器的TLS Server Hello中的有效数字证书的扩展信息中的域名相符合。(不一定完全相同,用于TLS服务器身份验证的数字证书中有一类"泛域名证书”,这类证书能够验证某域名下的所有子域名的身份而无需为子域名逐个签发证书。)

    同理,审查者可以记录经过的TLS流量中 TLS Client Hello 的SNI扩展的值,使用该值构造 TLS Client Hello,并将其发往对应服务器,检查返回的 TLS Server Hello 中数字证书的有效性,以及证书扩展信息中的域名是否与原SNI值相符合,若任何一项不符合,即可认为原流量为规避流量。

  2. 从1.中我们了解到,如果要修改 TLS Client Hello 的SNI,或许需要连带修改数字证书。

    当然,这只是开个玩笑,数字签名的数学属性决定了当前算力条件下它不可能被篡改同时保持其有效性。

    诶,等等…我们在上一节中是不是提到了什么? 再看看那幅示意图…TLS1.3握手中的服务器的数字证书是在加密后被发送的,对吧? 因此,在1.中审查者无法被动观察和收集到到用户发起的TLS1.3握手中的数字证书,审查者必须使用原SNI的值构造 TLS Client Hello 以获得先前的数字证书,从而实现审查目的。虽然数字证书不能被篡改,但是如果我们能够区分规避客户端和审查者,是否可以向他们分别提供不同的数字证书呢?

 🔍 试试潜入REALITY的代码海洋?

在上一节中我们提出了"通过区分TLS客户端类型从而提供不同数字证书"的规避策略,这便是REALITY的设计关键之一。但是如何区分呢? 如何协商用于加密握手的密钥 preMasterKey 呢? 这一节我们试试深入REALITY的源代码,结合第一节的知识,抽丝剥茧地解析REALITY实现这一规避策略的技术细节。

请先别走! 不论你是编程新手、还是开发小白,这一节都尽可能以易懂的自然语言,向你仔细地解释这一节出现的所有源代码的作用。请始终记住: 看懂程序是看懂设计思路,不是看懂编程语言知识

这里出现的所有服务端源代码,以Github代码库 XTLS/REALITYmain 分支的 079d0bd commit版本为准; 这里出现的所有客户端源代码,以Github代码库 XTLS/Xray-coremain 分支的 4c9e4b9 commit版本为准。

💻客户端

由于Xray-core的设计是由客户端发起代理连接,所以这里先从客户端的reality1开始: (1包, package, 组织程序功能的一种单位,Go中同一目录 (不含子目录)下只能存在一个包)

Xray-core中负责封装和传输代理流量的各个包都放在transport/internet目录下,reality包也不例外:

1xray-core/
2|-- transport/
3| |-- internet/
4| | |-- reality/
5| | | |-- config.go
6| | | |-- config.pb.go
7| | | |-- config.proto
8| | | |-- reality.go

该目录下config.proto, config.pb.go, config.go三个文件分别起 定义传入该包的protobuf3字段格式、定义用于存储配置的 struct2 reality.Config 的公开方法3,4、将 reality.Config 的公开字段5填充完整 的主要作用。reality.Config 主要存储用于REALITY的配置。 (2 struct,结构体,Go中组织数据的一种单位,广义上的对象; 3方法, 指与特定struct绑定的用于读写/处理该struct所有字段的函数; 4公开方法,指可以被其它包调用的方法; 5公开字段,指可以被其它包访问和修改的字段)

发起REALITY连接的关键在 reality.go,其中 func6 UClient 在其他包发起REALITY连接时被调用,是该包的客户端部分最主要的功能性入口:

1// Xray-core/transport/internet/reality/reality.go#L106
2func UClient(c net.Conn, config *Config, ctx context.Context, dest net.Destination) (net.Conn, error)

(6 func, 函数, 实现特定细分功能的一种单位)

如上所示,func UClient接受来自net包的可读写的网络流接口 c, 存储REALITY配置的 config, 用于超时控制的通知管道 ctx, 指示目的地的结构体 dest。

func UClient的关键功能之一在于初始化了本包定义的 struct UConn:

 1// Xray-core/transport/internet/reality/reality.go#L64-L69
 2// UConn的字段定义
 3type UConn struct {
 4  *utls.UConn
 5  ServerName string
 6  AuthKey    []byte
 7  Verified   bool
 8}
 9
10// Xray-core/transport/internet/reality/reality.go#L108-L115
11// uConn赋值为UConn实例, utlsConfig赋值为utls.Config实例(utls包配置)
12uConn := &UConn{}
13utlsConfig := &utls.Config{
14  VerifyPeerCertificate:  uConn.VerifyPeerCertificate,
15  ServerName:             config.ServerName,
16  InsecureSkipVerify:     true,
17  SessionTicketsDisabled: true,
18  KeyLogWriter:           KeyLogWriterFromConfig(config),
19}
20
21// Xray-core/transport/internet/reality/reality.go#L124
22// 初始化utls.UClient并赋值给uConn的第一个字段
23uConn.UConn = utls.UClient(c, utlsConfig, *fingerprint)

UConn的第一个字段为匿名字段7,类型为来自utls8包的UConn实例的指针。这段代码就 Servername(SNI)、TLS Client Hello指纹识别对抗、用于验证服务器证书有效性和服务器身份的func VerifyPeerCertificate 等参数,对接下来用于发起和处理TLS握手的uConn进行了配置和初始化。

(7匿名字段,即默认其名称为对应类型名称的字段; 8 utls, go标准库"crypto/tls"的变种, 为反审查用途提供TLS Client Hello指纹识别对抗、对于TLS握手的完全访问,Fake Session Ticket等功能)

接下来到了关键的地方: REALITY客户端利用 TLS Client Hello 中的 Session ID 字段空间为客户端作隐蔽标记,以供服务器区分审查者与合法REALITY客户端。Session ID字段原本用于TLS1.2的0-RTT会话恢复机制,然而TLS1.3虽然切换到了基于PSK (Pre-shared Key)的会话恢复机制,但是为尽量保持对TLS1.2的兼容性,Session ID字段在TLS1.3被弃用的同时被保留了下来。因此每一个TLS1.3连接使用的Session ID都应当是随机生成的。 (这里非常感谢一位读者(要求不具名), 指正了先前编写时将 TLS1.2基于SessionID和Session Ticket的会话恢复机制 与 TLS1.3基于PSK的会话恢复机制 混淆的错误,望见谅。)

(Xray-core中还专门为接下来这一段客户端代码划定了块级作用域。)

 1// Xray-core/transport/internet/reality/reality.go#L126-L135
 2// 生成默认ClientHello并提供给uConn
 3// 在这一步也生成了客户端的TLS密钥对
 4uConn.BuildHandshakeState()
 5// 将生成的默认ClientHello的指针赋值给hello
 6// (将指针赋值给hello意味着修改hello就是修改原数据)
 7hello := uConn.HandshakeState.Hello
 8// 将32字节长的空切片(动态数组)赋值给SessionId
 9// 并填充到ClientHello的第40-71字节,占个位
10hello.SessionId = make([]byte, 32)
11copy(hello.Raw[39:], hello.SessionId)
12// 将Xray-core的版本号x.y.z填入SessionId第1-3字节
13hello.SessionId[0] = core.Version_x
14hello.SessionId[1] = core.Version_y
15hello.SessionId[2] = core.Version_z
16hello.SessionId[3] = 0 // 填充0到第4字节
17// 将当前Unix时间戳填充到SessionId第5-8字节
18binary.BigEndian.PutUint32(hello.SessionId[4:], uint32(time.Now().Unix()))
19// 从SessionId第9字节开始将shortId填充进去
20copy(hello.SessionId[8:], config.ShortId)
21
22// Xray-core/transport/internet/reality/reality.go#L139
23// 将REALITY公钥转换为可用的公钥对象
24publicKey, err := ecdh.X25519().NewPublicKey(config.PublicKey)
25
26// Xray-core/transport/internet/reality/reality.go#L143
27// 使用BuildHandshakeState()生成的客户端TLS密钥对中的
28// x25519私钥和REALITY公钥输入ECDH算法计算共享密钥。
29// 将该密钥输入HKDF(基于HMAC的密钥导出函数)计算preMasterKey。
30uConn.AuthKey, _ = uConn.HandshakeState.State13.EcdheKey.ECDH(publicKey)
31// Xray-core/transport/internet/reality/reality.go#L147-L149
32if _, err := hkdf.New(sha256.New, uConn.AuthKey, hello.Random[:20], []byte("REALITY")).Read(uConn.AuthKey); err != nil {
33  return nil, err
34}
35
36// Xray-core/transport/internet/reality/reality.go#L150-L156
37// 选择AEAD算法: AES-GCM或Chacha20-Poly1305。
38// AEAD算法保证密文数据的安全性、完整性以及抗重放,
39// 同时为附加数据提供完整性保证。
40var aead cipher.AEAD
41if aesgcmPreferred(hello.CipherSuites) {
42  block, _ := aes.NewCipher(uConn.AuthKey)
43  aead, _ = cipher.NewGCM(block)
44} else {
45  aead, _ = chacha20poly1305.New(uConn.AuthKey)
46}
47
48// Xray-core/transport/internet/reality/reality.go#L160-L161
49// 使用AEAD算法加密SessionId。
50// 这里使用preMasterKey作密钥,ClientHello作附加数据。
51// 注: hello.SessionId[:0]指复用其内存空间。
52aead.Seal(hello.SessionId[:0], hello.Random[20:], hello.SessionId[:16], hello.Raw)
53// 将最终的SessionId复制到ClientHello第40-72字节
54// copy()用于复制数据。用法:copy(目的地, 源)
55copy(hello.Raw[39:], hello.SessionId)

至此,REALITY客户端完成了对自身的隐蔽标记。接下来客户端向REALITY服务器发起TLS连接:

1if err := uConn.HandshakeContext(ctx); err != nil {
2  return nil, err
3}

🐧服务器

👆用这个emoji不是我偏爱Linux服务器, 主要是Windows Server的性能和运维体验太拉胯了

来到REALITY服务器的源代码,它实际上是Go 1.20标准库中crypto/tls包服务器部分的变种。由于REALITY服务器以最小修改原则对crypto/tls包作修改,因此目录中存在较多与REALITY协议不直接相关的文件,在此不列出目录树。

REALITY服务器处理TLS握手的关键是文件 tls.go 中的func Server。其实在仔细阅读过Xray-core中的客户端源代码过后,读者应该已对服务器区分合法REALITY客户端的关键逻辑有大概认识了。鉴于如此,接下来的源代码注释将比较简洁,望读者见谅。

那么REALITY服务器如何告知合法REALITY客户端可以进行传输? 服务器如何处理非法客户端(审查者)发起的握手? 为方便叙述,这里先下结论:

  1. REALITY服务器将ClientHello转发到持有有效证书的TLS服务器dest(伪装服务器), 对来自dest的ServerHello和Change Cipher Spec及其附加的加密信息作最小修改,再转发给REALITY客户端。这种做法能够完全以正常TLS服务器的方式完成TLS握手,避免产生服务端TLS指纹。

  2. REALITY服务器在修改Change Cipher Spec后附加的加密信息时, 将其中的所有数字证书替换为**“临时证书”** , 并修改"临时证书"的签名的值,以便REALITY客户端通过使用 preMasterKey 计算签名作比对,以此告知客户端可以进行传输。

  3. REALITY服务器对来自合法REALITY客户端以外的流量全部转发到dest。这种做法的好处同1.

  1// REALITY/blob/main/tls.go#L113
  2// 这里是函数定义,其中conn为服务器接受的TCP连接
  3func Server(ctx context.Context, conn net.Conn, config *Config) (*Conn, error)
  4
  5// REALITY/blob/main/tls.go#L119
  6// 向REALITY配置中的dest(服务器)打开TCP连接
  7target, err := config.DialContext(ctx, config.Type, config.Dest)
  8
  9// REALITY/blob/main/tls.go#L139-L156
 10// 初始化互斥锁(独占某个任务)
 11mutex := new(sync.Mutex)
 12
 13// 初始化handshake_server_tls13.go中定义的
 14// serverHandshakeStateTLS13实例以存储握手信息
 15hs := serverHandshakeStateTLS13{
 16  c: &Conn{
 17    conn: &MirrorConn{
 18      Mutex:  mutex,
 19      Conn:   conn,
 20      Target: target,
 21	},
 22	config: config,
 23  },
 24  ctx: context.Background(),
 25}
 26
 27// 标识客户端是否未完成握手就发送无关数据
 28copying := false
 29
 30// 等待组,使main协程能等待子协程运行结束
 31// 协程: Go语言中轻量化的线程
 32waitGroup := new(sync.WaitGroup)
 33waitGroup.Add(2) // 添加两个子协程
 34
 35// REALITY/blob/main/tls.go#L158-L223
 36// 运行用于区分客户端的协程
 37go func() {
 38  for {
 39    mutex.Lock() // 加锁,独占当前任务
 40    // 读取ClientHello, context.Background()生成空ctx占位
 41    hs.clientHello, err = hs.c.readClientHello(context.Background())
 42    // 判断1.客户端是否未完成握手就发送数据;2.读取报错;
 43    // 3.版本小于TLS1.3;4.ClientHello中的SNI不在配置中
 44    // 若任一条件符合结束循环(break不再进入下一次循环)
 45    // (Go使用无条件for表示无限循环)
 46    if copying || err != nil || hs.c.vers != VersionTLS13 || !config.ServerNames[hs.clientHello.serverName] {
 47      break
 48    }
 49    // for逐个循环获取客户端x25519公钥
 50    // TLS1.3 ClientHello包含尽可能多的
 51        // 适用于不同算法的公钥和数字证书,以避免
 52    // TLS1.2中询问受支持算法增加1次往返延迟
 53    for i, keyShare := range hs.clientHello.keyShares {
 54      // 判断密钥类型是否为x25519且长度等于32字节
 55      // 这是REALITY客户端使用的公钥长度和类型
 56      // 任一条件不符合则countinue进入下一次循环
 57      if keyShare.group != X25519 || len(keyShare.data) != 32 {
 58        continue
 59      }
 60      // 将REALITY私钥和客户端公钥输入ECDH算法计算共享密钥。
 61      if hs.c.AuthKey, err = curve25519.X25519(config.PrivateKey, keyShare.data); err != nil {
 62        break
 63      }
 64      // 将该密钥输入HKDF(基于HMAC的密钥导出函数)计算preMasterKey。
 65      if _, err = hkdf.New(sha256.New, hs.c.AuthKey, hs.clientHello.random[:20], []byte("REALITY")).Read(hs.c.AuthKey); err != nil {
 66        break
 67      }
 68      // 选择AEAD算法。
 69      var aead cipher.AEAD
 70      if aesgcmPreferred(hs.clientHello.cipherSuites) {
 71        block, _ := aes.NewCipher(hs.c.AuthKey)
 72        aead, _ = cipher.NewGCM(block)
 73      } else {
 74        aead, _ = chacha20poly1305.New(hs.c.AuthKey)
 75      }
 76      if config.Show {
 77        fmt.Printf("REALITY remoteAddr: %v\ths.c.AuthKey[:16]: %v\tAEAD: %T\n", remoteAddr, hs.c.AuthKey[:16], aead) //调试信息
 78      }
 79      // 初始化两个32字节长的切片
 80      // 分别用于存放密文和明文。
 81      ciphertext := make([]byte, 32)
 82      plainText := make([]byte, 32)
 83      copy(ciphertext, hs.clientHello.sessionId)
 84      copy(hs.clientHello.sessionId, plainText)
 85      // 使用AEAD算法解密SessionId。
 86      if _, err = aead.Open(plainText[:0], hs.clientHello.random[20:], ciphertext, hs.clientHello.raw); err != nil {
 87        break
 88      }
 89      // 解析SessionId包含的Xray-core版本,
 90      // Unix时间戳, ShortId
 91      copy(hs.clientHello.sessionId, ciphertext)
 92      copy(hs.c.ClientVer[:], plainText)
 93      hs.c.ClientTime = time.Unix(int64(binary.BigEndian.Uint32(plainText[4:])), 0)
 94      copy(hs.c.ClientShortId[:], plainText[8:])
 95      if config.Show {
 96        fmt.Printf("REALITY remoteAddr: %v\ths.c.ClientVer: %v\n", remoteAddr, hs.c.ClientVer)
 97        fmt.Printf("REALITY remoteAddr: %v\ths.c.ClientTime: %v\n", remoteAddr, hs.c.ClientTime)
 98        fmt.Printf("REALITY remoteAddr: %v\ths.c.ClientShortId: %v\n", remoteAddr, hs.c.ClientShortId) //调试信息
 99      }
100      // 判断Xray-core版本,时间延迟,ShortId是否允许
101      if (config.MinClientVer == nil || Value(hs.c.ClientVer[:]...) >= Value(config.MinClientVer...)) &&
102        (config.MaxClientVer == nil || Value(hs.c.ClientVer[:]...) <= Value(config.MaxClientVer...)) &&
103        (config.MaxTimeDiff == 0 || time.Since(hs.c.ClientTime).Abs() <= config.MaxTimeDiff) &&
104        (config.ShortIds[hs.c.ClientShortId]) {
105        hs.c.conn = conn
106      }
107      // 标识客户端使用的公钥类型,供接下来使用
108      hs.clientHello.keyShares[0].group = CurveID(i)
109      break
110    }
111    if config.Show {
112      fmt.Printf("REALITY remoteAddr: %v\ths.c.conn == conn: %v\n", remoteAddr, hs.c.conn == conn) //调试信息
113    }
114    break
115  }
116  // 解锁,取消独占
117  mutex.Unlock()
118  // 判断客户端是否未完成握手就发送无关数据
119  if hs.c.conn != conn {
120    if config.Show && hs.clientHello != nil {
121      fmt.Printf("REALITY remoteAddr: %v\tforwarded SNI: %v\n", remoteAddr, hs.clientHello.serverName) //调试信息
122    }
123    // 将连接转发给配置中的dest(即伪装服务器)
124    io.Copy(target, underlying)
125  }
126  // 通知等待组该协程运行完成
127  waitGroup.Done()
128}()

直到这里,服务器已经完成了区分客户端的任务。但是TLS握手可还没完成呢?

在下面这部分,REALITY服务器通过将ClientHello转发到dest,修改dest返回的ServerHello,将其中的所有数字证书替换为**“临时证书”** , 并修改"临时证书"的签名的值,再将修改后的ServerHello发回合法REALITY客户端,以此完成TLS握手并告知客户端可以传输规避流量。由于"临时证书"并没有由客户端的可信CA进行签名,REALITY客户端通过验证数字证书的签名的值来确认服务器身份。

  1// REALITY/blob/main/tls.go#L225-L349
  2// 运行进行REALITY服务器握手及与dest通讯的协程
  3go func() {
  4  // 初始化两个size = 8192长的切片
  5  // s2cSaved用于存放来自dest的所有已接收数据
  6  // buf是REALITY服务器到dest的缓冲区
  7  s2cSaved := make([]byte, 0, size)
  8  buf := make([]byte, size)
  9  handshakeLen := 0
 10f:
 11  for {
 12    // 临时让出CPU时间片,优化程序性能
 13    runtime.Gosched()
 14    // 将接收的来自dest的数据读取到buf(会清空buf)
 15    n, err := target.Read(buf)
 16    // 判断是否已收到来自dest的数据
 17    if n == 0 {
 18      if err != nil {
 19        conn.Close()
 20        waitGroup.Done()
 21        return
 22      }
 23      continue
 24    }
 25    // 加锁,独占当前任务
 26    mutex.Lock()
 27    // 将buf数据附加的s2cSaved已有数据后
 28    s2cSaved = append(s2cSaved, buf[:n]...)
 29    // 判断客户端是否未完成握手就发送无关数据
 30    if hs.c.conn != conn {
 31      // 标识客户端未完成握手就发送无关数据
 32      copying = true
 33      break
 34    }
 35    // 判断s2cSaved长度是否过大
 36    if len(s2cSaved) > size {
 37      break
 38    }
 39    // 遍历type判断dest返回的包的类型
 40    // REALITY/blob/main/tls.go#L91-L99
 41    // types = [7]string{
 42    // "Server Hello",
 43    // "Change Cipher Spec",
 44    // "Encrypted Extensions",
 45    // "Certificate",
 46    // "Certificate Verify",
 47    // "Finished",
 48    // "New Session Ticket",
 49    // }
 50    for i, t := range types {
 51      // 判断是否已发送过了ServerHello
 52      // 符合则进入下一次循环
 53      if hs.c.out.handshakeLen[i] != 0 {
 54        continue
 55      }
 56      // 判断本次循环到的类型是否为
 57      // "New Session Ticket"且s2cSaved长度为0
 58      if i == 6 && len(s2cSaved) == 0 {
 59        break
 60      }
 61      // handshakeLen记录来自dest的握手包长度
 62      // 这里判断其长度为0且来自dest的握手包长度大于5
 63      // REALITY/blob/main/common.go#L63
 64      // recordHeaderLen = 5
 65      if handshakeLen == 0 && len(s2cSaved) > recordHeaderLen {
 66        // [1:3]指向TLS握手包第2-3字节的版本信息
 67        // 出于兼容性原因,TLS1.3的ClientHello的第2-3字节
 68        // 的值与TLS1.2相同,1.3的版本标记存在于扩展当中
 69        // 这里判断来自dest的数据包是否为ServerHello
 70        // 或ChangeCipherSpec或ApplicationData,
 71        // 这三类数据包是握手中应该出现的
 72        if Value(s2cSaved[1:3]...) != VersionTLS12 ||
 73          (i == 0 && (recordType(s2cSaved[0]) != recordTypeHandshake || s2cSaved[5] != typeServerHello)) ||
 74          (i == 1 && (recordType(s2cSaved[0]) != recordTypeChangeCipherSpec || s2cSaved[5] != 1)) ||
 75          (i > 1 && recordType(s2cSaved[0]) != recordTypeApplicationData) {
 76          break f
 77        }
 78        // [3:5]指向TLS握手包第4-5字节的握手包长度信息
 79        handshakeLen = recordHeaderLen + Value(s2cSaved[3:5]...)
 80      }
 81      if config.Show {
 82        fmt.Printf("REALITY remoteAddr: %v\tlen(s2cSaved): %v\t%v: %v\n", remoteAddr, len(s2cSaved), t, handshakeLen) //调试信息
 83      }
 84      // 判断握手包是否过长
 85      if handshakeLen > size {
 86        break f
 87      }
 88      // 判断握手包类型是否为Change Cipher Spec
 89      if i == 1 && handshakeLen > 0 && handshakeLen != 6 {
 90        break f
 91      }
 92      // 判断握手包类型是否为Encrypted Extensions
 93      // 即附加在Change Cipher Spec之后的加密部分
 94      if i == 2 && handshakeLen > 512 {
 95        hs.c.out.handshakeLen[i] = handshakeLen
 96        hs.c.out.handshakeBuf = buf[:0]
 97        break
 98      }
 99      // 判断握手包类型是否为New Session Ticket
100      if i == 6 && handshakeLen > 0 {
101        hs.c.out.handshakeLen[i] = handshakeLen
102        break
103      }
104      // 判断握手包长度是否为0或大于s2cSaved长度
105      if handshakeLen == 0 || len(s2cSaved) < handshakeLen {
106        mutex.Unlock()
107        continue f
108      }
109      // 判断握手包类型是否为Server Hello
110      if i == 0 {
111        // 构造新Server Hello
112        hs.hello = new(serverHelloMsg)
113        // unmarshal用于将来自dest的ServerHello解析并填充到新构造的握手包
114        // 这里检查了unmarshal是否报错以及解析并填充后的握手包是否合法
115        if !hs.hello.unmarshal(s2cSaved[recordHeaderLen:handshakeLen]) ||
116          hs.hello.vers != VersionTLS12 || hs.hello.supportedVersion != VersionTLS13 ||
117          cipherSuiteTLS13ByID(hs.hello.cipherSuite) == nil ||
118          hs.hello.serverShare.group != X25519 || len(hs.hello.serverShare.data) != 32 {
119          break f
120        }
121      }
122      // 修改对应类型握手包的已发送长度
123      // 及重置s2cSaved和handshakeLen
124      hs.c.out.handshakeLen[i] = handshakeLen
125      s2cSaved = s2cSaved[handshakeLen:]
126      handshakeLen = 0
127    } // 遍历结束
128    //标记运行开始时间
129    start := time.Now()
130    // 与REALITY客户端进行握手
131    // 即发送修改后的Server Hello和
132    // Change Cipher Spec及Encrypted Extensions
133    err = hs.handshake()
134    if config.Show {
135      fmt.Printf("REALITY remoteAddr: %v\ths.handshake() err: %v\n", remoteAddr, err) //调试信息
136    }
137    if err != nil {
138      break
139    }
140    // 运行协程处理与dest的连接,此时REALITY握手已结束
141    // 与dest的连接已经不需要了.
142    // 使用协程的目的是避免阻塞与客户端的通信
143    go func() {
144      if handshakeLen-len(s2cSaved) > 0 {
145        io.ReadFull(target, buf[:handshakeLen-len(s2cSaved)])
146      }
147      if n, err := target.Read(buf); !hs.c.isHandshakeComplete.Load() {
148        if err != nil {
149          conn.Close()
150        }
151        if config.Show {
152          fmt.Printf("REALITY remoteAddr: %v\ttime.Since(start): %v\tn: %v\terr: %v\n", remoteAddr, time.Since(start), n, err)
153        }
154      }
155    }()
156    // 等待客户端返回TLS Finished信息
157    err = hs.readClientFinished()
158    if config.Show {
159      fmt.Printf("REALITY remoteAddr: %v\ths.readClientFinished() err: %v\n", remoteAddr, err)
160    }
161    if err != nil {
162      break
163    }
164    hs.c.isHandshakeComplete.Store(true)
165    break
166  }
167  // 解锁,取消独占
168  mutex.Unlock()
169  // 判断REALITY服务器是否未发送ServerHello.
170  // 这种情况通常在dest返回无效ServerHello或
171  // 在dest返回ServerHello前运行至此会触发
172  if hs.c.out.handshakeLen[0] == 0 {
173    // 判断dest是否没有正确处理ClientHello
174    if hs.c.conn == conn {
175      waitGroup.Add(1)
176      go func() {
177        // 将流量转发给dest
178        io.Copy(target, underlying)
179        waitGroup.Done()
180      }()
181    }
182    // 将客户端连接内容写入s2cSaved
183    conn.Write(s2cSaved)
184    // 将流量转发给dest
185    io.Copy(underlying, target)
186    // 当io.Copy()返回时意味着dest已关闭连接
187    // 此时也应关闭到客户端的转发通道
188    underlying.CloseWrite()
189  }
190  // 通知等待组该协程运行完成
191  waitGroup.Done()
192}()

接下来服务器随机生成一份 ed25519格式 的 “临时证书” (实际上在 reality 包被导入时就完成了),并将数字证书的签名部分替换为 使用 preMasterKey 为密钥,将"临时可信证书"的公钥将输入HMAC算法 得到的值。

 1// REALITY/blob/main/handshake_server_tls13.go#L55-L59
 2// func init在所处的包被导入时执行
 3func init() {
 4    // 定义x509证书模板
 5  certificate := x509.Certificate{SerialNumber: &big.Int{}}
 6    // 生成64字节长的ed25519私钥
 7    // _ 表示忽略并丢弃值
 8  _, ed25519Priv, _ = ed25519.GenerateKey(rand.Reader)
 9    // 以前面的模板生成x509临时证书
10    // 不知道为什么REALITY服务器截取
11  // 私钥ed25519Priv的第33-64字节
12  // 作临时证书的公钥
13  signedCert, _ = x509.CreateCertificate(rand.Reader, &certificate, &certificate, ed25519.PublicKey(ed25519Priv[32:]), ed25519Priv)
14}
15
16// REALITY/blob/main/handshake_server_tls13.go#L74-L85
17// (再次)计算preMasterKey
18// 这次计算得到的密钥值
19// 赋值给了hs.sharedKey
20{
21  hs.suite = cipherSuiteTLS13ByID(hs.hello.cipherSuite)
22  c.cipherSuite = hs.suite.id
23  hs.transcript = hs.suite.hash.New()
24  
25  key, _ := generateECDHEKey(c.config.rand(), X25519)
26  copy(hs.hello.serverShare.data, key.PublicKey().Bytes())
27  peerKey, _ := key.Curve().NewPublicKey(hs.clientHello.keyShares[hs.clientHello.keyShares[0].group].data)
28  hs.sharedKey, _ = key.ECDH(peerKey)
29
30  c.serverName = hs.clientHello.serverName
31}
32
33// REALITY/blob/main/handshake_server_tls13.go#L94-L106
34// 修改临时证书的签名的值
35{
36  // 将临时证书存入证书数组
37  signedCert := append([]byte{}, signedCert...)
38
39  // 初始化hmac计算对象h
40  // 使用preMasterKey作密钥
41  h := hmac.New(sha512.New, c.AuthKey)
42  // 将ed25519私钥第33-64字节写入h的缓冲区
43  h.Write(ed25519Priv[32:])
44  // 计算缓冲区数据的hmac值并将其
45  // 从临时证书的第65字节开始写入
46  h.Sum(signedCert[:len(signedCert)-64])
47
48  // 构造完整的证书对象
49  hs.cert = &Certificate{
50    Certificate: [][]byte{signedCert},
51    PrivateKey:  ed25519Priv,
52  }
53  // 标识签名算法为ed25519
54  hs.sigAlg = Ed25519
55}

至此,REALITY服务器完成了与客户端的握手。下面是一小段最终的处理代码:

 1// REALITY/blob/main/tls.go#L351-L360
 2// 阻塞直到等待组中所有协程运行完成
 3waitGroup.Wait()
 4// 关闭与dest的连接
 5target.Close()
 6if config.Show {
 7  fmt.Printf("REALITY remoteAddr: %v\ths.c.handshakeStatus: %v\n", remoteAddr, hs.c.isHandshakeComplete.Load()) //调试信息
 8}
 9// 判断与REALITY客户端握手是否结束
10if hs.c.isHandshakeComplete.Load() {
11  // 将连接返回给调用方
12  return hs.c, nil
13}
14// 若与REALITY客户端的握手未正常结束
15// 则关闭与客户端的连接
16conn.Close()
17// 将错误返回给调用方
18return nil, errors.New("REALITY: processed invalid connection")

在REALITY服务器返回连接后,调用方(通常是上层的代理协议,如VLESS)即可通过reality包提供的与crypto/tls相同的公开API传输规避流量。接下来的流量传输与crypto/tls的行为完全一致。

🔐验证服务端身份

通常情况下,REALITY客户端在验证服务器身份后,再发送 TLS Finished,从而结束与REALITY服务器的TLS握手,并开始规避流量的传输。这里把客户端验证服务器证书的签名的值的关键逻辑作一些简短解释。

 1// Xray-core/transport/internet/reality/reality.go#L82-L104
 2func (c *UConn) VerifyPeerCertificate(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
 3  // 由于utls包不提供对证书的底层访问和修改
 4  // 这里利用Go中的reflect包获取证书数组的内存地址
 5  // 并通过操作指针和类型断言将原数据转换为Go数组
 6  p, _ := reflect.TypeOf(c.Conn).Elem().FieldByName("peerCertificates")
 7  certs := *(*([]*x509.Certificate))(unsafe.Pointer(uintptr(unsafe.Pointer(c.Conn)) + p.Offset))
 8  // 将证书数组中的第一份证书中的公钥转换为
 9  // ed25519.PublicKey类型
10  // 通过检查是否报错来确认公钥类型
11  if pub, ok := certs[0].PublicKey.(ed25519.PublicKey); ok {
12    // 初始化hmac计算对象h
13    // 使用preMasterKey作密钥
14    h := hmac.New(sha512.New, c.AuthKey)
15    // 将证书公钥写入h的缓冲区
16    h.Write(pub)
17    // h.Sum计算并返回缓冲区数据的hmac值
18    // 判断hmac值是否与证书签名完全一致
19    if bytes.Equal(h.Sum(nil), certs[0].Signature) {
20      // 标识REALITY服务器身份验证通过
21      c.Verified = true
22      return nil
23    }
24  }
25  ... // crypto/tls包原有的验证逻辑,此处略去
26}

🚀结语

在这篇文章里,我们共同了解了未启用ECH的TLS1.3协议的正常握手过程。以此为基础,我们通过深入REALITY客户端和服务端的源代码进行解析,洞悉了REALITY协议规避基于SNI的审查策略的具体实现。

这篇文章自从我7月30日建立博客并发布文章后就一直在马不停蹄地编写,今天发布正好一个星期。我希望能够在不失严谨性的同时,尽力以最易懂的方式为读者提供对于REALITY协议的全面认识,愿正在阅读的你能够从中有所收获。

最后,感谢XTLS/Xray-core的创建者RPRX,以及ProjectX (XTLS)下的所有项目的贡献者。 由于ProjectX下项目较多,这里仅列出Xray-core的贡献者:

(使用 contrib.rocks 制作. 中国大陆访问可能较慢.)

(推荐相关阅读 : )

  1. A Detailed Look at RFC 8446 (a.k.a. TLS 1.3) - Cloudflare Blog
  2. Good-bye ESNI, hello ECH! - Cloudflare Blog