ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

TLS重协商时报CRL拿不到?OpenSSL证书吊销检查的隐藏陷阱

TLS重协商时报CRL拿不到?OpenSSL证书吊销检查的隐藏陷阱 1. 先把重协商和 CRL 两项技术摆到同一张桌上1.1 重协商一次藏在长连接生命周期里的“二次握手”我在处理 6.3.24 这个 TLS 服务组件问题时第一反应是打开协议文档把重协商和 CRL 这两件事分开看。重协商在 TLS 1.2 及更早版本里是个非常典型的能力连接建立之后客户端或服务器还可以重新发起一次握手用来更换密钥、重新协商加密套件或者让服务器重新验证客户端证书。客户端主动发一个新的 ClientHello服务器也能用 HelloRequest 通知对端“我们需要重新握一次手”。注意重协商不是重新建连TCP 连接始终是一条只是 TLS 层的会话状态被刷新。触发重协商的理由很多比如证书有效期很短需要轮换、某条连接长时间空闲后需要恢复新鲜密钥、或者安全策略要求每过一段时间重新做一次客户端身份认证。但正因为重协商发生在一条已经跑着业务流量的连接上它比首次握手更加敏感任何一次额外等待、任何一个配置遗漏都会直接中断正在进行的业务。这也就是为什么“重协商时 CRL 拿不到”这类问题尤其讨厌它不是一个“新建连接验证失败”的简单 bug而是“已有连接在二次验证时突然失败”的隐藏坑。1.2 CRL证书吊销检查里最容易被忽略的一环CRL也就是 Certificate Revocation List证书吊销列表。它由 CA 定期签发里面记录了一批被吊销的证书序列号。TLS 对端出示证书后本地验证时除了检查证书签名、有效期、域名匹配之外还要确认这张证书不在吊销名单里。这个动作在 OpenSSL 里由X509_V_FLAG_CRL_CHECK开启如果希望链上每一层证书都查一遍还需要追加X509_V_FLAG_CRL_CHECK_ALL。CRL 的来源通常有两种第一种是从本地预置的目录或文件里加载第二种是根据证书里的 CRL Distribution Points 扩展从 URL 下载。无论哪种方式最终都要把 CRL 对象挂到验证用的X509_STORE上供X509_STORE_CTX在验证中使用。可以理解成一个门禁系统保安上班前拿到了一份最新的黑名单刷卡时对一下序列号就知道该不该放行。问题是如果保安换班了新来的保安没拿到黑名单那就会把正常用户拦在门外。1.3 为什么首次握手正常重协商却偏偏报 CRL 拿不到这是整个排查里最迷惑的地方。首次握手时验证所需的证书链、CRL、可信锚点都准备得妥妥的连接也建立成功了。重协商一来验证逻辑会重新执行一遍但很多实现对“二次握手”的上下文处理并不像首次握手那么完整。我从 6.3.24 的代码里观察到的规律是首次握手中的验证结果和证书链往往会被缓存到连接对象或会话上下文里代码默认“这条连接已经验过了”所以重协商时构造X509_STORE_CTX的逻辑会被简化。如果此时验证模式从SSL_VERIFY_NONE被切换成SSL_VERIFY_PEER或者从只验签名被切换成追加 CRL 检查新的验证上下文里极有可能没有加载任何 CRL直接导致X509_V_ERR_UNABLE_TO_GET_CRL。更麻烦的是有些实现会在重协商时把证书链裁剪得很窄只传叶子证书客户端无法靠链上的中间证书去匹配本地 CRL错误表现同样是“拿不到 CRL”。2. 定位“CRL 拿不到”的根因2.1 “拿不到”在 OpenSSL 里到底指什么先说一个容易误导人的地方X509_V_ERR_UNABLE_TO_GET_CRL这个错误名听起来像是“下载失败了”但它在 OpenSSL 里的真实含义是“当前X509_STORE_CTX中找不到与证书签发者匹配的 CRL”。也就是说系统根本没有可用的 CRL 对象或 CRL 对象的签发者名称和待验证证书的签发者名称对不上。这个区分至关重要。如果你把问题当成网络故障去查会浪费大量时间。我遇到过同事反复抓包怀疑 CDP 域名解析失败但实际原因是代码里加载 CRL 时写的是 DER 文件却用了X509_FILETYPE_PEM解析加载函数静默失败store 里一直是空的。所以看到这个错误码第一步永远是确认 CRL 有没有真正进到 store而不是急着去看网络。2.2 重协商阶段会踩到的三个坑第一个坑是 CRL 没随SSL_CTX共享。有的代码在首次握手成功后才往X509_STORE里补 CRL而重协商触发时新构造的验证上下文并没有继承这份 CRL。第二个坑是 CRL 加载时的 issuer 不匹配。证书链换了一套、或重协商时对端换用了新 CA 签发的证书但本地 CRL 还是旧 CA 的验证器找不到对应关系自然报错。第三个坑最隐蔽CRL 来自 CDP 下载但下载动作发生在验证回调里而且是在持锁状态下同步执行。TLS 握手的回调线程如果同时持有上下文锁下载时又需要读同一个连接对象就会直接卡死或超时看起来像是网络问题实际是锁和上下文状态的问题。这三个坑都能解释“为什么重协商才出问题”。首次握手时很多工程实现会在业务线程里预先把 CRL 准备好哪怕慢一点也没关系。重协商呢它出现在长连接中间代码往往走的是“快速路径”没有经过完整的初始化流程。2.3 看起来像网络故障其实不是网络问题我在 6.3.24 上定位时第一个想到的也是网络。因为报错信息里带着“拿不到”日志里又有超时字样很容易推断成 CRL 下载地址不可达。但仔细看日志的时间线后发现整个过程根本没有网络请求发生。验证回调在尝试从本地 store 查找 CRL 时就直接失败了根本没走到 CDP 下载逻辑。判断技巧很简单开启调试日志后如果错误发生在任何 HTTP 请求之前那基本可以断定是 store 配置问题如果日志里能看到“正在从某个 URL 下载 CRL”然后才失败那才需要去看网络、DNS、代理。这两种情况的排查路径完全不同不要混在一起。3. 实操复现让那个隐藏问题自己暴露出来3.1 打开调试开关和消息追踪我建议先在命令行层把现象复现一遍再回到工程代码里定位。用 OpenSSL 自带的s_client和s_server可以搭一个最简环境。服务端加载服务器证书并打开客户端证书校验# 终端 A启动服务端要求客户端证书 openssl s_server -accept 8443 -cert server.pem -key server.key \ -Verify 2 -CAfile ca.pem -crl_check -crl_check_all # 终端 B启动客户端观察状态流转 openssl s_client -connect 127.0.0.1:8443 \ -state -msg -tlsextdebug -verify_return_error在s_client交互界面里输入R可以触发一次重协商。观察终端 B 的输出重点看第二次握手过程的状态机。如果重协商阶段出现了verify error或证书验证失败就说明问题已经被稳定复现。某些 OpenSSL 版本里R不一定可用那就让服务端定期发送 HelloRequest或者直接改业务代码触发重协商。3.2 最小验证代码怎么写命令行能复现但工程代码里日志不够还需要自己写一个验证回调。不要小看这个回调它能把 OpenSSL 内部正在验证哪个证书、当前错误是什么、处于哪一层深度全部打出来。#include openssl/ssl.h #include openssl/x509.h #include openssl/x509_vfy.h static int verify_cb(int preverify_ok, X509_STORE_CTX *ctx) { int err X509_STORE_CTX_get_error(ctx); X509 *cert X509_STORE_CTX_get_current_cert(ctx); char subject[256] {0}; char issuer[256] {0}; if (cert) { X509_NAME_oneline(X509_get_subject_name(cert), subject, sizeof(subject)); X509_NAME_oneline(X509_get_issuer_name(cert), issuer, sizeof(issuer)); } fprintf(stderr, depth%d ok%d err%s\n, X509_STORE_CTX_get_error_depth(ctx), preverify_ok, X509_verify_cert_error_string(err)); fprintf(stderr, subject: %s\n, subject); fprintf(stderr, issuer : %s\n, issuer); if (err X509_V_ERR_UNABLE_TO_GET_CRL) { fprintf(stderr, 缺少 issuer 对应的 CRL\n); } return preverify_ok; }然后挂到上下文中SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, verify_cb);这样首次握手和重协商都会经过同一个回调日志差异一目了然。我第一次跑出来的结果是首次握手时回调里能正常看到证书链重协商时同样的证书却在UNABLE_TO_GET_CRL处返回了 0。3.3 用本地 CRL 做一个对照实验为了排除网络因素我准备了一个本地 CRL 文件在命令里指定加载。如果本地 CRL 存在时重协商能成功说明代码路径是完整的如果加载了本地 CRL 仍然失败那问题就更深了可能是证书链的构造逻辑在重协商时被裁剪。我在这个环节的做法是先不加载任何 CRL重协商失败然后加载一份与 CA 匹配的 CRL重协商成功。这就证明根因不是“CRL 本身不可用”而是“重协商路径没有把 CRL 带进验证上下文”。实验本身很简单但它能把讨论范围一下子缩小到上下文传递而不是证书失效、网络断连、防火墙拦截这些外围因素。4. 三种可落地的修复方案按工程特点选4.1 方案 A把 CRL 预加载到 X509_STORE 里最稳妥的方案是在程序初始化阶段就把 CRL 挂到SSL_CTX的 store 上不要等到握手或重协商发生时才去加载。这样首次握手和重协商复用同一个 storeCRL 始终存在。X509_STORE *store X509_STORE_new(); X509_LOOKUP *lookup X509_STORE_add_lookup(store, X509_LOOKUP_file()); if (lookup) { X509_LOOKUP_load_file(lookup, ca-chain.pem, X509_FILETYPE_PEM); } /* 加载 CRL注意格式必须与文件实际格式一致 */ X509_LOOKUP_load_file(lookup, ca.crl, X509_FILETYPE_ASN1); /* 开启 CRL 检查并且让整条链都检查 */ X509_STORE_set_flags(store, X509_V_FLAG_CRL_CHECK | X509_V_FLAG_CRL_CHECK_ALL); SSL_CTX_set_cert_store(ctx, store);这段代码的核心不是“加载 CRL”这个动作本身而是“在握手之前就完成加载”。很多工程代码把证书加载放在首次连接时执行连接成功后就不再管了重协商时上下文里自然拿不到。直接在初始化时铺好整套验证环境能消灭大部分这类问题。4.2 方案 B在验证回调里按需获取并缓存 CRL如果 CRL 会定期更新不能一直静态加载可以考虑按需获取。但这里有个非常重要的忠告不要直接在验证回调里做同步的 HTTP 下载。验证回调运行在握手路径上手里拿着锁网络下载一旦阻塞整个连接就卡死了。更好的做法是把下载逻辑放到独立线程定期从 CDP 或固定地址刷新 CRL然后写进共享的X509_STORE。验证回调只负责定位缺失对象static int verify_cb(int preverify_ok, X509_STORE_CTX *ctx) { int err X509_STORE_CTX_get_error(ctx); if (err X509_V_ERR_UNABLE_TO_GET_CRL) { /* 记录 issuer 信息触发外部缓存刷新 */ schedule_crl_refresh(); } return preverify_ok; }外部刷新任务通过X509_STORE_add_crl()更新 store。下一次重协商再发生时CRL 已经在 store 里了验证就能通过。时机上要注意CRL 是有有效期的刷新周期必须短于 CRL 的nextUpdate字段否则会出现 CRL 过期导致的二次失败。4.3 方案 C重协商时单独构建一个专用验证上下文如果工程上不方便全局修改 store可以在触发重协商之前给连接单独分配一个带完整 CRL 配置的SSL_CTX。OpenSSL 提供了SSL_set_SSL_CTX()它可以把当前 SSL 对象切换到另一个上下文后续握手会使用新上下文里的验证策略和证书数据。SSL_CTX *verify_ctx create_verify_ctx_with_crls(); SSL_set_SSL_CTX(ssl, verify_ctx);注意这个切换动作要在重协商之前完成不能在验证回调里临时做。我见过有人试图在回调中切换到新上下文但这时候握手机制已经进入验证阶段上下文切换产生的影响非常不确定。正确做法是把“开始重协商”和“准备好新验证上下文”当作一个原子操作来处理业务侧一旦决定重协商就先换好SSL_CTX再发起二次握手。4.4 验证修复后的行为修复完成后重新跑 3.1 里的命令行场景或者直接重放业务长连接。正常情况下s_client输入R后状态机会从RENEGOTIATING走到新的握手完成不会再出现证书验证错误。代码侧的回调日志里首次握手和重协商两次都能看到完整的证书链和 CRL 匹配信息。我额外做了一遍“负向验证”临时把 CRL 从 store 里移除确认错误又能复现。这样能确保修复效果确实来自 CRL 的加载而不是别的因素误打误撞解决了问题。负向验证虽然多花几分钟但对后续排查非常有价值。5. 问题排查速查表与配置细节5.1 常见症状、原因与解决方向对照现象可能原因优先排查项首次握手正常重协商报UNABLE_TO_GET_CRL重协商路径未加载 CRL检查 store 是否在初始化时就配置本地 CRL 文件存在但加载失败文件格式与加载参数不匹配确认 PEM/ASN1 格式查看加载返回值重协商时服务器不发送完整证书链链构建无法匹配本地 CRL抓包看 Certificate 消息检查服务器端链配置报错前有 URL 下载日志CDP 下载失败或超时检查 DNS、防火墙、代理以及下载逻辑的锁状态重协商偶尔失败错误时间不固定CRL 过期或未被定时刷新比较nextUpdate与当前时间检查刷新任务开启CRL_CHECK_ALL后失败链上某一层缺少中间 CA 的 CRL确认每级 CA 都签署了对应 CRL5.2 几个容易被忽略的配置细节X509_V_FLAG_CRL_CHECK只检查叶子证书如果只设置这一个标志链上中间 CA 的吊销状态是没人管的。需要整条链都检查时必须加上X509_V_FLAG_CRL_CHECK_ALL。另一个常见坑是 CRL 的签发者名称必须和证书签发者完全一致包括 DN 的排列顺序、字符大小写OpenSSL 对这部分匹配非常严格。文件扩展名也不可靠.pem文件未必就是 PEM 格式很多工具会无脑地把.pem后缀文件当 PEM 读而实际内容是 DER 编码加载函数直接返回失败错误还不会显式抛出。还有一个工程层面的细节如果你把 CRL 文件写死在构建镜像里那它的刷新周期就失效了。只要代码里出现“路径硬编码 启动时一次性加载”CRL 过期只是时间问题迟早会在某次重协商时爆发。6. 最后一点工程实践上的建议6.1 从流程设计上彻底规避同步拉取经历了这次 6.3.24 的问题我的核心体会是不要等到握手需要时再去“拿” CRL。CRL 应该被当成业务数据一样提前准备、定期更新而不是被当成握手参数随用随取。每次重协商都要走一次 CRL 下载相当于给 TLS 握手插入了一个额外网络 RTT性能和安全都不可控。更合理的架构是在连接管理组件之外单独跑一个 CRL 刷新服务。它按固定周期拉取 CRL校验签名和有效期然后写入内存中的X509_STORE。握手线程只负责读取永远不发起阻塞下载。这个改动不仅解决了重协商问题也让首次握手的延迟明显下降因为很多连接建立后发现 CRL 还没有缓存也得等下载换作提前刷新后就没有这个等字了。6.2 我在多次排查后留下的几条习惯第一验证回调里必须把当前证书的 subject 和 issuer 都打出来否则只看到一个错误码根本判断不了是链上哪一层出了问题。第二每次修改 CRL 相关配置后先用命令行工具复现一次确认行不要直接拿生产流量试错。第三日志里记录 CRL 的来源是本地文件还是 CDP 下载以及加载时的文件 hash出现问题时能很快定位到是数据源问题还是加载逻辑问题。这个故障我记了很久。它不算难但它把“首次握手正常”这件看起来可靠的表象彻底打破了让我意识到重协商的本质是一次全新的验证过程。之后每次接入新 TLS 组件我都会在需求里明确写一条重协商必须使用与首次握手相同的证书、链、CRL 完整上下文不允许任何简化路径。一句话总结排查心得就是日志里多打几行证书字段永远比反复查网络更快。
返回列表