HTTPS 握手失败:区分证书链、SNI、协议版本与系统时间

96 次浏览4 条回复

适用于 Linux 上排查客户端访问 HTTPS 时出现证书校验失败、握手中断或协议不匹配。命令基线为 OpenSSL 1.1.1+、curl 7.68+;示例域名 api.example.com、端口 443 和地址 203.0.113.10 必须替换为实际值。以下检查会主动连接目标服务,应在获准的来源网络执行。先保留错误原文,不要用 curl -k 把校验失败掩盖掉。

1. 固定客户端、时间与失败阶段

date -u
openssl version -a
curl --version | head -n 1
getent ahosts api.example.com
curl -v --connect-timeout 5 --max-time 15 \
  https://api.example.com/healthz -o /dev/null

记录 curl 实际连接的 IP、错误码以及失败发生在 TCP 建连、TLS 握手还是收到 HTTP 响应之后。若 TCP 尚未建立,应先查路由、防火墙和监听;已经收到 HTTP 状态码,则 TLS 通常已经完成。curl -v 可能显示请求头和网络地址,公开日志前应脱敏。

证书有效期依赖客户端时间。使用 systemd 的主机可补充查看:

timedatectl status

时间明显错误时应先修正时间同步,再重复原请求,避免误判证书尚未生效或已经过期。

2. 显式携带 SNI 并执行证书校验

openssl s_client \
  -connect api.example.com:443 \
  -servername api.example.com \
  -verify_hostname api.example.com \
  -verify_return_error -showcerts </dev/null

重点保留协商出的协议和密码套件、服务端返回的证书链,以及末尾的校验结果。主机名不匹配时检查叶子证书的 SAN;unable to get local issuer certificate 一类错误则应区分服务端漏发中间证书与客户端信任库缺失。不要把服务端发送的中间证书直接当作新的信任根导入。

只查看叶子证书的主体、签发者、有效期和 SAN,可执行:

openssl s_client -connect api.example.com:443 \
  -servername api.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -serial -dates -ext subjectAltName

3. 固定后端 IP,避免把 DNS 或负载均衡差异混入结果

当同一域名解析到多个地址时,应逐个后端验证,同时保留域名对应的 SNI 和主机名校验:

openssl s_client \
  -connect 203.0.113.10:443 \
  -servername api.example.com \
  -verify_hostname api.example.com \
  -verify_return_error </dev/null

curl --resolve api.example.com:443:203.0.113.10 \
  -v --connect-timeout 5 --max-time 15 \
  https://api.example.com/healthz -o /dev/null

只有部分地址失败,通常应继续比较各负载均衡节点的证书链、监听配置与发布版本;直接访问 IP 而不设置 SNI,可能拿到默认证书,不能代表域名入口。

4. 按证据比较 TLS 版本和 ALPN

OpenSSL 1.1.1+ 可分别限制协议版本:

openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_2 </dev/null
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_3 </dev/null
openssl s_client -connect api.example.com:443 -servername api.example.com -alpn 'h2,http/1.1' </dev/null

TLS 1.2 成功而 TLS 1.3 失败,或反之,只能说明版本路径存在差异;还需结合客户端能力、服务端策略和中间代理日志定位。wrong version number 常见于把明文 HTTP 端口当成 TLS 端口,或代理协议配置不一致;no shared cipher、protocol version 才更接近协议或密码套件策略不兼容。不要在没有证据时整体降低最低 TLS 版本。

5. 闭环验证

每次只修改一个有证据支持的证书链、监听或 TLS 策略项。修复后从原失败客户端重复同一 curl 请求,并对域名解析出的每个实际后端执行固定 IP 验证。闭环至少应满足:系统时间正确、主机名校验通过、证书链能由现有信任库验证、预期 TLS 版本与 ALPN 协商成功,且原应用入口不再新增握手错误。浏览器单次成功不能替代原客户端和全部后端的验证。

