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

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。

现场现象,三个「全绿」互相掩护、对监控完全不可见:

  1. 一张客户端证书被撤销,crl.pem 的 md5sum 前后确实不同,CRL 里也真的列出了该序列号; 轮换器打印 [rotator] published CRL -> /srv/pki/crl.pem,退出码 0。
  2. 该客户端再次请求仍然 200 OK,网关审计日志 allowed=true reason=ok。
  3. 网关日志、健康检查、自检报表 /srv/state/report.json 全部正常; 报表里那句 self_test: "certificate revocation list is configured" 永远是绿的。
  4. 重启网关之后就正常了:同样的客户端立刻 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 字节再写入,两个后果:

  1. inode 不变 → 依赖 inode 变化做失效判断的读者永远发现不了更新;
  2. 截断与写入之间有窗口,并发读者可能读到 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: ...),沿用上一次成功快照,不抛异常。

六、经验与避坑

  1. 「配置了」≠「在生效」。自检报表 self_test: "certificate revocation list is configured" 永远绿,是因为它走 force=True 真读盘,而真正判定用的握手路径读的是陈旧缓存。 排查这类问题要看实际判定路径,不要只看自检报表/健康检查。
  2. 不要用 md5sum 作为「轮换已生效」的证据。B1(md5 变了)一路绿,C1 照样 FAIL。 文件内容变了 ≠ 服务读到了新内容。
  3. 缓存失效必须真的比较。算了指纹却只存不比,是极其常见的隐蔽缺陷 —— 代码"看起来"已经把 dev/ino/mtime/size 都准备好了,唯独漏了那句 if。
  4. 同名覆盖场景下,inode 有时是唯一可靠的变更信号,有时又完全不可靠 —— 取决于写入方式。就地 open(..., "wb") 不改 inode;原子 os.replace() 才改 inode。 所以「发布方式」和「失效判断」必须一起设计。
  5. 发布共享文件一律用「临时文件 + fsync + os.replace」。 就地截断改写会产生「0 字节 / 半截文件」窗口,让并发读者炸掉; 而「读到空文件」在很多场景下会被解释成「没有条目」= 静默 fail-open。
  6. 读不到 ≠ 没有。「文件不存在」「解析失败」绝不能退化成「空列表」, 否则正好把「本该拒绝」变成「放行」。要么沿用旧快照,要么明确失败。
  7. 验收前务必 docker compose down -v。pki 是 named volume, 上一轮「已被撤销」的状态会串场:不 down -v 连跑第二次,C2 会因 fresh-client 上一轮已被撤销而 FAIL(PASS=11 FAIL=1)。这是验收脚本固有的 状态残留,不是修复引入的。
  8. 改 COPY 进镜像的代码必须 up -d --build,docker compose restart 跑的还是旧代码。
  9. 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             # 一键复现

版权声明

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

分享这篇文章