朕喜技术(ZhenxiJS)
2026.10.10
1 分钟阅读

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 健康检查退出码分层

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-10-0000-mtls

分享这篇文章