补一个容易让 curl 与 openssl s_client 得出相反结论的分支:代理路径和信任库并不一定相同。s_client 默认直连,而 curl 可能读取代理环境变量、~/.curlrc,并且其 TLS 后端与 CA 路径也可能不同。

先只记录代理变量是否存在,避免把代理地址中的凭据贴进日志(以下需 Bash 4.2+):

for name in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY \
            http_proxy https_proxy all_proxy no_proxy; do
  [[ -v $name ]] && printf '%s=set\n' "$name"
done

curl -V

若策略允许客户端直连,可对同一后端做一组对照;-q 必须放在第一个参数,用于忽略用户级 curl 配置:

curl -q -v --connect-timeout 5 --max-time 15 \
  https://api.example.com/healthz -o /dev/null

curl -q -v --noproxy '*' \
  --resolve api.example.com:443:203.0.113.10 \
  --connect-timeout 5 --max-time 15 \
  https://api.example.com/healthz -o /dev/null

只有经代理的请求失败,应检查代理的 CONNECT、TLS 检查策略及其日志;只有直连失败,则回到出口 ACL、后端监听和证书链。若 s_client 成功但直连 curl 仍失败,可再把两者显式指向同一份经批准的 CA bundle:

curl -q --cacert /path/to/ca-bundle.pem \
  --noproxy '*' --resolve api.example.com:443:203.0.113.10 \
  https://api.example.com/healthz -o /dev/null

openssl s_client -connect 203.0.113.10:443 \
  -servername api.example.com -verify_hostname api.example.com \
  -CAfile /path/to/ca-bundle.pem -verify_return_error </dev/null

这样可以把“服务端链有问题”与“代理或客户端信任来源不同”分开。公开 curl -v 输出前还要删去 Authorization、Cookie、代理凭据和内部地址。

再补一个双栈环境里常被忽略的分支:IPv4 与 IPv6 可能落到不同入口。默认 curl 会在多地址间选择并可能回退,偶发成功不能证明两条路径的证书与 TLS 配置一致。先分开记录解析和连接结果:

getent ahostsv4 api.example.com
getent ahostsv6 api.example.com

curl -q -4 -v --connect-timeout 5 --max-time 15 \
  https://api.example.com/healthz -o /dev/null

curl -q -6 -v --connect-timeout 5 --max-time 15 \
  https://api.example.com/healthz -o /dev/null

这里要区分“IPv6 无路由或 TCP 超时”和“已经连到 IPv6 入口后 TLS 校验失败”;只有后者才直接指向该入口的证书链、SNI 或协议策略。若 AAAA 返回多个地址,可逐个固定验证。下面的 2001:db8::10 是文档地址,必须替换为实际地址:

curl -q -v \
  --resolve 'api.example.com:443:[2001:db8::10]' \
  --connect-timeout 5 --max-time 15 \
  https://api.example.com/healthz -o /dev/null

openssl s_client \
  -connect '[2001:db8::10]:443' \
  -servername api.example.com \
  -verify_hostname api.example.com \
  -verify_return_error </dev/null

若 IPv4 全部通过而某个 IPv6 地址失败,应核对 AAAA 记录指向、IPv6 监听、负载均衡后端及证书发布是否同步,不要用删除 AAAA 记录代替根因定位。修复后分别用 curl -4、curl -6 从原失败网络复测;没有 IPv6 连通性的客户端不能用于验证 IPv6 入口。

再补一个会在网关或服务网格中出现的分支:入口启用了双向 TLS(mTLS),但客户端没有发送可接受的证书。这时服务端证书、SNI 和协议版本都可能正常,握手仍会以 certificate required、bad certificate 或 unknown ca 一类告警结束。以下以 OpenSSL 1.1.1+、curl 7.68+ 为基线;证书和私钥路径必须替换,私钥不得粘贴到日志或帖子中。

先不带客户端证书观察服务端是否请求证书:

openssl s_client \
  -connect api.example.com:443 \
  -servername api.example.com \
  -verify_hostname api.example.com \
  -verify_return_error -state </dev/null

