在 BoringSSL 的接缝中实现 REALITY
本文来自 @wangran10 的投稿
REALITY 通常被认为是 Go 的东西(XTLS/REALITY 加 uTLS)。这篇文章讨论另一种可行性:直接在 BoringSSL 上实现它,包括对真实站点 ServerHello 的每连接实时镜像,以及认证失败时的透明回落。文中给出机制、接口签名,以及可以自己编译运行的 PoC 及其输出。
本文只讨论技术可行性,给出让机制成立的最小证据。
一、为什么考虑 BoringSSL
REALITY 想解决的问题是:在主动探测下,让代理服务器与一个普通 HTTPS 站点不可区分。相对 Go/uTLS 的路线,放在 BoringSSL 上有几个考量。
一是指纹。客户端如果复用 Chromium 的网络栈,ClientHello 天然就是 Chrome 的形状——GREASE、ALPN、后量子 key_share、GREASE ECH 都是现成的,不用像 uTLS 那样去逼近一个目标指纹。
二是改动的可审计性。把功能压缩成对一个以严格著称的库的少量 hook,比在标准库里另起炉灶更容易被人从头看一遍、确认没碰到危险的地方。
三是一个有点反直觉的点:BoringSSL 越严格,越容易干净地嫁接这类功能。它不太允许你绕过它做事,所以最后能留下的改动,基本都是走它本来就留好的扩展点的。结果是一个足够小、能一次看完的 patch。
作为参考,服务端一侧对 BoringSSL 的改动大约是 +210 / −45 行,涉及三个文件;客户端一侧十余处,全部门控在一个开关后,关掉时行为与上游逐字节一致。握手主状态机没有改动。
前提:BoringSSL 不保证任何稳定性
在往下讲之前必须强调一点。Google 明确声明 BoringSSL 不提供任何 C API 或 C ABI 的稳定性保证——它是为 Google 自己的项目服务的,随时可能改接口、改行为、改符号,不做版本兼容承诺,也不希望外部把它当作一个有稳定 API 的库来依赖(官方措辞大意是:不建议第三方依赖它,不保证 API/ABI 稳定,需要稳定 TLS 库的话请用别的)。
这直接影响本文所有内容:下面用到的那些接缝(证书选择回调的语义、ssl_select_cert_retry 到 SSL_ERROR_PENDING_CERTIFICATE 的行为、消息回调、内部的 key_share 处理等),都是某个特定 commit 上的行为,换一个版本随时可能变。所以任何基于 BoringSSL 的改动,唯一稳妥的做法是 pin 到一个确切的 commit / tag,并在升级时重新验证每一处 hook。
本文验证时用的版本:
- 服务端一侧,BoringSSL BCR tag
0.20260413.0
(sha2563560f7dd3f08e16b9f84d877a5be21ec62071564783009571af5fcc6fad734d2) - 客户端一侧(随 Chromium 网络栈),boringssl commit
65818adf16411ca394625f5747a1af28faf95d2c
换到别的版本,上面的接缝行为需要重新核对,patch 也可能需要重新适配。
二、三个接缝
所有服务端改动都在一个安装好的回调和几个受门控的分支后面,没有重写任何一个握手 flight。
接缝一:证书选择回调
SSL_CTX_set_select_certificate_cb 在 SSL_do_handshake 内部触发,时点是 ClientHello 已经解析完、ServerHello 还没生成。REALITY 的 session-id 认证(X25519 → HKDF → AES-GCM)就放在这里。这本来是库给的“看一眼 ClientHello、选个证书”的钩子,往里塞认证逻辑是顺理成章的。
接缝二:挂起与恢复
同一个回调可以返回 ssl_select_cert_retry。之后 BoringSSL 会给出 SSL_ERROR_PENDING_CERTIFICATE,握手在此挂起——不发任何字节,状态机冻结,worker 线程可以释放。
这个机制设计出来是给云网关用的:证书还没准备好(要去异步查、问 HSM、做 SNI 路由),先把握手停住,别占着线程。但它的本质能力跟“证书”没什么关系——它其实是一个通用的“在握手的某个确定时点合法暂停整个状态机、去做任意异步 I/O、然后接着往下走”的原语。
我们可以用这个原语去做一件设计者大概没预想的事:在挂起窗口里连一次真实的镜像目标,抓下它此刻返回的 ServerHello,注入,再恢复握手。状态机没有被动过,变的只是挂起期间做了什么。
这一步是整件事里比较关键的地方。它不是“找到一个异步 API 来调”,而是意识到一个为特定用途设计的扩展点,其本质是一个更通用的暂停/恢复能力,于是把它挪作他用。
接缝三:ServerHello 镜像回调
一个安装上去的全局回调,签名大致是:
1 | // 返回 true 并把 out/out_len 设为要发出的镜像 ServerHello 字节。 |
它存在时,服务端做两件事。一是采用镜像的 cipher suite,但先确认客户端确实在 ClientHello 里提供了这个套件(检查 client_hello.cipher_suites)。二是发出镜像的 ServerHello,只把其中 key_share 扩展的密文换成服务端自己的(reality_replace_key_share),这样 ECDHE 是真实的,其余部分逐字节看起来就是被镜像那个站点的样子。
三、三层信任
REALITY 只动了三个正交层里的一层。理清这三层,也就理清了“到底改了多少”。
| 层 | 用途 | 状态 |
|---|---|---|
| session-id:X25519 + HKDF + AES-GCM | 真正的对端认证 | 新增(信任根) |
| Ed25519 CertificateVerify 签名 | transcript 完整性 | 保留并验证 |
| sigalg 通告检查 | 协商形式 | 放宽 |
REALITY 在 CertificateVerify 里用了一个客户端没有通告的 Ed25519 签名算法,这在字面上违反 RFC 8446 §4.4.3 的 MUST。但要分清楚:违反的是“MUST be one offered in signature_algorithms”这半句,也就是准入;不是“MUST verify”那半句,也就是验证。Ed25519 签名本身照常做密码学验证,握手 transcript 的完整性保护没有被削弱。
对比 XTLS/REALITY 常见的 InsecureSkipVerify(客户端整个跳过 CertificateVerify 验证):那短路的是“对端身份必须被验证”这个核心不变量;而这里放宽的只是“sigalg 必须被通告”这个协商形式约束。按削弱的安全保证来称,后者的偏离要小得多。
之所以能这么做,是因为 REALITY 的信任根本不来自证书链,而来自 session-id 的 X25519/HKDF/AEAD。Ed25519 临时证书和 CertificateVerify 只是为了让握手在结构上像一次正常的 TLS 握手,把指纹补全。
澄清:sigalg 放宽不是必需的
需要说明:上面那个违反 RFC §4.4.3 的通告放宽,并不是“实现 REALITY”所必需的,它是“沿用 Ed25519 临时证书”这个选择带来的。
道理很直接:证书链在 REALITY 里只是仪式,信任由 session-id 承担,那么服务端用什么算法签 CertificateVerify 是自由的。
沿用 Ed25519,问题在于客户端(比如 Chrome)的 ClientHello 通常不通告 ed25519(0x0807),于是要让握手成立,要么客户端跳过验证,要么放宽 sigalg 准入——通告放宽就是从这来的。
另一种同样成立的做法,是让服务端只用客户端已经通告过的算法来签,比如 ECDSA-P256(0x0403)或 RSA-PSS,这些真 Chrome 一定会通告。这样完全不需要任何 carve-out,不违反 §4.4.3,改动还更小,临时证书用 ECDSA/RSA 就行。
所以这是个取舍,不是技术上的必需:
| 做法 | RFC 合规 | 改动 | 代价 |
|---|---|---|---|
| Ed25519 + 通告放宽 | 违反 §4.4.3 准入半句 | 多两处 carve-out | 与既有 Ed25519 形态一致 |
| 只签客户端已通告的算法 | 合规 | 更小 | 与 Ed25519 形态有细微差别 |
说明这一点,是为了不让人误以为“REALITY 一定违反 RFC”。它不一定。
四、PoC
下面两个 PoC 都是自包含的,客户端和服务端在同一进程内走 memory BIO,可以直接编译运行。它们只演示机制,不含认证的实现——认证的落点在代码里用注释标出。
4.1 挂起原语
先证明接缝二成立:证书选择回调返回 ssl_select_cert_retry 之后,握手确实被挂起、期间不泄露字节、并且能恢复。
1 |
|
运行输出:
1 | === Phase 1: client -> ClientHello === |
err=12 就是 SSL_ERROR_PENDING_CERTIFICATE。握手在证书选择点被挂起,期间一个字节都没发出;异步工作完成后回调第二次被调用、返回 success,握手接着走完。接缝二的原语成立。
4.2 在挂起窗口里抓 ServerHello
有了挂起原语,就可以在挂起窗口里连镜像目标、抓它此刻的 ServerHello、注入。这个 PoC 用同进程三方(client、reality-server、mirror-server,都走 memory BIO)来演示。
抓包用的是公开 API SSL_set_msg_callback:reality-server 作为 TLS 客户端连 mirror-server 时,把收到的 ServerHello 原样截下来。
1 | // 装在 mirror-client 上:截获 mirror server 的 ServerHello。 |
整个流程:
1 | 1. client 发 ClientHello -> reality-server |
运行输出:
1 | === Phase 2: reality-server SUSPENDS === |
握手完成、TLS 1.3、没有 BAD_DECRYPT、数据传输正常,ASan/UBSan 也是干净的。从挂起原语到实时镜像的链路走通了。
这里抓到的是 122 字节,因为这个 PoC 的 mirror-server 用的是普通 X25519。如果镜像目标协商的是后量子混合组,情况会不同——见下节。
4.3 组必须匹配
镜像必须用客户端-面向握手会协商的那个 key_share 组去连,也就是客户端第一个提供的、双方都支持的组。
- 如果客户端首选
X25519MLKEM768(0x11ec,后量子混合),镜像也必须用这个组去连,抓回来的是约 1210 字节的 ServerHello(含 MLKEM768 的 1184 字节 key_share)。 - 如果用普通
X25519去连,抓回来的是约 122 字节的 ServerHello。
两者尺寸和类型都不同。把一个 122 字节的 X25519 ServerHello 注入一个协商了 MLKEM768 的握手,BoringSSL 会拒绝。
处理办法是把两件事分开:用于连镜像的组,必须是客户端首个提供的组;而用于 ECDH 认证的公钥提取,则可以偏好任一 X25519 值。这两个是不同的东西,之前容易混在一起。
五、放到一个 event-driven 框架里
上面的 PoC 是同进程手动 step。放到一个 event-driven 的服务端框架里,思路是复用框架原生的异步握手通道:证书选择回调返回 retry,框架把这条连接标记为“阻塞在异步操作上”并保持打开、释放 worker;在连接自己的事件循环上异步去连镜像(非阻塞 socket、每 worker 一个 DNS resolver);抓到 ServerHello 后调用框架的“异步证书选择完成”回调,触发 resume。整个过程不改 BoringSSL 状态机,也不改框架的握手核心——它本来就为这种异步证书场景留了通道。
认证失败时的透明回落
要在主动探测下站得住,还有另一半:认证没通过的连接(探测者、扫描器、误连的浏览器)应该被透明地转到真实站点,让它们看到真站的证书和页面,而不是被拒绝。
一个直接但错误的做法,是在证书选择回调里认证失败就返回错误——那会让 BoringSSL 发一个 alert 中止握手,对面立刻看到“握手失败”这个可识别的异常,恰恰是要避免的。
更合适的做法是把认证的决策提前到 TLS 之前的 L4 层:一个监听层过滤器用 MSG_PEEK 窥探 ClientHello(不消费),用一个只用于解析的 BoringSSL 复用同一套认证逻辑做判定,把结果写进连接的元数据,再由 filter chain 的匹配把连接分流——认证通过的走 REALITY TLS,其余的走透明 TCP 代理到真实目标。因为 ClientHello 是 peek 而非 drain,两条路都能重新读到完整的原始字节。
这一段的关键点是:REALITY 的认证逻辑本身是无状态的纯函数(输入是解析好的 ClientHello 加配置里的密钥,输出是一个布尔),所以它既能在 BoringSSL 内部的证书选择回调里跑,也能在 L4 层用一个只解析的 BoringSSL 实例跑,两处共用一份实现。
六、为什么改动能这么小
因为踩的是 BoringSSL 原生的扩展点——证书选择回调、异步 pending-certificate 的挂起/恢复、消息回调——而不是重写流程。握手状态机没变,功能关掉时库的行为和上游逐字节一致。而且有几处改动是放宽一个检查,属于减法,不是加法。
一个对严格 TLS 库的 diff 只有两百来行、能半小时看完的协议级改动,比大规模重写更容易让人信任。这件事的重心其实不在那两百行代码,而在于弄清楚 BoringSSL 哪里可以挂钩、以及一个为特定用途设计的扩展点其本质能力是什么。
七、边界
RFC 合规上,如前所述,字面上违反 §4.4.3 的准入半句;不过这只发生在认证成功之后,对外部观察者不可见,且发生在受控的两端之间,而这一放宽本身也不是必需的(见第三节)。
TLS-in-TLS:用一条 TLS 隧道去访问 HTTPS 站点,隧道内部会出现内层 TLS 握手的包长模式。这是所有 TLS 隧道代理的共性弱信号,不是这套做法特有的,且会被 padding、每目标独立连接、多流交织等因素稀释;单独用它来定位代理,误报很高。
时序与镜像目标选择:探测者可以对比“连你”和“直连镜像目标”的握手时延。所以镜像目标要在网络上离服务器足够近(server 到目标的往返尽量小),理想情况下目标和服务器在探测者视角下的距离也相近。这也是找邻居这类工具存在的原因。大 CDN 边缘密集,从多数位置都近。另外选目标时要确认它支持客户端会协商的 key_share 组(比如 X25519MLKEM768),否则会回到第 4.3 节那个组不匹配的问题。
八、小结
在 BoringSSL 上实现 REALITY,本质是顺着库留好的接缝进去,而不是逆着它的设计切开。里面比较关键的一步,是把一个为异步查证书设计的挂起原语,重新理解成一个通用的握手暂停/恢复能力,从而在挂起窗口里抓取镜像的 ServerHello。最后它落成的是两百来行可以审计的改动。
