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

审计日志哈希链「一条都没改过」却报 11 条断链:prev 写的是哈希,校验方却拿序号去比

关键词:审计日志、append-only 账本、哈希链 hash chain、防篡改 tamper-evident、 prev 指针、链式摘要 chained digest、前一条记录的哈希 vs 序号、 跨账本轮转(ledger rotation)、链头封存 seal、自述字段自证、 健康检查判据表为空、退出码恒 0、fail-closed、 chain_digest CHAIN_PREV_MODE prev_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

三重缺陷:

  1. 没有 BAD_FIELDS 判据表(空表 = 关掉整个健康检查);
  2. 只读 report.json(写入方自己的计数器),不读账本文件事实;
  3. 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

三个要点:

  1. chain.prev_reference(prev, policy) 是 prev 口径的唯一来源 (与写入方同一个函数);
  2. carry 让链条跨账本延续;
  3. 断链证据带 ledger / seq / prev / expected_prev,可外部独立复核。

4.2 app/health.py:判据表非空 + 读账本事实 + 退出码分层

# 判据表:字段 → 期望值,None = 只要求字段存在。空表等于关掉整个健康检查。
BAD_FIELDS = (
    ("ok", True),
    ("records", None),
)

_issues_for() 三条判据全部落在账本文件事实上:

  1. 校验结论 verify_report["ok"](来自按契约重算账本), 并把前 5 条断链的证据带进 issues;
  2. 账本真实条数 vs 契约应得 records_per_ledger × ledger_count,缺账本也算 issue;
  3. 封存锚定值 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.envhealth=3 / demo.py=3✓ 大声失败
DCHAIN_PREV_MODE=record_index[FATAL] 契约不可用 demo.py=3✓
E封存值改成 deadbeefdeadbeefhealth=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 踩坑记录

  1. health 读的是「上一次落盘的 verify.json」 手工篡改账本后直接跑 python3 -m app.health 得到 exit=0, 一度以为健康检查还是没牙。实际原因是 verify.json 是上一轮干净账本 跑 demo.py 时落盘的,health 读的是陈旧报告。 正确姿势:篡改后必须重跑 demo.py(或重算并刷新 verify.json), 让 verify 报告反映当前账本,再看 health。
  2. ops/negative.sh 的 restore() 会覆盖 app/ 为参考答案 (cp ops/expected-fixed/*.py app/)。跑完它之后 app/ 里已经不是自己的解法, 必须把自研版本重新写回,否则提交的是参考答案。
  3. ops/verify.sh 的 E 组在完好账本下 TAMPER_CHAIN_BROKEN 会是 0 因为 E 组脚本里紧接着重跑了 demo.py 重建干净账本。 F 组的 TAMPER_CHAIN_BROKEN=2 才是真实篡改的检出数,别误判为「没抓到篡改」。
  4. 服务名是 auditd(容器名 3day-106-auditd), docker compose exec 要用服务名,且非交互环境必须加 -T (否则报 the input device is not a TTY)。
  5. app/__pycache__ 会让改动看起来「不生效」。 改完 app/*.py 后先 rm -rf app/__pycache__。
  6. 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 自查三问

  1. 你的链式指针是用上一条的内容摘要还是上一条的序号? 写入侧与校验侧是不是同一个函数算出来的? 跨分片 / 跨文件 / 跨账本时,新的一片第一条的 prev 接的是谁?
  2. 一个「完整性校验」报失败时,能不能拿出来一份可外部独立复核的证据 (哪一条、实测值、期望值、按哪条契约算的)? 还是只有一句 ok: false / tamper detected?
  3. 你的健康检查判据,取的是自己写的计数器还是被检对象的事实文件? 判据表是不是空的?main() 是不是无条件 return 0? 「判不了」(拿不到数据 / 契约缺失)时,是放行还是拒绝?

6.4 同类影响面

  • 哈希链 / 防篡改账本:审计日志、区块链、Merkle 链、 certificate transparency、git commit 的 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

版权声明

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

分享这篇文章