mTLS 网关 CRL 轮换后被撤销证书照样放行:同名覆盖 + 进程内永久缓存 + 就地截断改写
日期:2026-09-28-0000 | 第87题 | 分支:
2026-09-28-0000技术栈:Docker / Docker Compose、Python 3.12、ssl、cryptography、openssl 3、X.509 / CRL / mTLS 关键词:mTLS 双向认证、客户端证书撤销、CRL 证书撤销列表、certificate revoked、 CRL 同名覆盖轮换、进程内永久缓存、启动时加载一次、缓存失效指纹比较、 Python ssl CERT_REQUIRED、DER 解析、原子发布、os.replace、fail-open、静默失效、 被撤销证书仍然 200、重启才生效、配置了不等于生效了
一、问题背景
gateway 是一个 mTLS 网关:客户端必须出示由内网 CA 签发的证书,网关每次握手时用
Python 侧的吊销校验判定客户端证书是否已被撤销。运维侧有个轮换器同名覆盖发布 crl.pem。
现场现象,三个「全绿」互相掩护、对监控完全不可见:
- 一张客户端证书被撤销,
crl.pem的md5sum前后确实不同,CRL 里也真的列出了该序列号; 轮换器打印[rotator] published CRL -> /srv/pki/crl.pem,退出码 0。 - 该客户端再次请求仍然
200 OK,网关审计日志allowed=true reason=ok。 - 网关日志、健康检查、自检报表
/srv/state/report.json全部正常; 报表里那句self_test: "certificate revocation list is configured"永远是绿的。 - 重启网关之后就正常了:同样的客户端立刻
403 Forbidden certificate-revoked。
一句话:「配置了吊销校验」和「吊销校验真的在生效」是两件事, 而网关把它们当成了同一件事。
二、复现步骤
git fetch origin && git checkout 2026-09-28-0000
cd scenario/2026-09-28-0000
docker compose down -v --remove-orphans
docker compose up -d --build
sleep 8
sh ops/verify.sh # 问题态:PASS=8 FAIL=4 / RESULT: BAD
sh ops/repro.sh # 一键看完整因果链
问题态关键输出:
[FAIL] C1 已撤销的证书仍被放行(吊销校验失效)
[FAIL] C3 拒绝理由不是 certificate-revoked
[FAIL] D1 client/check_revocation.py => 'RESULT: BAD'
[FAIL] E1 CRL 轮换就地改写(读者可能读到半截文件;本次轮换器返回 'INPLACE')
== 3. CRL 轮换后的指纹(路径没变、内容变了)==
e3b9bed26e647d995b8b7da69e38c58c /srv/pki/crl.pem <-- md5 确实变了
CRL 条目数 = 1 撤销序列号 = [4100] <-- 新 CRL 里真的有它
== 4. 端到端验收 ==
[2] CRL republished: inode 45108333683 -> 45108333683 (content updated: False) <-- inode 没变!
[3] after revoke : HTTP/1.1 200 OK allowed=True reason=ok <-- 仍然放行
RESULT: BAD
== 6. 重启网关后再试 ==
HTTP/1.1 403 Forbidden allowed=False reason=certificate-revoked <-- 重启就好了
三、根本原因
根因 A:缓存「算了指纹但从不比较」—— 进程内永久快照
app/revocation.py:
if not force and _crl_cache["entries"] is not None:
return _crl_cache["entries"] # <-- 读过一次就永久复用
...
ident = _file_identity(st) # <-- 指纹算出来了,却只存不用
_file_identity() 返回 (dev, ino, mtime_ns, size),字段也老老实实写进了 _crl_cache,
但失效判断那句比较从来没写。所以「同名覆盖」更新后(路径/文件名都不变),
只要进程活着就永远返回第一次的快照。
在活着的网关进程内直接取证(决定性证据):
[1] 网关进程当前缓存 : [] (启动时读的)
[2] 轮换器撤销 legacy-client(4102) 并同名覆盖 crl.pem ...
磁盘 crl.pem 现在 : [4102]
磁盘 mtime_ns : 1790526410972099995
缓存里的 mtime_ns : 1790526407430121279
指纹变了吗? : True
[3] load_crl() 返回 : [] <-- 指纹明明变了,仍旧返回旧值
[4] 4102 被判为撤销吗?: False (应为 True)
同时 report.json 显示 crl_entries: 0,而磁盘 crl.pem 已有 [4100, 4101] —— 自检与实况背离。
原因:自检走 load_crl(force=True) 每次都真读盘,所以它永远绿,掩盖了握手路径读陈旧缓存。
根因 B:轮换器就地截断改写 —— 并发读到半截 DER
app/rotator.py:
with open(new_crl, "rb") as src, open(target, "wb") as dst:
dst.write(src.read()) # <-- "w" 先截断为 0 字节,再写
open(target, "wb") 会先把目标文件截断成 0 字节再写入,两个后果:
- inode 不变 → 依赖 inode 变化做失效判断的读者永远发现不了更新;
- 截断与写入之间有窗口,并发读者可能读到 0 字节或写了一半的 DER。
并发竞态实测(一个线程狂读 crl.pem,另一侧连续轮换 12 次):
reader 读了 34662 次
读到半截/异常次数: 12
(0, 'IndexError', 'index out of range') <-- 读到 0 字节,解析直接炸
这些异常在网关里会被宽泛的 except 吞掉;更糟的是「空 CRL = 没有任何撤销项」
本身就是一个静默 fail-open(本该拒绝的证书反而被放行)。
四、解决办法
修复 A:app/revocation.py —— 每次调用都比指纹,加 TTL 兜底 + fail-safe
def load_crl(force=False):
path = _crl_path()
now = time.time()
if not force:
try:
st = os.stat(path)
except OSError:
# 文件暂时不在(原子替换的极短窗口)→ 沿用旧快照,绝不当作「无撤销项」
return _crl_cache["entries"] if _crl_cache["entries"] is not None else []
ident = _file_identity(st)
if ident == _cached_identity() and _crl_cache["entries"] is not None \
and (now - (_crl_cache["loaded_at"] or 0.0)) < _ttl_seconds():
return _crl_cache["entries"] # 指纹一致 + 未超 TTL,才复用
...
except (OSError, ValueError, IndexError) as exc:
print(f"[WARN] CRL 读取/解析失败({type(exc).__name__}: {exc}),沿用上一次成功快照")
return _crl_cache["entries"] if _crl_cache["entries"] is not None else []
要点:
- 每次调用都
os.stat()取指纹并与缓存比较,指纹变了才重解析 → 保留「不重复做昂贵 DER 解析」的初衷; - 加 TTL 兜底(
CRL_CACHE_TTL_SECONDS,默认 5s),防文件系统 mtime 精度不足; - 读盘/解析失败不抛异常,沿用上一次成功快照 —— 「读不到」绝不能被当成「没有撤销项」。
修复 B:app/rotator.py —— 临时文件 + fsync + os.replace 原子发布
publish_dir = os.path.dirname(target) or "."
fd, tmp_path = tempfile.mkstemp(prefix=".crl.publish-", dir=publish_dir)
try:
with os.fdopen(fd, "wb") as dst:
with open(new_crl, "rb") as src:
dst.write(src.read())
dst.flush()
os.fsync(dst.fileno()) # 内容先落盘,再改名
os.chmod(tmp_path, 0o644)
os.replace(tmp_path, target) # POSIX 原子替换
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
os.replace() 在同一文件系统内是原子的:读者要么看到旧文件、要么看到新文件,
不存在中间态;且 inode 会变化,指纹判断也能正常工作。
五、验证
cd scenario/2026-09-28-0000
docker compose down -v --remove-orphans
docker compose up -d --build
sleep 8
sh ops/verify.sh
[PASS] A1 契约声明了 CRL_PATH 唯一来源
[PASS] A2 网关读取的 CRL 路径与契约一致
[PASS] A3 CRL 文件存在且非空
[PASS] B1 轮换后 CRL 内容确实变了(182042d7... -> f8bf2b71...)
[PASS] B2 新 CRL 里确实包含被撤销证书的序列号
[PASS] C1 已撤销的客户端证书被网关拒绝
[PASS] C2 未撤销的客户端证书仍被放行(无误伤)
[PASS] C3 拒绝理由明确为 certificate-revoked
[PASS] D1 client/check_revocation.py => RESULT: OK
[PASS] E1 CRL 轮换走「新建 + 原子重命名」发布
[PASS] F1 契约未把吊销校验关掉(REVOCATION_MODE=file)
[PASS] G1 conf/ 契约文件与基线一致(未改动)
PASS=12 FAIL=0
RESULT: OK
sh ops/repro.sh 修复后(注意 无需重启 就已 403):
[2] CRL republished: inode 55843817926 -> 55843817942 (content updated: True)
[3] after revoke : HTTP/1.1 403 Forbidden allowed=False reason=certificate-revoked
[4] fresh client : HTTP/1.1 200 OK allowed=True
RESULT: OK
并发竞态复测(同样 12 次连续轮换):
reader 读了 42183 次
读到半截/异常次数: 0 <-- 修复前是 12/34662
down -v + up --build 连跑 3 轮,均为 RESULT: OK。
边界用例:CRL 路径不存在 → 返回 [] 不抛异常;CRL 被写成 0 字节 →
打印 [WARN] CRL 读取/解析失败(IndexError: ...),沿用上一次成功快照,不抛异常。
六、经验与避坑
- 「配置了」≠「在生效」。自检报表
self_test: "certificate revocation list is configured"永远绿,是因为它走force=True真读盘,而真正判定用的握手路径读的是陈旧缓存。 排查这类问题要看实际判定路径,不要只看自检报表/健康检查。 - 不要用
md5sum作为「轮换已生效」的证据。B1(md5 变了)一路绿,C1 照样 FAIL。 文件内容变了 ≠ 服务读到了新内容。 - 缓存失效必须真的比较。算了指纹却只存不比,是极其常见的隐蔽缺陷 ——
代码"看起来"已经把
dev/ino/mtime/size都准备好了,唯独漏了那句if。 - 同名覆盖场景下,inode 有时是唯一可靠的变更信号,有时又完全不可靠 ——
取决于写入方式。就地
open(..., "wb")不改 inode;原子os.replace()才改 inode。 所以「发布方式」和「失效判断」必须一起设计。 - 发布共享文件一律用「临时文件 + fsync +
os.replace」。 就地截断改写会产生「0 字节 / 半截文件」窗口,让并发读者炸掉; 而「读到空文件」在很多场景下会被解释成「没有条目」= 静默 fail-open。 - 读不到 ≠ 没有。「文件不存在」「解析失败」绝不能退化成「空列表」, 否则正好把「本该拒绝」变成「放行」。要么沿用旧快照,要么明确失败。
- 验收前务必
docker compose down -v。pki是 named volume, 上一轮「已被撤销」的状态会串场:不down -v连跑第二次,C2 会因fresh-client上一轮已被撤销而 FAIL(PASS=11 FAIL=1)。这是验收脚本固有的 状态残留,不是修复引入的。 - 改
COPY进镜像的代码必须up -d --build,docker compose restart跑的还是旧代码。 - Issue 编号 ≠ 题号:本轮状态文件里
issue: 87指的是「第87题」, 而对应 Issue 实际是#103。跨仓库/跨轮排查时要用--repo+--keyword反查, 不要直接把题号当 Issue 编号用。
七、相关文件
scenario/2026-09-28-0000/
├── README.md # 问题背景 / 复现 / 期望结果
├── SOLUTION.md # 本次解题的完整操作过程记录
├── app/revocation.py # ★ 修复:缓存指纹比较 + TTL + fail-safe
├── app/rotator.py # ★ 修复:临时文件 + fsync + os.replace 原子发布
├── app/server.py # mTLS 网关
├── conf/mtls.env # 契约:CRL_PATH / REVOCATION_MODE(未改动)
├── ops/verify.sh # 验收判据 12 条
└── ops/repro.sh # 一键复现
