审计日志哈希链「一条都没改过」却报 11 条断链:prev 写的是哈希,校验方却拿序号去比
关键词:审计日志、append-only 账本、哈希链 hash chain、防篡改 tamper-evident、
prev指针、链式摘要 chained digest、前一条记录的哈希 vs 序号、 跨账本轮转(ledger rotation)、链头封存 seal、自述字段自证、 健康检查判据表为空、退出码恒 0、fail-closed、chain_digestCHAIN_PREV_MODEprev_reference、 Python 完整性校验器写错口径
一、问题背景
一套审计写入链路(auditd)按 append-only 方式逐条追加事件到账本文件,
每条记录带一个 prev 字段(链式指针),写满一本就轮转(rotation),
轮转时把链头摘要「封存」进 chain-seals.json。
另有一个外部独立校验器(app/verifier.py)逐条重算链条,
还有一个健康检查(app/health.py)给出结论与退出码。
契约(唯一权威来源)conf/chain-policy.env:
CHAIN_PREV_MODE=chain_digest # 一条记录的 prev = 上一条记录的**哈希**
CHAIN_GENESIS_PREV=GENESIS # 首条记录的 prev
CHAIN_ALGO=sha256
CHAIN_SEAL_EVERY_LEDGER=1
现象:一轮审计「跑完了」、写入方自述全绿、health 退出码 0,
但校验器把一份完好的账本报成了 11 条断链:
$ bash ops/repro.sh
== 1. 跑一轮审计(12 条事件,4 个账本,每本 3 条)==
round OK 记录=12 账本=4
verify ok=False 记录=12 断链=11
demo.py 退出码 = 1
== 3. 写入方自述 ==
report.json = {"ok": true, "self_consistent": true, "records_written": 12,
"records_failed": 0, "ledgers_written": 4} ← 全绿
== 4. 校验方按契约逐条重算 ==
断链:账本=ledger-000.ndjson seq=1 实测 prev=27142258baeb68a3 期望 prev=0
断链:账本=ledger-000.ndjson seq=2 实测 prev=... 期望 prev=1
断链:账本=ledger-001.ndjson seq=3 实测 prev=... 期望 prev=GENESIS
…(共 11 条)
== 5. 健康检查 ==
{"degraded": false, "issues": [], "self_consistent": true, "records_written": 12}
health 退出码 = 0 ← 判据表为空 + 恒 0
== 6. 外部独立探针(只读契约 + 账本 + 封存)==
账本记录合计 = 12(契约应得 12)
断链 / 不一致 = 0
RESULT: OK ← 账本其实是完好的
12 条记录里有 11 条「对不上」,可账本一条都没被改过。 这是典型的**「写入方与校验方对同一个字段用了两套口径」**。
二、复现步骤
只需 Python 3.11(全部标准库)。两种方式任选:
cd scenario/2026-10-06-0000
# 方式 A:本机直接跑
bash ops/repro.sh # 一键复现:跑链路 → 自述 → 校验 → 健康检查 → 外部探针
bash ops/verify.sh # 验收:问题态 PASS=6 FAIL=2 / RESULT: BAD
# 方式 B:docker 一键(服务名是 auditd,不是 app)
docker compose up -d --build
docker compose exec -T auditd bash ops/repro.sh
docker compose exec -T auditd bash ops/verify.sh
问题态验收输出:
== C. 校验方不得把完好的账本判成篡改 ==
[FAIL] 校验结论 ok(True=账本完好) (期望=True 实际=False)
[FAIL] 校验方报出的断链数(0=没有误报) (期望=0 实际=11)
== F. 真实篡改必须被抓到(写坏一条记录的 prev) ==
[PASS] 真实篡改会被校验方抓到 (断链=11)
PASS=6 FAIL=2
RESULT: BAD
一条命令坐实「两套口径」:
python3 -c "
import sys; sys.path.insert(0,'.')
from app import chain, contract, ledger
policy=contract.load_chain_policy()
recs=ledger.read_ledger('ledger/ledger-000.ndjson')
print('seq=1 真实 prev =', recs[1]['prev'])
print('上一条(seq=0)的哈希 =', chain.prev_reference(recs[0], policy))
print('校验方当前期望值 =', str(recs[0]['seq']))
"
seq=1 真实 prev = 27142258baeb68a3
上一条(seq=0)的哈希 = 27142258baeb68a3 ← 完全一致,账本没被改
校验方当前期望值 = 0 ← 拿的是「序号」
三、根本原因
3.1 校验方自己另算了一套 prev 口径(题眼)
app/verifier.py(问题态):
for index, record in enumerate(records):
if index == 0:
expected = policy["genesis_prev"]
else:
expected = str(records[index - 1].get("seq")) # ← 拿「上一条的序号」
写入方 app/auditd.py 走的却是契约唯一实现:
prev = chain.prev_reference(self.current[-1] if self.current else self._carry(), self.policy)
而 chain.prev_reference 在 CHAIN_PREV_MODE=chain_digest 下返回
上一条记录的哈希(digest_of(prev_record, algo))。
两套口径长得像(都在描述「上一条记录」), 不抛异常、不报错、日志零 ERROR —— 于是好好的账本被判成 11 条断链。
第二个错:问题态 verify_ledger 没有 carry 概念,
每个账本的第一条都被当成链头(index == 0 → GENESIS)。
但链是跨账本延续的:
seq=3 的真实 prev = c9ec60e8444fe354
上一本最后一条哈希 = c9ec60e8444fe354 ← 链跨账本延续
校验方期望值 = GENESIS ← 校验方把每本当成独立链头
所以跨账本的第一条(seq=3/6/9)必然误报。
3.2 健康检查没有牙
app/health.py(问题态):
def main(argv=None) -> int:
...
issues = [] # ← 判据表压根不存在
print(json.dumps({"degraded": False, "issues": issues, ...}))
return EXIT_OK # ← 恒定 0
三重缺陷:
- 没有
BAD_FIELDS判据表(空表 = 关掉整个健康检查); - 只读
report.json(写入方自己的计数器),不读账本文件事实; main()末尾无条件return EXIT_OK—— 「判不了」被当成「没问题」。
3.3 为什么难查
- 两套口径长得像,不抛异常、不报错;
- 写入方自述(
report.json的ok/self_consistent)与健康检查 都取自写入方自己的计数器,于是四个绿灯没有一件指向真相; - 唯一说真话的是外部独立探针(只读契约 + 账本文件 + 封存文件)。
四、解决办法
只改两个文件:app/verifier.py、app/health.py。
conf/*.env、client/chain_probe.py、ops/verify.sh、ops/negative.sh 不动。
4.1 app/verifier.py:prev 一律走契约口径 + 链条跨账本延续
def verify_ledger(path, name, policy, carry=None):
"""carry = 上一个账本的最后一条记录;链是跨账本延续的。"""
result = VerifyResult(policy)
records = ledger.read_ledger(path)
result.records = len(records)
result.by_ledger[name] = len(records)
prev = carry # ← 上一本的尾巴带进来
for index, record in enumerate(records):
expected = chain.prev_reference(prev, policy) # ← 唯一来源,不许另算
got = record.get("prev")
if got != expected:
result.chain_broken.append({
"ledger": name, "seq": record.get("seq"), "index": index,
"prev": got, "expected_prev": expected, # ← 可外部独立复核的证据
})
prev = record
result.last = prev
return result
def verify_all(ledger_dir, prefix, count):
...
carry = None
for i in range(count):
result = verify_ledger(..., carry)
total.chain_broken.extend(result.chain_broken)
carry = result.last # ← 交给下一个账本续链
return total
三个要点:
chain.prev_reference(prev, policy)是 prev 口径的唯一来源 (与写入方同一个函数);carry让链条跨账本延续;- 断链证据带
ledger / seq / prev / expected_prev,可外部独立复核。
4.2 app/health.py:判据表非空 + 读账本事实 + 退出码分层
# 判据表:字段 → 期望值,None = 只要求字段存在。空表等于关掉整个健康检查。
BAD_FIELDS = (
("ok", True),
("records", None),
)
_issues_for() 三条判据全部落在账本文件事实上:
- 校验结论
verify_report["ok"](来自按契约重算账本), 并把前 5 条断链的证据带进 issues; - 账本真实条数 vs 契约应得
records_per_ledger × ledger_count,缺账本也算 issue; - 封存锚定值
chain-seals.json[name]vs 账本真实链头chain.head_digest(records, policy),并检查「不在册的账本」。
main() 退出码分层:
if not os.path.exists(verify_path): return EXIT_BAD # 拿不到数据 → 2(fail-closed)
except (OSError, ValueError): return EXIT_BAD
except contract.ContractError: return EXIT_CONTRACT # 契约不可用 → 3
...
return EXIT_BAD if degraded else EXIT_OK # 不再无条件 0
退出码契约:0 正常 / 2 有真问题或拿不到数据 / 3 契约不可用或自相矛盾。
五、验证
5.1 验收脚本 PASS=8 FAIL=0
docker compose exec -T auditd bash ops/verify.sh
[PASS] 契约口径下断链的记录数(0=账本完好) (期望=0 实际=0)
[PASS] 账本记录合计 (期望=12 实际=12)
[PASS] 校验结论 ok(True=账本完好) (期望=True 实际=True)
[PASS] 校验方报出的断链数(0=没有误报) (期望=0 实际=0)
[PASS] 账本完好时 health 仍应放行(0=放行) (期望=0 实际=0)
[PASS] 完好账本下 health 退出码(0=正常) (期望=0 实际=0)
[PASS] 真实篡改会被校验方抓到 (断链=2)
[PASS] 外部探针判定 (期望=OK 实际=OK)
PASS=8 FAIL=0
RESULT: OK
修复后 ops/repro.sh:verify ok=True 记录=12 断链=0、demo.py 退出码 = 0、
外部探针 断链 / 不一致 = 0 / RESULT: OK。
5.2 独立重算(不依赖校验器自身)
docker compose exec -T auditd python3 - <<'PY'
import sys,os; sys.path.insert(0,'.')
from app import chain, contract, ledger
policy=contract.load_chain_policy(); conf=contract.load_auditd()
prev=None; bad=0
for i in range(conf['ledger_count']):
for r in ledger.read_ledger(os.path.join(os.environ['LEDGER_DIR'],
'%s-%03d.ndjson'%(conf['ledger_prefix'],i))):
if r['prev']!=chain.prev_reference(prev,policy): bad+=1
prev=r
print('独立重算(带 carry)断链 =',bad)
PY
# → 独立重算(带 carry)断链 = 0
5.3 负向验证:证明闸门真的有牙
docker compose exec -T auditd bash ops/negative.sh
| 组 | 注入 | 实测结果 | 说明 |
|---|---|---|---|
| 基线 | 应用参考答案 | PASS=8 FAIL=0 | ✓ |
| A | 校验口径改回「序号当 prev」 | PASS=4 FAIL=4 / RESULT: BAD | ✓ 立刻转红 |
| B | 判据表清空、不读账本 | health 退出码 = 0 | ✓ 证明判据表就是那颗牙 |
| C | 删掉 conf/chain-policy.env | health=3 / demo.py=3 | ✓ 大声失败 |
| D | CHAIN_PREV_MODE=record_index | [FATAL] 契约不可用 demo.py=3 | ✓ |
| E | 封存值改成 deadbeefdeadbeef | health=2,报「封存值 … ≠ 真实链头 …」 | ✓ |
| 收尾 | 恢复后复验 | PASS=8 FAIL=0 / RESULT: OK | ✓ |
真实篡改必须带证据:
{"degraded": true, "issues": [
"判据字段 ok 期望 True 实际 False",
"哈希链校验不通过:断链 2 条",
"断链:账本=ledger-001.ndjson seq=4 prev=TAMPERED00000000 期望=d556f740c8a5d66a",
"断链:账本=ledger-001.ndjson seq=5 prev=528bb2a7ec927371 期望=16bc71b17814b2d5"],
"self_consistent": false, "records": 12}
exit=2
六、经验与避坑
6.1 踩坑记录
health读的是「上一次落盘的 verify.json」 手工篡改账本后直接跑python3 -m app.health得到exit=0, 一度以为健康检查还是没牙。实际原因是verify.json是上一轮干净账本 跑demo.py时落盘的,health 读的是陈旧报告。 正确姿势:篡改后必须重跑demo.py(或重算并刷新verify.json), 让 verify 报告反映当前账本,再看 health。ops/negative.sh的restore()会覆盖app/为参考答案 (cp ops/expected-fixed/*.py app/)。跑完它之后app/里已经不是自己的解法, 必须把自研版本重新写回,否则提交的是参考答案。ops/verify.sh的 E 组在完好账本下TAMPER_CHAIN_BROKEN会是 0 因为 E 组脚本里紧接着重跑了demo.py重建干净账本。 F 组的TAMPER_CHAIN_BROKEN=2才是真实篡改的检出数,别误判为「没抓到篡改」。- 服务名是
auditd(容器名3day-106-auditd),docker compose exec要用服务名,且非交互环境必须加-T(否则报the input device is not a TTY)。 app/__pycache__会让改动看起来「不生效」。 改完app/*.py后先rm -rf app/__pycache__。app/、conf/、client/、ops/都是主机挂载,改完无需 rebuild。
6.2 核心认知
- 「前后指针」字段必须只有一个口径来源。
哈希链 / 区块链 / 审计账本 / git commit 的
parent, 写入侧与校验侧必须是同一个函数算出来的。 一旦各写一套(哈希 vs 序号),两套口径长得像、不报错,就会静默误报。 - 跨分片 / 跨文件 / 跨账本时,新一片第一条的 prev 接的是「上一片最后一条的内容摘要」,不是
GENESIS。 校验时要把上一片的最后一条记录(carry)带进下一片。 - 完整性校验失败时,必须给出可外部独立复核的证据
(哪一条、实测值、期望值、按哪条契约算的),而不是一句
ok: false。 - 健康检查判据必须读被检对象的事实文件,不能拿自己的计数器自证。
判据表不能为空,
main()不能无条件return 0。 - 「判不了」≠「没问题」。 拿不到数据 / 契约缺失时必须 fail-closed,
退出码非 0(本题用
3表示契约不可用、2表示有真问题)。 - 契约驱动的一致性开关(
CHAIN_PREV_MODE这种「口径开关」型配置), 读不到或写成别的值时必须大声失败,不许静默按某一种认。 - 一个恒真的判据比一个会报错的判据危险得多。
issues = []、self_consistent = True、return 0都是静默地骗人。
6.3 自查三问
- 你的链式指针是用上一条的内容摘要还是上一条的序号?
写入侧与校验侧是不是同一个函数算出来的?
跨分片 / 跨文件 / 跨账本时,新的一片第一条的
prev接的是谁? - 一个「完整性校验」报失败时,能不能拿出来一份可外部独立复核的证据
(哪一条、实测值、期望值、按哪条契约算的)?
还是只有一句
ok: false/tamper detected? - 你的健康检查判据,取的是自己写的计数器还是被检对象的事实文件?
判据表是不是空的?
main()是不是无条件return 0? 「判不了」(拿不到数据 / 契约缺失)时,是放行还是拒绝?
6.4 同类影响面
- 哈希链 / 防篡改账本:审计日志、区块链、Merkle 链、
certificate transparency、
gitcommit 的parent、rekor的 transparency log、WAL 的 LSN vs 摘要 - append-only 日志轮转:
logrotate后新文件的第一条、 分片归档跨文件续链、Kafka 消费位点跨 partition - 一切「前后指针」字段:链表
next/prev、fsck的目录项链、inode链接计数 - 一切「自述字段自证」的健康检查:
self_consistent: true手写常量、BAD_FIELDS = ()空判据表、return 0恒定退出码 - 契约驱动的一致性:口径开关型配置读不到时的静默 vs 大声失败
七、复现这条实验(照抄即可)
cd scenario/2026-10-06-0000
docker compose up -d --build
# ① 看问题态
docker compose exec -T auditd bash ops/apply_fix.sh --restore
docker compose exec -T auditd bash ops/verify.sh # PASS=6 FAIL=2 / RESULT: BAD
docker compose exec -T auditd bash ops/repro.sh # 11 条断链 + health 退出码 0
# ② 按第四节改 app/verifier.py 与 app/health.py,清缓存再验
docker compose exec -T auditd bash -c "rm -rf app/__pycache__ && bash ops/verify.sh"
# → PASS=8 FAIL=0 / RESULT: OK
# ③ 负向验证(跑完记得把自研版本写回 app/)
docker compose exec -T auditd bash ops/negative.sh
docker compose down
- 问题态快照:
ops/problem-state/ - 参考答案:
ops/expected-fixed/ - 完整操作过程记录:
scenario/2026-10-06-0000/SOLUTION.md
