宿舍路由器的代理突然挂了,实则是 Go TLS 库的破坏性更新引发的连锁反应。
环境
| 组件 | 版本/信息 |
|---|---|
| 路由器系统 | ImmortalWRT |
| 代理前端 | homeproxy (luci-app-homeproxy) |
| 代理核心 | sing-box 1.12.25 |
| Go 版本 | 1.26.3 |
| 节点协议 | Hysteria2 (QUIC) |
故障现象
所有通过代理的流量不通。DNS 解析失败,网页打不开,但路由器本身网络正常。
排查过程
第一层:进程与连通性
先确认基本状态:
| |
网络层没问题,问题在应用层。
第二层:日志
直接看 sing-box 日志:
| |
满屏同一个错误:
CRYPTO_ERROR 0x12a (local): tls: failed to verify certificate:
x509: certificate relies on legacy Common Name field, use SANs instead
根因定位到了——TLS 握手阶段证书验证失败。
第三层:根因分析
这个错误的信息量其实足够了:
- CRYPTO_ERROR 0x12a:QUIC 加密层错误
- x509: certificate relies on legacy Common Name field, use SANs instead:Go 的 x509 库拒绝验证该证书
要理解这个错误为什么会导致整个连接失败,得先退一步看看 TLS 在做什么。
TLS 是什么
TLS(Transport Layer Security)是互联网上最核心的安全协议。每当你在浏览器里看到地址栏左侧的小锁图标,背后运行的就是 TLS。它建立在 TCP 传输层之上,为上层应用协议(HTTP、SMTP、IMAP 等)提供安全的通信信道。
TLS 提供三个核心安全保障:
| 保障 | 含义 | 实现手段 |
|---|---|---|
| 认证 (Authentication) | 确认通信对方的身份——你确实在和 example.com 通话,而不是某个冒充者 | 证书链 + 数字签名 |
| 机密性 (Confidentiality) | 传输内容无法被第三方窃听 | 对称加密 |
| 完整性 (Integrity) | 传输内容无法被第三方篡改 | MAC / AEAD |
这三者缺一不可。没有认证,加密就没有意义——你可能在和攻击者建立一个"安全"通道,把你所有的数据加密后发给一个假服务器。这个问题出在认证环节。
TLS 握手
每次 TLS 连接的第一步是握手(Handshake)。握手的目的是让客户端和服务端在不安全的网络上协商出一套共享的加密密钥,同时验证彼此身份。以 TLS 1.3 为例(TLS 1.3 在 2018 年标准化,大幅精简了握手的往返次数和过时算法),一次完整的握手过程如下:
Client Server
ClientHello ──────────────────►
- 支持的密码套件
- 随机数 (client random)
- 密钥协商参数
◄────────────────── ServerHello
- 选定的密码套件
- 随机数 (server random)
- 密钥协商参数
Certificate
- 服务端证书链
CertificateVerify
- 证书签名验证
Finished
Client
- 验证 Certificate
- 验证 CertificateVerify
- 计算会话密钥
Finished ──────────────────►
✅ 安全通道建立
🔒 后续数据加密传输
握手的关键步骤:
ClientHello / ServerHello:双方交换支持的密码套件(密钥交换算法 + 加密算法 + 哈希算法)、随机数和密钥协商参数。TLS 1.3 把密码套件从几百种砍到了 5 种核心套件,砍掉了 RSA 密钥交换等一切不具备前向安全性(Forward Secrecy)的算法。
Certificate:服务端发送自己的证书链——从服务端自己的证书,到签发它的中间 CA,一直到客户端信任的根 CA。TLS 1.3 中,Certificate 消息本身被加密传输,外面看不到证书内容。
CertificateVerify:服务端用证书对应的私钥,对握手过程中的关键数据做一个数字签名,证明"我确实拥有这张证书的私钥"。这是防止证书被盗用的关键——攻击者可以拿到一张公开的证书文件,但没有私钥就通不过这一步。
客户端验证:客户端收到证书后,会做一系列检查:
- 证书链信任验证:从服务端证书逐级向上,验证每一级的签名,直到根 CA。如果中间任何一环的签名不对,整条链都不被信任。浏览器的根证书库由操作系统或浏览器厂商维护,预装了全球几百个受信任的根 CA。
- 有效期检查:证书的
Not Before和Not After字段定义了有效期。过期证书被拒绝。有趣的是证书过期是最常见的线上故障原因之一,因为它的发作是精确到秒的"定时炸弹"。 - 吊销状态检查:证书可能被 CA 吊销(私钥泄露、域名不再属于原持有者等)。客户端通过 CRL(Certificate Revocation List,CA 定期发布的吊销证书黑名单)或 OCSP(Online Certificate Status Protocol,实时查询单张证书状态)来验证证书未被吊销。实践中 OCSP 有性能和隐私问题,现代浏览器多采用 CRLsets 或强制 CA 使用短有效期证书。
- 主机名验证:客户端把证书中声明的域名与正在连接的域名做比对。这就是
x509: certificate relies on legacy Common Name field发生错误的地方。
密钥协商:双方基于 ClientHello / ServerHello 中的参数,各自计算出相同的会话密钥。TLS 1.3 强制使用 DH(Diffie-Hellman)密钥交换或其 ECDH 椭圆曲线变体。DH 的优雅之处在于:即使攻击者记录了全部的握手流量,事后也无法推导出会话密钥——这就是"前向安全性"(Forward Secrecy)的含义。
Finished:双方用协商好的会话密钥加密一条 Finished 消息,互相对答案——“我这边算的会话密钥是这个,你验证一下”。如果双方都能解密并验证对方的 Finished 消息,说明握手成功,后续数据流全部使用该会话密钥加密。
回到我们的错误:握手在步骤 4(客户端验证)的主机名验证阶段失败。服务端发了证书,签名和有效期都没问题,但证书里声明域名的方式——旧式 CN 字段——不被 Go x509 库接受。客户端单方面拒绝了证书,连接终止,根本走不到 Finished。
证书的身份标识:CN 与 SANs
TLS 握手时,客户端会检查服务端证书中的身份信息,确认"我正在连接的服务器确实是我想连接的那个"。这个身份信息可以写在证书的两个不同位置:
Common Name (CN)
CN 是 X.509 证书 Subject 字段中的一个属性,出现在 TLS 诞生之初。一张证书的 Subject 大概长这样:
Subject: C=US, ST=California, L=San Francisco, O=Example Inc, CN=example.com
其中 CN=example.com 表示这张证书属于 example.com。在 TLS 的早期(SSL 时代),浏览器和客户端库都依据 CN 来做主机名验证——把 CN 的值跟用户访问的域名做比对,一致就放行。
Subject Alternative Names (SANs)
SANs 是 X.509 v3 引入的证书扩展字段(RFC 2818,2000 年),专门用来列出证书所绑定的所有身份标识。SANs 可以承载多种类型的标识:
| SAN 类型 | 示例 | 用途 |
|---|---|---|
| DNS | example.com | 域名 |
| IP | 192.168.1.1 | IP 地址 |
| URI | https://example.com/path | 统一资源标识 |
admin@example.com | 电子邮件地址 |
一张带有 SANs 的证书结构如下:
Subject: C=US, O=Example Inc, CN=example.com
X509v3 Subject Alternative Name:
DNS: example.com
DNS: *.example.com
DNS: api.example.com
SANs 解决了 CN 的几个固有问题:
- 多域名:一张证书可以覆盖多个域名(如
example.com+api.example.com+cdn.example.com),CN 字段只能写一个值。 - 通配符语义混乱:CN 里写
*.example.com是否应该匹配example.com本身?不同实现的处理不一致,SANs 的 DNS 类型有明确定义。 - 类型安全:CN 是一个自由文本字段,没有规定它一定是域名——理论上可以往里塞任何东西。SANs 的每个条目都带有明确的类型标签,IP 就是 IP,域名就是域名,不会混淆。
- 防止注入攻击:由于 CN 是 Subject DN(Distinguished Name)的一部分,攻击者可以通过构造特殊的 Subject 字符串来混淆主机名验证逻辑。SANs 作为独立扩展字段,不与 Subject DN 共享解析路径,天然规避了这类攻击面。
业界淘汰 CN 的时间线
正是因为上述缺陷,主流 TLS 实现陆续废弃了 CN 回退:
| 时间 | 事件 |
|---|---|
| 2000 | RFC 2818 明确规定:SANs 存在时,必须用 SANs 验证,CN 应被忽略 |
| 2017 | Chrome 58 起完全弃用 CN 回退,仅信任 SANs |
| 2018 | Go 1.11 开始在 x509 库中标记 CN 回退为 deprecated |
| 多年演进 | Go 的 x509 库逐步收紧,最终在近期的安全增强版本中彻底移除 CN 回退逻辑 |
Go 的 x509 库并不是一步到位直接砍掉的——它经历了漫长的 deprecation 过渡期。但当一个足够新的版本(Go 1.26)终于把"拒绝仅含 CN 的证书"从警告变成错误时,所有用这个版本编译的程序都将不信任旧式证书。
回到本案
Hysteria2 服务端的证书只使用了 CN 字段,没有 SANs 扩展。这在 Go 1.26 之前的 sing-box 中是可以正常工作的——x509 库检测到 SANs 为空,会回退到 CN 进行主机名验证。
但从 Go 1.26 开始,x509 库强制要求 SANs,拒绝仅含 CN 的证书。sing-box 1.12.25 恰好是用 Go 1.26.3 编译的,于是表现出来就是 Hysteria2 节点不可用。
因果链:
Go 1.26 x509 强制 SANs
→ sing-box TLS 握手拒绝旧式 CN 证书
→ Hysteria2 连接失败 (CRYPTO_ERROR)
→ 代理出站不可用
→ DNS 过代理失败
→ 所有流量不通
这是一个典型的底层依赖行为变更 → 上层应用断裂的案例。服务端证书格式和 sing-box 代码都没有变化,变的是它们之间的 Go TLS 库。
为什么 vless 节点不受影响
同一个订阅里有 25 个 vless 节点和 16 个 Hysteria2 节点,全都走 TLS。Go 1.26 的 x509 强制 SANs 是无差别打击的——只要经过 Go 的证书验证逻辑,旧式 CN 证书都会被拒。那为什么只有 Hysteria2 节点挂了?答案在于 vless 节点根本没走到 x509 验证这一步。
这涉及到 vless 生态中两种主流的 TLS 处理方式:
Reality
VLESS 有一种叫 Reality 的传输模式,它在 TLS 层面玩了一个巧妙的戏法:服务端不持有自己的证书,而是"借用"一个真实存在的目标网站的证书(比如 www.microsoft.com),在握手时原样呈现给客户端。
Reality 的工作流程大致如下:
服务端预先"摘取"(snipping)一个目标网站(称为 dest)的证书。这个目标网站可以是任何启用 TLS 的公网服务器。
客户端连接时,服务端直接转发目标网站的 ServerHello 和 Certificate 消息——就像你访问的不是代理节点,而是那个目标网站本身。
客户端收到的证书,从内容和签名来看,就是目标网站的真实证书——它是一个合法的、由公共 CA 签发的、带 SANs 的证书。
中间人(GFW)看到的是:你在和
www.microsoft.com做正常的 TLS 握手,流量加密,内容不可区分。
这里面有一个关键的隐含行为:客户端必须跳过主机名验证。你连接的是 aws-linkhy16.liangxin1.xyz,但证书里写的是 www.microsoft.com——标准 x509 验证会直接拒掉这种域名不匹配。因此 Reality 模式下,sing-box 的 tls.insecure 必然是开启的,或者 sing-box 内部的 Reality 实现直接绕过了 Go x509 的验证路径,使用了自定义的证书处理逻辑。
正因为 x509 验证在 Reality 模式下本来就不执行,Go 1.26 的 SANs 强制要求对这个路径毫无影响。
非 Reality 的普通 vless + TLS
对于不使用 Reality 的普通 vless 节点,它们走的是标准 TLS 握手。但之所以也没挂,可能的原因有几个:
- 服务端证书配置正确:vless 节点部署时通常用 acme.sh 或 certbot 从 Let’s Encrypt 申请证书,这些工具默认生成的证书包含 SANs,不会被 Go 1.26 拒绝。
allow_insecure早已生效:很巧,由于之前排查日志时就翻过订阅脚本里allow_insecure的逻辑——vless 的非 Reality TLS 节点也可能因为订阅级allow_insecure默认开启而跳过了验证。- XTLS Vision:XTLS 在流量中检测到 TLS 数据时会进行特殊处理,其内部的证书校验路径可能也不同。
所以不是 Hysteria2 特殊脆弱,而是它所在的 TLS 验证路径恰好被 Go 1.26 的变更命中。vless 节点无论是 Reality 的"验证绕过"设计,还是证书本身的格式规范,都恰好避开了这个雷区。
直接修复
最直接的修复——跳过证书验证:
| |
重启 sing-box 后,代理恢复。Google HTTP 200,HTTPS 200,一切正常。
问题复现:订阅更新的覆盖链
第二天代理又挂了。同样的 CRYPTO_ERROR。
覆盖链条追踪
homeproxy 的订阅更新机制是这样工作的:
上游订阅 URI (insecure=false)
→ 订阅脚本 parse_uri() 解析节点配置
→ UCI 写入: option tls_insecure '0'
→ sing-box JSON 生成: "tls": { "insecure": false }
→ TLS 验证失败 → CRYPTO_ERROR
关键点:上游节点的 URI 里硬编码了 insecure=false。每次订阅自动更新(每天凌晨 4 点),订阅脚本从上游拉取并解析 URI,将 tls_insecure 写回 0,覆盖掉手动修改。
这是一个状态漂移问题——手动修改被自动化流程定期覆盖。只改节点配置而不处理订阅层面的逻辑,修复持久不了。
持久化修复
找到正确的注入点
阅读 homeproxy 的订阅更新脚本(/usr/share/homeproxy/update_subscriptions.uc),发现它内置了一个全局开关:
| |
这意味着在订阅级别设置 allow_insecure=1,可以让订阅脚本在解析每条节点时自动注入 tls_insecure=1,无视上游 URI 中写死的 insecure=false。
执行
| |
新覆盖链
上游订阅 URI (insecure=false)
→ 订阅脚本 parse_uri() → config.tls_insecure = '0'
→ 检测 allow_insecure=1 && config.tls='1'
→ 强制 tls_insecure = '1' ← 注入点
→ UCI 写入: option tls_insecure '1'
→ sing-box JSON: "tls": { "insecure": true }
→ TLS 握手跳过 x509 验证 ✅
为什么这里是安全的
Hysteria2 的安全模型与 HTTPS 不同:
| 层级 | HTTPS (TCP+TLS) | Hysteria2 (QUIC) |
|---|---|---|
| 节点认证 | x509 证书链 | PSK 预共享密钥(128 位) |
| 通道加密 | TLS | TLS |
| 完整性 | TLS | QUIC 内置 |
tls_insecure 跳过的只是 x509 证书验证这一环——即"证书是否由可信 CA 签发、是否属于该域名"。Hysteria2 真正的节点身份认证靠的是 PSK (Pre-Shared Key),这是 128 位的对称密钥,在协议握手阶段独立验证。只要 PSK 不泄露,中间人无法冒充节点。
换句话说:TLS 在这里只负责通道加密,不负责身份认证。跳过证书验证不影响安全性。
END
这个问题有三个层次值得记录:
兼容性断裂:Go TLS 库的"安全增强"在不改任何代码的情况下让存量节点失效。这不是谁的 bug,但提醒我们——依赖链上的每一个组件都可能成为断裂点。
状态漂移:自动化订阅更新会覆盖手动修改。修复必须在正确的层级进行——不是改节点,而是改订阅的注入逻辑。
安全模型理解:
tls_insecure听起来危险,但在 Hysteria2 的架构下是安全的,因为身份认证由 PSK 独立完成。理解协议的安全分层才能做出正确判断。
以上问题被ds一脚踢飞并顺手帮我水了篇博客。