mTLS 客户端证书准入判定失效:未签发的自签证书也放行、已被撤销的旧证书也放行,而自述报告/健康检查/退出码全绿
日期:2026-10-10(第116题,分支
2026-10-10-0000)|技术栈:Python 3 / OpenSSL x509 / NDJSON 台账 / mTLS 客户端证书准入 / 健康检查退出码 关键词:active_cert_only、证书撤销、证书吊销、replaces_serial、准入判定 fail-open、合取式判据、空判据表、健康检查假绿、退出码恒 0、自证自己、fail-closed、mTLS 客户端证书、openssl x509 -fingerprint
一、问题背景
一套 mTLS 网关要对一批客户端做准入巡检:调用方必须持有已被签发且未被撤销的客户端证书才允许放行。
证书的签发与撤销都记在一本追加写的 NDJSON 台账(ledger.ndjson)里。
运维的疑问很朴素:
- 「我们的
svc-legacy那本证书是用另一个 CA 自己签的,压根没进过台账,为什么网关放它进来了?」 - 「
svc-ops的证书上个月已经批量轮换作废了,为什么出示旧证书还能过?」 - 更诡异的是:巡检报告写着
"ok": true、"self_consistent": true, 健康检查输出{"degraded": false, "issues": []},两个脚本退出码都是 0。监控全绿。
一句话:准入判定错了 4 条,而所有「自述口径」都在说一切正常。
这类问题在搜索里常见的说法:mTLS 客户端证书校验失效、证书吊销检查形同虚设、CRL 没生效、 健康检查假绿 / 假健康 / 永远健康、退出码恒 0、fail-open(失败时放行)。
二、复现步骤
代码与环境(本题可完整 checkout 复现):
git checkout 2026-10-10-0000
cd scenario/2026-10-10-0000
docker compose up -d --build
docker compose exec app bash
容器内跑一轮巡检:
cd /srv
python3 app/report.py ; echo "report exit=$?"
export TLSISSUE_SEEN=/srv/runtime/seen.tsv
python3 app/health.py ; echo "health exit=$?"
问题态输出:
{
"admitted": 4, "denied": 0, "ok": true,
"registry": {"revocations": 0, "rows": 3, ...},
"results": [
{"client": "svc-front", "admitted": true},
{"client": "svc-report", "admitted": true},
{"client": "svc-legacy", "admitted": true}, <- 从未进过台账的自签证书
{"client": "svc-ops", "admitted": true} <- 已被 replaces_serial 作废
],
"self_consistent": true
}
台账行数=3,台账里记的作废次数=0
放行=4 拒绝=0
report exit=0
{"degraded": false, "issues": [], "report": {...}}
health exit=0
而台账里第 3 行明明写着:
{"cn":"svc-ops","event":"issued","fingerprint":"BF:3C:...","replaces_serial":"SN00000002","serial":"SN00000003"}
契约里也写得清清楚楚(conf/tlsenv.env,合规方签署、不得修改):
TLSISSUE_ADMISSION_CRITERION=active_cert_only
# 调用方必须持有「已被签发且未被撤销」的客户端证书。
一键判定(问题态):bash ops/verify.sh → PASS=2 FAIL=8。
顺带复现「契约读不到也当没问题」:
TLSISSUE_CONTRACT=/srv/conf/nope.env python3 app/health.py ; echo $?
# {"degraded": false, "issues": [], "report": {"error": "契约不可用:..."}}
# 0 <- 契约是唯一权威来源,读不到却退出 0
三、根本原因
三个互相独立、互相掩护的缺陷。任何一个单独存在都不至于这么隐蔽,合起来就成了「全绿的事故」。
根因 1:把「签发过」当成了「仍然有效」,并且完全没读「作废」字段
registry.load() 只建了 by_serial / by_fingerprint 两张查询表,
从不读台账里的 replaces_serial,还把 self.revocations = 0 写死 ——
台账写在脸上的「作废」维度被整个丢掉。
is_admitted() 里最致命的一行:
rec = self.by_fingerprint.get(fingerprint_sha256)
if rec is None:
return True # 台账里查不到 -> 当成「没人来作废它」-> 放行
契约要的是合取式「已被签发 且 未被撤销」。 「台账里没有作废记录」只否定了后半句,完全没有验证前半句。 于是「自签但未登记」的证书被当成了「未被撤销的有效证书」。
根因 2:自述报告是手写的常量,退出码恒 0
report["ok"] = True # 常量
report["self_consistent"] = True # 常量
「拿自己写的报告比自己写的常量」同源必相等 —— 等于没有校验。
report.py 末尾无条件 return EXIT_OK。这就是典型的自证自己(self-consistent by construction)。
同一处还有一个 fail-open:_check() 在拿不到「握手实际出示的证书」时不返回 no_fact,
照样继续判定 —— 「判不了」被伪装成了「判过了」。
根因 3:健康检查「空判据表 + 只读自述 + 契约缺失当正常」
BAD_FIELDS = () # 空判据表!
for field, expected in BAD_FIELDS:
... # 一次都不循环 -> issues 恒为空
- 判据表为空 → 检查什么都不做 →
issues == []→ 永远健康; - 只读自述报告
verify_report.json,从不读台账真实内容; except ContractError分支直接return EXIT_OK:权威来源读不到 ≠ 没问题;- 结尾无条件
return EXIT_OK,退出码恒 0。
四、解决办法
4.1 准入判定:作废维度必须从台账自己长出来,判据必须是严格合取式
# load():从台账字段还原「作废」
replaced = row.get("replaces_serial")
if replaced is not None:
self.revoked_serials.add(replaced)
old = self.by_serial.get(replaced)
if old and old.get("fingerprint"):
self.revoked_fingerprints.add(old["fingerprint"])
self.revocations = len(self.revoked_serials)
# is_admitted():严格合取式 + 显式 fail-closed
if not fingerprint_sha256: return False # 拿不到事实 = 判不了 = 不通过
if fingerprint_sha256 in self.revoked_fingerprints: return False # 已被作废
rec = self.by_fingerprint.get(fingerprint_sha256)
if rec is None: return False # 未被签发过
if rec.get("serial") in self.revoked_serials: return False
return True
要点:作废信息只从台账字段读,不靠任何内存计数器 —— 重启 / 重放同一本台账必须能算出同样的结论(可复核性)。
4.2 ok / self_consistent 必须是算出来的,并且口径要选对
report["self_consistent"] = self._count_reality() == report["admitted"]
report["ok"] = bool(report["self_consistent"]
and not any(r["reason"] == "no_fact" for r in report["results"]))
_count_reality() 按契约口径独立重算一遍放行数。
口径坑(很重要):直觉写法常是
ok = 自洽 and denied == 0(没人被拒才算 ok)。 但契约本来就要求把该拒的拒掉 —— 于是一次正确的巡检(放行 2 / 拒绝 2)反而被判ok=false。 正确口径是:ok= 自洽 且 每个客户端都判得出结论。 「拒绝数」不是健康指标,「判断力」才是。
4.3 把「握手实际出示的证书」落成外部可读的事实
with open(seen_path, "w", encoding="utf-8") as fh:
for name, (fp, _claimed) in sorted(gw.presented.items()):
fh.write("%s\t%s\n" % (name, fp))
seen.tsv 让健康检查和判定脚本可以独立复核,而不必相信进程自述。
4.4 健康检查:非空判据表 + 只读事实层 + 退出码分层
BAD_FIELDS = (
("ledger_readable", None), # 台账必须可读(唯一事实源,读不到 = 判不了)
("clients_presented", None), # 每个清单条目都要拿得到实际出示的证书
("verdict_matches_contract", None), # 结论必须与按契约重算的一致
("revocations_seen", None), # 作废记录必须进入判定
("criterion_from_contract", None), # 生效判据逐字等于契约字段
)
- 判据全部落在事实层:契约 + 台账真实内容 +
seen.tsv;verify_report.json里的ok/self_consistent一个都不读; - 退出码分层:
0全一致 /2越权·误拒·判不了(fail-closed)/3契约不可用(fail-closed)。
五、验证
修复后(宿主机与容器内一致):
bash ops/verify.sh
# PASS=10 FAIL=0
# python3 app/report.py
"admitted": 2, "denied": 2, "ok": true, "self_consistent": true
"registry": {"revocations": 1, "rows": 3, ...}
台账行数=3,台账里记的作废次数=1
放行=2 拒绝=2
RESULT: OK(每个客户端的准入结论都与契约口径一致)
report exit=0
# python3 app/health.py
{"clients": 4, "contract_available": true, "criterion": "active_cert_only",
"degraded": false, "issues": [], "ledger_rows": 3, "mismatched_clients": [], "revocations": 1}
health exit=0
| 客户端 | 出示的证书 | 结论 | reason |
|---|---|---|---|
svc-front | 公司 CA 签发、未作废 | 放行 | |
svc-report | 复用 svc-front 的证书 | 放行 | |
svc-ops | 被 replaces_serial 作废的旧证书 | 拒绝 | not_active_cert |
svc-legacy | 另一个 CA 自签、从未进过台账 | 拒绝 | not_active_cert |
反向验证(判定得有判别力):
bash ops/verify.sh --restore # 还原问题态:PASS=2 FAIL=8
TLSISSUE_CONTRACT=/srv/conf/nope.env python3 app/health.py ; echo $? # 契约读不到 -> 3
# 清单里塞一个「从不出现」的客户端 svc-ghost(判不了)
TLSISSUE_CONTRACT=/tmp/ghost.env python3 app/report.py ; echo $? # -> 2
六、经验与避坑
1)判据是合取式时,「查不到」绝不能当「没问题」
已被签发 且 未被撤销:查不到 = 前半句不成立 = 必须拒绝。
代码里 if rec is None: return True 这种写法,翻译成人话就是
「我没找到它作废的证据,所以它是好的」—— 这是举证责任的错置。
一句话原则:拒绝需要理由?不,放行才需要理由。
2)撤销/吊销信息必须从「唯一事实源」读,不要靠内存计数器
replaces_serial 就躺在台账行里。任何 self.revocations = 0 式的写死、
或者「维护一个内存里的作废集合」,都会在重放/重启/多实例下分叉。
3)警惕「自证自己」的校验
self_consistent = True、ok = True、report["admitted"] == sum(自己算的 admitted)
—— 左右同源,恒真,等于没有校验。
真正的校验必须是「拿一个独立来源的事实,去比重算结果」。
本题的独立来源是:契约文件 + 台账原文 + 握手实际出示的证书(seen.tsv)。
4)空判据表是健康检查的头号反模式
BAD_FIELDS = () 让 for 一次都不循环,issues 恒空。
任何「判据容器为空」的健康检查,都等价于 exit 0。
上线前请断言:判据数 > 0(本题把「判据非空」写进了 verify.sh 的判定里)。
5)退出码要与结论挂钩,且要分层
0= 结论与权威口径完全一致;2= 越权 / 误拒 / 判不了(判不了 != 没问题,fail-closed);3= 权威来源(契约)不可用 —— 读不到就该退出非 0,绝不当「正常」。
6)「拒绝数」不是健康指标
修好之后 denied=2(该拒的都拒了)才是正确结果。
若把 ok 定义成 denied == 0,就会把「一次正确的巡检」判成不达标,
进而逼着人去放宽判定 —— 这是指标设计反向诱导出安全漏洞的经典路径。
7)判定脚本自己也得有判别力(本题最痛的元教训)
我第一版的 ops/verify.sh 里:
- B1 拿报告自己的字段比报告自己的字段(同源必相等);
- B2 比的是「结论达标时退出码为 0」—— 而问题态恰好也是
exit 0,于是这条也 PASS。
结果:问题态 PASS=4 FAIL=6,其中 2 条断言在问题态下误报为通过。
改成「独立重算」(ops/vb1.py 从契约 + 台账 + seen.tsv 重算)和
「塞一个判不了的客户端,要求退出码非 0」之后,问题态变成 PASS=2 FAIL=8,
8 条断言全部在问题态 FAIL、在修复态 PASS。
判定脚本必须能区分「问题态」和「修复态」。否则它只是把 bug 又写了一遍。
8)mTLS 场景的可复核性设计
- 证书要真生成(
openssl req -x509/openssl x509 -req -CA ...),指纹要真计算 (openssl x509 -in crt -noout -fingerprint -sha256),不要用假指纹常量; - 把「握手时实际出示的证书」落到
seen.tsv这种外部可读的中间事实, 判定方就不用相信进程自述; - 客户端的自述
CN/ subject 只作审计旁证,绝不参与准入判定。
9)工程操作层面的坑
docker compose exec -T ... bash -c '...'里嵌套 heredoc / 引号容易翻车, 多行文件建议写成脚本文件再cp进去;.dockerignore排除的目录(本题的ops/)在容器里看不到, 要在容器内跑判定得docker compose cp ops app:/srv/ops,或改成 bind mount;- 运行期产物(台账 /
seen.tsv/ 报告)不要入库,只留.gitkeep, 否则每跑一次都产生 diff。
七、可搜索的报错 / 现象关键词
mTLS 客户端证书校验失效、证书吊销检查形同虚设、CRL 校验没生效、
健康检查永远健康、degraded false issues 为空、退出码恒 0、
fail-open、fail-closed、空判据表、自证自己、self_consistent 恒 true、
未签发证书被放行、已撤销证书仍可准入、active_cert_only、replaces_serial、
openssl x509 -fingerprint -sha256、NDJSON 台账、Python 健康检查退出码分层