保留服务端证书校验结果、Acceptable client certificate CA names(若有)和末尾 TLS alert。服务端列出可接受 CA 只是筛选线索;列表为空也不能直接证明未启用 mTLS,部分实现不会发送该列表。还应与入口配置及服务端握手日志核对。

拿到经批准的客户端证书后,先离线检查用途、有效期以及证书与私钥是否配对:

openssl x509 -in /path/client.crt -noout \
  -subject -issuer -serial -dates -ext extendedKeyUsage

openssl x509 -in /path/client.crt -pubkey -noout \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256
openssl pkey -in /path/client.key -pubout -outform DER \
  | openssl dgst -sha256

后两条摘要应一致;摘要只用于本机比较,不应作为私钥安全性的证明。证书的扩展用途应允许 TLS Web Client Authentication。若私钥有口令,命令会交互读取,不要通过命令行参数暴露。

再用同一域名、后端和信任来源复测:

curl -q -v \
  --cert /path/client.crt --key /path/client.key \
  --cacert /path/server-ca-bundle.pem \
  --resolve api.example.com:443:203.0.113.10 \
  --connect-timeout 5 --max-time 15 \
  https://api.example.com/healthz -o /dev/null

未带证书失败、带正确证书成功,才支持 mTLS 客户端身份缺失这一判断。若仍收到 unknown ca,应检查服务端信任的客户端 CA 链;若是 bad certificate,继续核对有效期、扩展用途、签名算法和入口的身份映射规则。闭环要从原失败客户端验证握手成功及预期 HTTP 响应,并确认服务端不再新增同类 TLS 告警。

原文已经提示要区分服务端漏发中间证书与客户端信任库缺失,这里补一组可复核的方法:s_client -showcerts 展示的是服务端实际发送的证书,不是 OpenSSL 自动补全后的完整链。浏览器可能命中中间证书缓存或通过 AIA 补链,因此浏览器成功不能证明入口发送完整。以下仍以 OpenSSL 1.1.1+ 为基线,并在获准网络执行。

先把服务端本次实际发送的每张证书拆到临时目录,并列出主体、签发者和有效期:

tmp=$(mktemp -d)
openssl s_client -connect api.example.com:443 \
  -servername api.example.com -showcerts </dev/null \
  2>"$tmp/handshake.stderr" |
awk -v dir="$tmp" '
/-----BEGIN CERTIFICATE-----/ {
  n++; file=sprintf("%s/served-%02d.pem", dir, n)
  bundle=dir "/served-intermediates.pem"; copying=1
}
copying {
  print > file
  if (n > 1) print > bundle
}
/-----END CERTIFICATE-----/ { close(file); copying=0 }
END { close(bundle); if (n == 0) exit 1 }
'

for cert in "$tmp"/served-*.pem; do
  printf '\n[%s]\n' "$cert"
  openssl x509 -in "$cert" -noout \
    -subject -issuer -serial -dates
done

通常第一张是叶子证书,后续应能按 issuer 到下一张的 subject 接续;信任根通常不需要由服务端发送,不能因末尾没有根证书就判定链不完整。若服务端发送了中间证书,可用实际客户端采用的 CA bundle 验证;路径必须替换,且只有存在 served-intermediates.pem 时才加 -untrusted:

openssl verify -verbose -show_chain \
  -CAfile /path/to/system-ca-bundle.pem \
  -untrusted "$tmp/served-intermediates.pem" \
  "$tmp/served-01.pem"

若上述失败,而把经内部 PKI 或 CA 官方渠道核验过的中间证书作为 -untrusted /path/to/approved-intermediates.pem 后验证通过,并且该中间证书不在 served-* 中,才支持“服务端漏发中间证书”的判断。若服务端链已连续但仍找不到可信签发者,则应核对原失败客户端实际使用的 CA 文件、容器镜像中的信任库和运行时自带信任库,不要把下载到的中间证书直接导入为信任根。

修复后应重新抓取 -showcerts,确认入口已发送叶子证书及所需中间证书,再从原失败客户端启用正常校验复测;只看某个浏览器恢复仍不足以闭环。