本文来自 @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_retrySSL_ERROR_PENDING_CERTIFICATE 的行为、消息回调、内部的 key_share 处理等),都是某个特定 commit 上的行为,换一个版本随时可能变。所以任何基于 BoringSSL 的改动,唯一稳妥的做法是 pin 到一个确切的 commit / tag,并在升级时重新验证每一处 hook。

本文验证时用的版本:

  • 服务端一侧,BoringSSL BCR tag 0.20260413.0
    (sha256 3560f7dd3f08e16b9f84d877a5be21ec62071564783009571af5fcc6fad734d2
  • 客户端一侧(随 Chromium 网络栈),boringssl commit 65818adf16411ca394625f5747a1af28faf95d2c

换到别的版本,上面的接缝行为需要重新核对,patch 也可能需要重新适配。


二、三个接缝

所有服务端改动都在一个安装好的回调和几个受门控的分支后面,没有重写任何一个握手 flight。

接缝一:证书选择回调

SSL_CTX_set_select_certificate_cbSSL_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
2
// 返回 true 并把 out/out_len 设为要发出的镜像 ServerHello 字节。
bool (*reality_serverhello_cb)(SSL *ssl, const uint8_t **out, size_t *out_len);

它存在时,服务端做两件事。一是采用镜像的 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
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
#include <openssl/ssl.h>
#include <openssl/err.h>
#include <openssl/bio.h>
#include <cstdio>
#include <cstring>

static int g_call_count = 0;
static bool g_resumed = false;

// 证书选择回调:在 ClientHello 处理最开始触发,早于任何响应发出。
// SSL_CLIENT_HELLO 原生地暴露了认证所需的一切,不需要 patch。
static enum ssl_select_cert_result_t reality_select_cb(const SSL_CLIENT_HELLO *ch) {
g_call_count++;
// 认证需要的数据在此处都可达(这里只打印;真实认证是 X25519/HKDF/AES-GCM):
// ch->client_hello_len : 原始 ClientHello 字节,作为 AEAD 的 AAD
// ch->session_id_len : 加密的认证信息(版本/时间/shortId)藏在这里
// ch->random : HKDF salt(random[:20]) + AEAD nonce(random[20:])
// key_share 扩展(id=51): 客户端 X25519 公钥,用于 ECDH
const uint8_t *ext; size_t extlen;
SSL_early_callback_ctx_extension_get(ch, 51, &ext, &extlen);
fprintf(stderr, "[select_cb] #%d ch_len=%zu sid=%zu key_share=%zu\n",
g_call_count, ch->client_hello_len, ch->session_id_len, extlen);

if (!g_resumed) {
// 第一次:挂起握手,worker 线程完全释放。
// SSL_do_handshake 返回 -1,SSL_get_error => SSL_ERROR_PENDING_CERTIFICATE。
fprintf(stderr, " -> ssl_select_cert_retry [SUSPEND]\n");
return ssl_select_cert_retry;
}
// 第二次(异步工作完成后):恢复。
fprintf(stderr, " -> ssl_select_cert_success [RESUME]\n");
return ssl_select_cert_success;
}

// 跑一步握手,把输出 BIO 排给对端输入 BIO,返回 SSL_get_error。
static int step(SSL *s, BIO *out, BIO *peer_in) {
int e = SSL_get_error(s, SSL_do_handshake(s));
char buf[16384]; int n;
while ((n = BIO_read(out, buf, sizeof(buf))) > 0) BIO_write(peer_in, buf, n);
return e;
}

int main() {
SSL_library_init();
SSL_CTX *sctx = SSL_CTX_new(TLS_server_method());
SSL_CTX *cctx = SSL_CTX_new(TLS_client_method());
SSL_CTX_set_select_certificate_cb(sctx, reality_select_cb);
SSL_CTX_use_certificate_chain_file(sctx, "cert.pem");
SSL_CTX_use_PrivateKey_file(sctx, "key.pem", SSL_FILETYPE_PEM);
SSL_CTX_set_min_proto_version(sctx, TLS1_3_VERSION); // REALITY 仅 TLS 1.3
SSL_CTX_set_min_proto_version(cctx, TLS1_3_VERSION);
SSL_CTX_set_verify(cctx, SSL_VERIFY_NONE, nullptr); // PoC 只测挂起点

SSL *client = SSL_new(cctx), *server = SSL_new(sctx);
SSL_set_tlsext_host_name(client, "reality.test");
BIO *c_in=BIO_new(BIO_s_mem()), *c_out=BIO_new(BIO_s_mem());
BIO *s_in=BIO_new(BIO_s_mem()), *s_out=BIO_new(BIO_s_mem());
SSL_set_bio(client,c_in,c_out); SSL_set_bio(server,s_in,s_out);
SSL_set_connect_state(client); SSL_set_accept_state(server);

step(client, c_out, s_in); // 1. client 发 ClientHello
int e = step(server, s_out, c_in); // 2. server 挂起
if (e != SSL_ERROR_PENDING_CERTIFICATE) return 1; // err=12
char tmp[16];
if (BIO_read(s_out, tmp, sizeof(tmp)) > 0) return 1; // 挂起期间必须 0 字节输出

g_resumed = true; // 3. 模拟异步工作完成
for (int i = 0; i < 12; i++) { // 恢复,跑完握手
step(server, s_out, c_in); step(client, c_out, s_in);
if (SSL_is_init_finished(client) && SSL_is_init_finished(server)) break;
}
fprintf(stderr, "%s\n", (SSL_is_init_finished(client) && SSL_is_init_finished(server))
? "PASS: suspend-resume closed loop works" : "FAIL");
return 0;
}

运行输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
=== Phase 1: client -> ClientHello ===
[client] step => err=2 (WANT_READ)

=== Phase 2: server SUSPENDS (async point) ===
[select_cb] #1 ch_len=191 sid=32 key_share=38
-> ssl_select_cert_retry [SUSPEND]
[server] step => err=12 (SSL_ERROR_PENDING_CERTIFICATE)
[server] correctly emitted nothing while suspended

=== Phase 3: async work done -> RESUME ===
[select_cb] #2 ch_len=191 sid=32 key_share=38
-> ssl_select_cert_success [RESUME]
[server] step #0 => err=2
[client] step #0 => err=0 DONE
[server] step #1 => err=0 DONE

=== RESULT ===
client init done: YES server init done: YES
select_cb called: 2 time(s) negotiated TLS: TLSv1.3
PASS: suspend-resume closed loop works

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
2
3
4
5
6
7
8
9
10
11
// 装在 mirror-client 上:截获 mirror server 的 ServerHello。
static std::vector<uint8_t> g_live_mirror_sh;
static void mirror_msg_cb(int write_p, int, int content_type,
const void *buf, size_t len, SSL *, void *) {
// 入站(write_p==0) 的握手(content_type==22),首字节是 ServerHello(type==2)
const uint8_t *p = static_cast<const uint8_t *>(buf);
if (write_p == 0 && content_type == SSL3_RT_HANDSHAKE &&
len >= 1 && p[0] == SSL3_MT_SERVER_HELLO && g_live_mirror_sh.empty()) {
g_live_mirror_sh.assign(p, p + len); // 要注入给真实客户端的实时 ServerHello
}
}

整个流程:

1
2
3
4
5
6
7
8
1. client 发 ClientHello -> reality-server
2. reality-server 的 select_cb 返回 ssl_select_cert_retry,握手挂起,0 字节发出
3. [挂起窗口] reality-server 作为 TLS 客户端连 mirror-server,完成一次握手,
经 SSL_set_msg_callback 抓下 mirror-server 的 ServerHello 字节
4. reality-server 恢复。发 ServerHello 时调用 reality_serverhello_cb,
返回刚抓的 ServerHello(session_id 换成此 client 的);
patch 把其中 key_share 密文换成自己的,并沿用镜像的 random。
5. client 对着 reality-server 走完握手,数据流通。

运行输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
=== Phase 2: reality-server SUSPENDS ===
[reality-server] select_cb: SUSPEND (sid=32B)
suspended cleanly (0 bytes emitted)
=== Phase 3: [async window] dial mirror, capture LIVE ServerHello ===
[mirror-client] captured LIVE ServerHello: 122 bytes
=== Phase 4: RESUME reality handshake with live mirror ===
[reality_cb] returning LIVE mirror SH: 122 bytes
=== Phase 5: verify ===
reality handshake complete: YES
negotiated: TLSv1.3 cipher=0x1301
data transfer: OK (got 29 bytes)

PASS: realtime mirror handshake works (live-captured ServerHello injected,
no BAD_DECRYPT, data flows)

握手完成、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。最后它落成的是两百来行可以审计的改动。