引言 — 浏览器上锁标好好的,curl 却失败
刚部署了新签发的证书。用浏览器打开一看,锁标正常,证书信息也没问题。可是从后端调用那个端点时却这样失败。
curl -sS https://api.example.com/v1/health
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html
此刻最常见的结论是"curl 不认识最新的 CA"。于是更新 ca-certificates,还不行就加上 -k,或者在应用里关掉校验。这是掩盖原因的做法,而且往往会让配置错误的服务器就这么进入生产。
真正的原因几乎总是同一个:服务器没有把中间证书一起发出来,而浏览器自己把这个缺口补上了,所以成功了。本文准确解释这个机制,并把内容整理成只看错误消息就能指认原因的形式。
按消息顺序阅读 TLS 1.3 握手
要做调试,就得知道哪些消息按什么顺序往来。TLS 1.3 一个往返就结束。
客户端 服务器
| |
|-- ClientHello -------------------------------------> |
| supported_versions: TLS 1.3 |
| key_share: x25519 公钥 |
| server_name: api.example.com (SNI, 明文) |
| application_layer_protocol_negotiation: h2, http/1.1
| |
| <------------------------------------ ServerHello -- |
| key_share: x25519 公钥 |
| ---- 从这里开始所有消息都被加密 ---- |
| <------------------------------- EncryptedExtensions |
| <----------------------------- CertificateRequest(*) |
| <------------------------------------- Certificate |
| <------------------------------- CertificateVerify |
| <------------------------------------------ Finished |
| |
|-- Certificate(*) ----------------------------------> |
|-- CertificateVerify(*) ----------------------------> |
|-- Finished ----------------------------------------> |
|-- Application Data (第一个HTTP请求) ---------------> |
| |
(*)仅在要求mTLS时出现
从这里可以得出三个直接影响运维的事实。
第一,ServerHello 之后的所有消息都会被加密。在 TLS 1.2 里只靠抓包就能把服务器证书取出来,但在 TLS 1.3 里做不到。Wireshark 里看不到证书,并不代表服务器没有发证书。要确认证书,必须在客户端一侧用 openssl s_client 这类工具来看。
第二,ClientHello 里的 SNI 依然是明文。即使其余部分全部加密,你要连的主机名还是会暴露。这也是 Encrypted Client Hello 出现的原因之一。从调试角度看反而很有用:抓包时唯独 SNI 永远读得到。
第三,客户端会把 Finished 和第一个请求连着发出去。在服务器校验客户端证书之前,客户端就已经认为握手结束了。因此 mTLS 的认证失败不会表现为握手错误,而是表现为发出第一个请求之后连接被断开。这个差别正是 mTLS 调试困难的核心。
没有 SNI,服务器就随便递一个证书
一个 IP 和端口上挂着数百个站点,是如今的基本配置。服务器仅凭 TCP 连接无法知道该递出哪张证书。这个信息由 ClientHello 的 SNI 扩展来告知。
不带 SNI 连接时,服务器会返回默认虚拟主机的证书。
# 去掉 SNI,直接用 IP 连接
openssl s_client -connect 203.0.113.40:443 -noservername </dev/null 2>/dev/null \
| openssl x509 -noout -subject
subject=CN = default.vhost.example.net
# 明确指定 SNI 就会出来正确的证书
openssl s_client -connect 203.0.113.40:443 -servername api.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject
subject=CN = api.example.com
使用 openssl s_client 时最常犯的错误就在这里。只写 -connect 而漏掉 -servername,SNI 就不会被发送,于是你看到的是与真实服务不同的证书,进而得出跑偏的结论。较新的 OpenSSL 会把 -connect 的主机名自动设为 SNI,但用 IP 连接或使用旧版本时并非如此。养成永远显式写 -servername 的习惯更安全。
确实存在不发送 SNI 的客户端。非常老的 Java 客户端、一部分嵌入式设备,以及某些监控代理就是这样。如果只有这类客户端报证书错误,那么原因不是证书,而是没有发送 SNI。服务器一侧把默认虚拟主机设成什么,就成了对策本身。
在抓包里确认 SNI 的方法也值得掌握。
sudo tcpdump -nn -i any -A 'tcp port 443 and tcp[((tcp[12:1] & 0xf0) >> 2)] = 0x16' | head -40
链校验与中间证书缺失 — 浏览器与 curl 分道扬镳的地方
校验从末端证书开始,沿签发者一路往上,只要到达本地信任库里的根证书就成功。服务器的责任是把除根证书之外的所有中间证书一并发出来。
正常的证书链看起来是这样。
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
| sed -n '/Certificate chain/,/---/p'
Certificate chain
0 s:CN = api.example.com
i:C = US, O = Let's Encrypt, CN = R11
1 s:C = US, O = Let's Encrypt, CN = R11
i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
---
缺了中间证书的服务器会是这样。
Certificate chain
0 s:CN = api.example.com
i:C = US, O = Let's Encrypt, CN = R11
---
...
Verify return code: 20 (unable to get local issuer certificate)
只有 0 号而没有 1 号。客户端得在信任库里找到名为 R11 的签发者,可信任库里只有根证书 ISRG Root X1,没有中间证书 R11。于是链条断了。
那浏览器为什么能成功呢?这正是关键所在。
末端证书里面写着可以下载签发者证书的 URL。
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -text | grep -A3 'Authority Information Access'
Authority Information Access:
OCSP - URI:http://r11.o.lencr.org
CA Issuers - URI:http://r11.i.lencr.org/
Chrome、Safari、Edge 在校验过程中找不到签发者时,会向这个 CA Issuers 的 URL 发 HTTP 请求,自己把中间证书下载下来。这叫 AIA fetching 或 AIA chasing。Firefox 的做法略有不同,它会预置已知的中间证书,并把见过一次的中间证书缓存起来。
相对地,OpenSSL 默认不会去追 AIA。curl、wget、Python 的 requests、Go、以及大部分 Node 都属于这一类。Java 默认也不追,需要打开 com.sun.security.enableAIAcaIssuers 属性。
于是下面这个命题成立。如果浏览器能通而 curl 失败,那么错的是服务器而不是客户端。浏览器只是替服务器把失误补上了而已。而且 AIA fetching 是网络请求,每次首连都会多花几十到几百毫秒,也会转化为用户可感知的延迟。
确认只要两条命令。
# 确认仅凭服务器发来的链能否通过校验 (信任库用系统默认)
openssl s_client -connect api.example.com:443 -servername api.example.com \
-verify_return_error </dev/null 2>&1 | tail -3
# 把链导出来数一下个数
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null \
| grep -c 'BEGIN CERTIFICATE'
Verify return code: 0 (ok)
2
修复的地方在服务器配置。nginx 的 ssl_certificate 必须指向把末端证书和中间证书按顺序拼接好的文件(fullchain)。Apache 在较新版本里把 fullchain 放进 SSLCertificateFile 即可,旧版本则要另外指定 SSLCertificateChainFile。如果是负载均衡器,就检查证书注册界面里的链条目是不是留空了。
错误消息词典
只要把消息读准,原因的大部分就定下来了。
| 错误消息 | 实际原因 | 确认命令 | 处置 |
|---|---|---|---|
| unable to get local issuer certificate | 服务器没发中间证书,或者本地没有根证书 | 数一下 s_client 的链长度,确认信任库是否存在 | 在服务器部署 fullchain,或安装 ca-certificates |
| certificate has expired | notAfter 已过,或者客户端时钟不准 | openssl x509 -noout -dates,确认 date | 续期,或做 NTP 同步 |
| certificate is not yet valid | 早于 notBefore,实际上是客户端时钟问题 | date、timedatectl | 校准时间 |
| hostname mismatch, no alternative subject name | 连接用的名字不在证书的 SAN 列表里 | openssl x509 -noout -ext subjectAltName | 往 SAN 里加名字,或用正确的 SNI 连接 |
| self signed certificate in certificate chain | 中间有内部 CA 或拦截代理 | 查看链最上层的签发者 | 把内部根证书注册到信任库 |
| self signed certificate | 末端证书本身就是自签名 | 确认 issuer 与 subject 是否相同 | 签发正式证书 |
| tlsv1 alert protocol version | 客户端与服务器的最低协议版本没有交集 | 分别给 s_client 指定 -tls1_2、-tls1_3 做对比 | 调整服务器配置或升级客户端 |
| sslv3 alert handshake failure | 没有共同的加密套件,或 mTLS 下未提交客户端证书 | openssl ciphers,给 s_client 指定 -cert 与 -key | 对齐加密套件,或提交客户端证书 |
| certificate required (alert 116) | TLS 1.3 mTLS 下客户端发了空证书 | 服务器日志,给 s_client 指定 -cert 与 -key | 配置客户端证书 |
要纠正两个误解。
关于 hostname mismatch,现代客户端根本不看 CN 字段,只检查 subjectAltName。认为"域名写在 CN 里就没问题",从 2017 年之后就是错的。Chrome 从 58 版开始移除了 CN 回退,OpenSSL 和 Go 也一样。必须确认 SAN。
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -ext subjectAltName
X509v3 Subject Alternative Name:
DNS:api.example.com, DNS:api-internal.example.com
certificate has expired 也常被误诊。相当多的情况不是服务器证书的问题,而是客户端时钟不对。没有时钟的嵌入式设备、停机很久后才启动的虚拟机、与宿主机时间不一致的容器都是典型。养成把过期日期和当前时间一起确认的习惯能省下时间。
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -dates
date -u
notBefore=Jun 2 08:14:31 2026 GMT
notAfter=Aug 31 08:14:30 2026 GMT
Sun Jul 26 05:11:20 UTC 2026
用 openssl s_client 确认的顺序
记住一条能一次看全的命令会很方便。
HOST=api.example.com
PORT=443
openssl s_client -connect "${HOST}:${PORT}" -servername "${HOST}" \
-showcerts -status -alpn h2,http/1.1 </dev/null 2>&1 | head -60
主要输出这样读。
CONNECTED(00000003)
depth=2 C = US, O = Internet Security Research Group, CN = ISRG Root X1
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = R11
verify return:1
depth=0 CN = api.example.com
verify return:1
---
Certificate chain
0 s:CN = api.example.com
i:C = US, O = Let's Encrypt, CN = R11
1 s:C = US, O = Let's Encrypt, CN = R11
i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
---
ALPN protocol: h2
OCSP response: no response sent
---
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
Verify return code: 0 (ok)
Verify return code: 0 (ok) 是最终判定。从高 depth 往下走的 verify 行会告诉你链在哪一步断了。
按协议分别测试也很有用。
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_3 </dev/null 2>&1 | grep -E 'New,|Cipher'
openssl s_client -connect api.example.com:443 -servername api.example.com -tls1_2 </dev/null 2>&1 | grep -E 'New,|Cipher'
New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
New, TLSv1.2, Cipher is ECDHE-RSA-AES128-GCM-SHA256
邮件或数据库这类使用 STARTTLS 的协议需要加上选项。
openssl s_client -connect smtp.example.com:587 -starttls smtp -servername smtp.example.com </dev/null
openssl s_client -connect db.example.com:5432 -starttls postgres </dev/null
用 curl 看的时候,下面这个组合信息量最大。
curl -v --resolve api.example.com:443:203.0.113.40 https://api.example.com/v1/health 2>&1 | grep -E '^\*'
* Server certificate:
* subject: CN=api.example.com
* start date: Jun 2 08:14:31 2026 GMT
* expire date: Aug 31 08:14:30 2026 GMT
* subjectAltName: host "api.example.com" matched cert's "api.example.com"
* issuer: C=US; O=Let's Encrypt; CN=R11
* SSL certificate verify ok.
--resolve 会绕开 DNS 直接连到指定 IP,同时把 SNI 和 Host 头保持为原来的名字。逐台验证负载均衡器后面的各个节点时必不可少。
信任库、容器,以及 mTLS 的失败点
校验失败的另一条轴是客户端一侧的信任库。各发行版的路径和更新命令都不一样。
# Debian、Ubuntu
ls -l /etc/ssl/certs/ca-certificates.crt
cp corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
update-ca-certificates
# RHEL、Rocky、Fedora
ls -l /etc/pki/tls/certs/ca-bundle.crt
cp corp-root.crt /etc/pki/ca-trust/source/anchors/
update-ca-trust extract
# Alpine
apk add --no-cache ca-certificates
cp corp-root.crt /usr/local/share/ca-certificates/
update-ca-certificates
容器里最常见的事故,是最小镜像里根本没有信任库。把 Go 二进制放到 scratch 或 alpine 上,就会这样失败。
x509: certificate signed by unknown authority
不是证书有问题,而是镜像里一个可供比对的根证书都没有。如果是 Alpine,就安装 ca-certificates 包;如果是静态二进制,就用 distroless 的 base 镜像,或者在多阶段构建里把证书包复制进去。
FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates
FROM scratch
COPY /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY app /app
ENTRYPOINT ["/app"]
各运行时指向信任库的环境变量各不相同,这一点也值得记住。
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt # OpenSSL, Go, curl
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt # Python 的 requests
export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/corp-root.crt # Node
# Java 使用单独的密钥库
keytool -importcert -alias corp-root -file corp-root.crt \
-keystore "$JAVA_HOME/lib/security/cacerts" -storepass changeit -noprompt
如果 self signed certificate in certificate chain 这个错误只在公司内网出现,原因多半是拦截代理。公司一旦部署 TLS 检查设备,所有证书都会被内部 CA 重新签名。看一眼链最上层的签发者就一目了然。
mTLS 会让失败点翻倍。确认顺序如下。
# 确认服务器是否要求客户端证书,以及它接受哪些 CA
openssl s_client -connect mtls.example.com:8443 -servername mtls.example.com </dev/null 2>&1 \
| sed -n '/Acceptable client certificate CA names/,/^---/p'
Acceptable client certificate CA names
C = KR, O = Example Internal CA, CN = Example Issuing CA
Client Certificate Types: RSA sign, ECDSA sign
这份清单是决定性的。如果自己客户端证书的签发者不在里面,那张证书就绝对通不过。确认签发者。
openssl x509 -in client.crt -noout -issuer -subject -dates
openssl verify -CAfile ca.crt client.crt
issuer=C = KR, O = Example Internal CA, CN = Example Issuing CA
subject=CN = payments-service
notBefore=Jul 1 00:00:00 2026 GMT
notAfter=Jul 1 00:00:00 2027 GMT
client.crt: OK
现在提交客户端证书再连接。
openssl s_client -connect mtls.example.com:8443 -servername mtls.example.com \
-cert client.crt -key client.key -CAfile ca.crt </dev/null 2>&1 | tail -5
curl -sS --cert client.crt --key client.key --cacert ca.crt \
https://mtls.example.com:8443/v1/health
前面提到的 TLS 1.3 特性在这里显现出来。即使客户端证书被拒绝,握手看起来也像是成功了,错误要等到发出第一个请求之后才出现。
curl: (56) OpenSSL SSL_read: error:0A000412:SSL routines::sslv3 alert bad certificate, errno 0
只看客户端日志会以为是请求发送途中断掉了,所以 mTLS 的失败必须结合服务器日志一起看。扩展密钥用途是否正确也是经常被漏掉的一项。服务器证书需要 serverAuth,客户端证书需要 clientAuth。
openssl x509 -in client.crt -noout -ext extendedKeyUsage
X509v3 Extended Key Usage:
TLS Web Client Authentication
结语 — 动客户端之前,先数一数证书链
证书问题的判断顺序可以简短概括。
先用 openssl s_client 连接,并且务必加上 -servername,然后数一数输出里 Certificate chain 下面有几条。如果只出现末端证书一条,那一刻原因就确定了。服务器没有发送中间证书,而浏览器一直在用 AIA 替它补齐。与其去动客户端,不如在服务器上部署 fullchain,事情就结束了。
如果链是完整的却依然失败,那才轮到看信任库。确认容器里有没有 ca-certificates、内部 CA 是否已注册、各运行时的环境变量是否正确。
而关掉校验这个选项,应该留到最后。-k 和 verify=False 并不解决问题,只是让问题看不见,而且带着这种状态上线就等于完全暴露在拦截攻击之下。绝大多数证书错误,都能靠服务器配置的一行精确解决。
현재 단락 (1/201)
刚部署了新签发的证书。用浏览器打开一看,锁标正常,证书信息也没问题。可是从后端调用那个端点时却这样失败。