MVF-2026-0011 — a ledger redacted by an older build could have lost a withdrawal silently
This is the follow-up to MVF-2026-0002, and it concerns the damage that defect could already have done.
| Identifier | MVF-2026-0011 | | Internal row | M26-CUS-021 | | Severity | S3, priority P1 | | Affected | no released version | | Not affected | every release: 1.0.0, 1.0.1, 2.0.0, 2.0.1, 2.1.0, 2.2.0, 2.3.0, 2.4.0 and 2.5.0 | | Fixed in | 2.6.0 | | Reported | internally; Stage C follow-up to M26-CUS-001, 2026-09-25 | | Published | 2026-10-05 | | CVE / GHSA | none assigned | | CVSS:3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:L/A:N | | CVSS:4.0 | CVSS:4.0/AV:L/AC:L/AT:P/PR:L/UI:N/VC:H/VI:L/VA:N/SC:N/SI:N/SA:N |
Summary#
This is the follow-up to MVF-2026-0002, and it concerns the
damage that defect could already have done. A redaction record keeps the node id,
the scope, the cascaded edges and hashes — never a label — so nothing
afterwards can say whether the node a redaction removed was a withdrawal, a
disclosure or a reclassification. On a ledger redacted by a build older than the
guard, the withdrawal lookup could therefore find nothing, no refusal was raised,
and a grant was issued: the record went again to the recipient it had been
withdrawn from, while the history simply counted one fewer.
The attack-complexity metrics differ between the two scoring systems on purpose
here: CVSS:3.1 says AC:H, and CVSS:4.0 says AC:L/AT:P, because what this needs
is a precondition — a ledger already redacted by an older build — rather than
a difficult attack.
Why this page exists although no release was affected#
Every identifier in this register has a page, so a citation never lands on a gap. For an operator: there is nothing to do. The ledger family is not in any released version.
Affected range, checked at each tag#
The ledger_* family first appears on 2026-09-21, after the v2.5.0 tag, and both
subject files are absent at all nine tags. The exposed population is anyone holding
a ledger redacted by a development build older than 2026-09-26.
Details#
Every disclosure reader finds a record through a property index. Before the fix, the withdrawal lookup took two hops: fetch the recipient's nodes by the recipient fingerprint index, match the record identity, then ask for a withdrawal naming that disclosure's identity. A whole-node redaction deletes the node, its labels and its index entries, so either hop could be broken — and the redaction record that remained could not say which kind of node had gone. With no refusal raised, the grant issued.
The fix#
Two parts, and they do different jobs.
The lookup no longer depends on the disclosure surviving. It now scans the withdrawal nodes themselves, matching the record identity and the recipient fingerprint the withdrawal itself names, so a withdrawal outlives the removal of the disclosure it withdraws.
A gate refuses every grant while the ledger holds an unexplained redaction. A redaction record with no role recorded can only have been made by a build from before roles were recorded, and anything that is not provably a properties-only redaction of a script's own node or edge that the ledger still holds now blocks issuance — until a hash-linked chain of review records, each bound to the hash of the redaction record it answers for, accounts for it. Four readers gained an unguarded-redaction count, and the redaction listing gained per-record and chain-level fields so an operator can see what is outstanding.
What this fix does not do, and the code says so itself#
It does not repair a damaged ledger, and a withdrawn recipient can still be re-granted after a review. The review machinery's own header says it plainly: the review checks nothing — whether a redaction removed a withdrawal is known to whoever made it and to their notes, not to the ledger. Mechanically: once a review names the redaction, nothing is pending, the gate returns no refusal, and the withdrawal lookup still finds nothing, because the withdrawal node and its index entries are gone. The grant issues.
The fixture for that path gets its withdrawal count back only because the test re-records the withdrawal before recording the review. The ledger did not recover it.
Two further distinctions a reader should not blur. The direct-lookup half covers the case where the disclosure was removed and the withdrawal survived, which has its own fixture — that is not the case the original reproduction demonstrated; that one redacted the withdrawal itself, for which nothing but a human re-recording restores the refusal. And the four readers were annotated, not corrected: in that same fixture the history still reports its old count after a redaction removed one of three disclosures.
So: the defect is now detected and must be answered for. It is not undone.
Fixture#
builtin/ledger_redaction_review_test.go, which carries seven tests, including
TestAnUnguardedRedactionStopsGrantsUntilReviewed for the original reproduction's
exact path — refused before any key is derived or any passphrase is prompted for,
then lifted by a recorded review —
TestEveryAnswerFromTheDisclosureRecordsNamesTheUnguardedRedactions, and
TestAWithdrawalOutlivesTheDisclosureARedactionRemoved.
Workarounds#
None is needed: no released version is affected.
For the only exposed population — someone holding a ledger redacted by a development build older than 2026-09-26 — detection was already partially possible before the fix: the redaction listing already reported a role name, which is empty for a record made before roles were recorded, so the role-less records could be listed and spotted.
What that does not cover: it does not say what any of those redactions removed, because the redaction record keeps no label, so it cannot distinguish a redaction that took a withdrawal from one that took a script's own node; it does not stop a grant, so a disclosure could still issue to a withdrawn recipient while the operator was reading the list; and it does nothing for the counts in the four readers, which stay silently short by whatever was removed.
Re-recording what can be re-recorded — a withdrawal while the disclosure is still present, a reclassification — is a genuine partial remedy, but it only works where the examiner's own notes say what the redaction took.
Credit#
Found by this project's own 2.6.0 security review, as a Stage C follow-up to
M26-CUS-001 on 2026-09-25, and confirmed in Stage E by a probe and then a failing
test. No external reporter.
References#
- The entry in CHANGELOG.md under 2.6.0, Security.
MVF-2026-0002, the defect that allowed the redaction this one is about.- SECURITY.md for the severity scale.
- The advisory register.