Skip to content

fix(audit): scan success defensively in VerifyChain so a poisoned value reports a break#179

Merged
xunholy merged 1 commit into
mainfrom
fix/audit-verifychain-poisoned-success
Jul 1, 2026
Merged

fix(audit): scan success defensively in VerifyChain so a poisoned value reports a break#179
xunholy merged 1 commit into
mainfrom
fix/audit-verifychain-poisoned-success

Conversation

@xunholy

@xunholy xunholy commented Jul 1, 2026

Copy link
Copy Markdown
Owner

VerifyChain scanned the success column into a bare Go bool. success and entry_hash are independent columns an attacker with direct SQLite write can set separately; setting success to NULL or an out-of-range integer (2) makes rows.Scan fail, so VerifyChain returns an error and ABORTS the entire walk instead of the designed 'chain broken at row N' verdict — a single UPDATE turns the forensic integrity check into a self-inflicted DoS. Fail-loud (never reads Valid=true), but degrades tamper-evidence. Fix: scan success as sql.NullInt64 (like duration_ms), hash success.Int64==1 — behaviour-preserving for legit 0/1 rows; any poisoned value now correctly reports a chain break. Found via the internal audit sweep (rest verified sound). Test proven to fail pre-fix. task ci green; eval 17/17.

…ue reports a break

VerifyChain scanned the success column into a bare Go bool. Legitimate rows
always store 0/1 (schema DEFAULT 1; RecordCtx writes 0/1), but success and
entry_hash are independent columns an attacker with direct SQLite write can set
separately. Setting success to NULL or an out-of-range integer (e.g. 2) makes
rows.Scan fail — "sql: Scan error on column 'success': couldn't convert 2 into
type bool" — so VerifyChain returns (nil, error) and ABORTS the entire walk,
instead of the designed "chain broken at row N" verdict.

Impact: this is fail-loud, not fail-open — it can never read a tampered log as
Valid=true (callers surface the error, not "valid"). But it degrades the
tamper-evidence guarantee: an operator running audit_verify on a poisoned row
gets an opaque type-conversion error instead of a pinpointed break, and the
verifier can't report which/how many rows are broken because it aborts at the
first poisoned row. A single UPDATE ... SET success=NULL turns the forensic
integrity check into a self-inflicted DoS.

Fix: scan success as sql.NullInt64 (matching the sibling duration_ms scan) and
feed success.Int64 == 1 into chainHash. Recorded rows are always 0/1 so this is
behaviour-preserving for legitimate logs; any NULL/out-of-range value now flows
into the hash recompute, fails the comparison, and is correctly reported as a
chain break (Valid=false, FirstBrokenID=id) rather than aborting.

Found via the internal audit sweep of the audit query DSL + hash chain; the rest
verified sound (chain pre-image injectivity via length-prefixed fields, verify
recomputes rather than trusting the stored hash, reorder/deletion/truncation +
CheckpointAnchor detection, fully-parameterized WHERE builder, capped LIMIT).

Test: TestVerifyChain_PoisonedSuccessReportsBreak (success=2 and success=NULL)
asserts VerifyChain reports a break at the poisoned row without erroring; proven
to fail pre-fix (both cases aborted with a bool scan error).

Verified: task ci green (lint 0 / vet / build / test:full -race 0 fail /
govulncheck clean); task eval 17/17.

Follow-up (LOW, banked): the same bare-bool scan exists in Query/QueryFiltered,
where a NULL-success row scan-errors and is silently dropped from results;
barely reachable (needs attacker DB write) so tracked separately.
@xunholy xunholy merged commit 137637d into main Jul 1, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant