88404 bc high preimage reference over drop in referenda enactment scheduling silently voids a passed governance decision
Submitted on Aug 13th 2026 at 01:38:38 UTC by @Ispolin11 for Audit Comp | Quantus
Report ID: #88404
Report Type: Blockchain/DLT
Report severity: High
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Manipulation of governance voting result deviating from voted outcome and resulting in a direct change from intended effect of original results
A bug in the respective layer 0/1/2 network code that results in unintended smart contract behavior with no concrete funds at direct risk
Description
Brief/Intro
Quantus' forked pallet-scheduler releases a preimage reference on every failed scheduling attempt, including the case where the reference belongs to the caller (pallet-referenda) rather than to the scheduler. Because referenda schedules a passed referendum's enactment through a Quantus-added 17-attempt retry loop over schedule_named, a single attempt that fails against a full block agenda strips the pin that keeps the approved proposal's call bytes available.
The approved call is then left with zero outstanding preimage requests, so the noter's ordinary deposit reclaim (unnote_preimage) deletes its bytes. On mainnet this silently voids a tech-collective referendum that already passed at the required threshold — the chain's sole lane for Root/governance dispatch: at dispatch the scheduler can only emit CallUnavailable (indistinguishable from routine scheduler cleanup), the referendum still reads Approved, and neither call nor origin is retained, so it cannot be retried.
Recovery requires re-running the full multi-day prepare/decide/confirm cycle, which the same mechanism can defeat again. The corruption needs no attacker (an honest deposit reclaim while the agenda happens to be full is enough) and can also be deliberately triggered and repeated by a single collective member.
Vulnerability Details
Referenda owns exactly one preimage reference for a Lookup proposal, and releases it exactly once.
submit takes a preimage request so a Lookup proposal's bytes stay pinned until enactment, even if the noter reclaims their deposit (pallets/referenda/src/lib.rs:597, guarded by an existence/length check at :589-595; a V12-audit change). That single reference is released by conclude_ongoing's T::Preimages::drop(proposal) (pallets/referenda/src/lib.rs:1706) on every terminal transition.
Enactment is scheduled through the v3 ScheduleNamed trait, which requests the preimage on success but drops it unconditionally on failure.
The scheduler's trait entry routes to do_schedule_named_inner with request_preimage = true (pallets/scheduler/src/lib.rs:928):
Inside do_schedule_named_inner, the success path adds the scheduler's own reference (gated on request_preimage), but every fallible path drops unconditionally, ignoring request_preimage (pallets/scheduler/src/lib.rs:942, :948, :958):
The success path treats referenda as the owner: it re-requests its own and leaves referenda's reference untouched, so conclude_ongoing still has something to drop. The failure path treats the scheduler as the owner: it releases the caller's reference. The two paths are inconsistent, and the caller is never told.
The scheduler's own comment scopes the drop to "callers that hand over an already-Bounded call" — which is exactly the class that owns its own reference — yet the drop runs for them anyway.
The trait contract settles which model is correct:
"NOTE: This will request
callto be made available"
frame/support/src/traits/schedule.rs:471
schedule_named adds a reference for the scheduler's own use; it does not assume ownership of the caller's. So on failure the scheduler holds no reference of its own to release, and dropping here can only consume the caller's.
Referenda calls this path up to 17 times in a loop (pallets/referenda/src/lib.rs:1077 MAX_ENACTMENT_SCHEDULE_RETRIES = 16, :1105):
Result — a single failed first attempt leaves the approved call completely unpinned.
Accounting on RequestStatusFor[hash] for one approval with one failed attempt:
Step
RequestStatusFor[hash]
noter note_preimage
Unrequested { ticket }
submit → request
Requested { count: 1 }
attempt #1 fails → scheduler drop
Unrequested ← referenda's pin gone
attempt #2 succeeds → scheduler request
Requested { count: 1 } (this one is the scheduler's)
conclude_ongoing → drop
Unrequested ← scheduler's pin gone
conclude_ongoing believes it is releasing the submit-time reference, but that one was already consumed by the failed attempt, so it releases the scheduler's instead. The remaining 15 attempts are no-ops only because Unrequested makes do_unrequest_preimage return NotRequested.
The referendum is now Approved, with an enactment task pending, and nothing holding its bytes.
Two ways the bytes are then deleted (either suffices):
Honest noter, no attacker. The submit-time pin was added (
#654) precisely so a noter can reclaim their storage deposit viaunnote_preimagewhile the bytes stay pinned through enactment. With the pin gone, that same ordinary, permissionless action silently destroys the approved proposal. If the enactment block's agenda is full for any reason — including normal governance activity — the decision is lost with no attacker and no intent.Deliberate, single member.
note_preimageis permissionless andsubmitonly checks that the hash exists, never who noted it. A member who noted the blob (routine — the proposal must be on-chain first) then votes and callsunnote_preimage, so the entire exploit sequence is attacker-controlled (no honest third party required).
Reachability of a full agenda.
MaxScheduledPerBlock = 50 (runtime/src/configs/mod.rs:345); push_to_agenda reserves 10 slots against LOWEST_PRIORITY, so the permissionless surface (reversible transfers, LOWEST) can fill at most 40 — the last 10 need non-LOWEST tasks.
Referendum alarms are priority 128, and every vote re-arms the voted referendum's alarm at now + 1. One member voting on ten ongoing referenda in block X-1 drops ten priority-128 tasks into block X; combined with 40 pre-scheduled reversible transfers, block X is full.
X is deterministic and public (status.enactment in ReferendumInfoFor, min_enactment_period 1 day), and only the first attempt must fail.
Which proposals are affected.
StorePreimage::bound picks Inline for encoded calls ≤128 bytes, where Preimages::drop is a no-op, and Lookup above that. Calls >64 KiB are rejected at submit (PreimageTooBig).
The affected window is Lookup calls of 128 B – 64 KiB:
Utility::batch/batch_allMulti-member
TechCollectivechangesTreasury-spend batches
System::set_storage
Standard set_code(wasm) runtime upgrades are not affected — the wasm exceeds MaxProposalSize, so upgrades go via authorize_upgrade(code_hash) (~40 bytes, Inline, immune).
This is a fresh regression in HEAD, not aged code.
Three ingredients are required, and the last one created the defect:
Scheduler drop-on-every-fallible-path (
#617, 2026-07-06)Referenda's 17-attempt retry loop (
#630, 2026-07-25)Referenda's submit-time
request+conclude_ongoingdrop (#654, 2026-08-10)
Commit 3b243b8 ("V12 Vesting (#654)") is HEAD itself; among the many changes it bundles is "fix: pin referendum proposal preimage for its whole lifecycle". That change is what first gives referenda a reference the scheduler can steal.
Before it, referenda held no reference and a failed attempt's drop was a no-op, so the end state was correct. No prior audit could have covered it.
The project's own full-agenda tests miss it because they all use inline bounded calls (set_balance_proposal_bounded), where Preimages::drop is a no-op.
Impact Details
A passed tech-collective referendum — the chain's sole lane for Root dispatch (TechCollectiveTracksInfo: one track, Root proposals only) — is silently discarded.
This maps to the two selected in-scope impacts:
Manipulation of governance voting result deviating from voted outcome and resulting in a direct change from intended effect of original results. The collective votes to enact call X; the bug causes X to be silently discarded while the recorded result staysApproved. The intended effect of the original result — enactment of X — is directly changed from "X executes" to "X never executes." In the deliberate variant this is a single actor purposely overriding a decision passed at the required ≥61% threshold — a silent, repeatable veto the governance thresholds are specifically designed to deny any single member.A bug in the respective layer 0/1/2 network code that results in unintended smart contract behavior with no concrete funds at direct risk. This is the exact characterization: L1 pallet code (referenda×scheduler) produces unintended behavior (an approved proposal is discarded), with no funds at direct risk.
The team documented this exact failure mode — and this bug re-opens it.
The submit comment added by #654 (pallets/referenda/src/lib.rs:574-583) describes precisely the loss demonstrated here, as the thing the fix was meant to prevent:
// V12 audit: a lookup proposal whose preimage is missing at enactment is // dropped by the scheduler as terminal (
CallUnavailable) after the referendum // record has already been replaced withApproved, which retains neither the call // nor a retry path — the voted proposal silently disappears.
The fix's stated guarantee is that "on approval the scheduler holds its own request from schedule_named until dispatch, so the bytes stay pinned through enactment" (:582-583).
That is exactly what the enactment-retry over-drop breaks: one failed attempt consumes referenda's submit-time request, conclude_ongoing then drops the scheduler's own request, and the enactment call ends with zero references — reproducing verbatim the "CallUnavailable … voted proposal silently disappears" outcome the comment set out to eliminate.
The invariant the team wrote down is the one that fails.
Concrete consequences on mainnet:
The governance action never takes effect, yet no event distinguishes it from a normal enactment except
CallUnavailable. The referendum readsApprovedforever and retains neither call nor origin, so it cannot be retried on-chain.Recovery requires re-noting, re-submitting and re-voting through a full prepare/decide/confirm cycle (≥3 days per
min_enactment_periodand track timings), which the same single-member mechanism can defeat again — an indefinite, low-cost denial of the collective's decisions.Secondary accounting corruption: pallet-preimage's own
debug_assert!(res.is_ok(), "do_unrequest_preimage failed - counter underflow?")(pallets/preimage/src/lib.rs:579) fires from the scheduler'sservice_taskcleanup. Atry-runtimeor debug build panics insideon_initialize; production release builds compile the check out and fail silently.
Boundaries, stated honestly (no over-claim):
The chain keeps confirming transactions: the
CallUnavailablearm ofservice_taskis graceful, and the only assertion is release-compiled-out.No funds are stolen, minted, or frozen. An unexecuted treasury batch simply leaves the funds in the treasury.
The loss is recoverable by re-proposal.
The damage is to governance integrity: a decision passed at the required threshold does not take effect, silently and repeatably.
On the "privileged actor within attributed privileges" boundary.
The deliberate variant involves a tech-collective member, but the effect is outside any member's attributed privileges, and that exclusion does not apply — for two independent reasons.
First, a member is granted exactly two powers: to submit and to vote (RootOrMemberForTechReferendaOrigin, runtime/src/governance/definitions.rs). The 61%-approval / 60%-support curves (runtime/src/governance/definitions.rs:119-127) exist precisely to deny any single member control over the outcome.
What this bug yields — a silent, repeatable veto over a decision the collective already passed at those thresholds — is a capability the governance design deliberately withholds, obtained through a defect rather than a granted privilege. A bug that hands an actor a power the system is built to deny them is the opposite of "within attributed privileges."
Second, the loss needs no privileged actor at all: route 1, an honest noter's permissionless unnote_preimage while the enactment block's agenda is full for any reason, including ordinary governance activity, destroys the approved proposal with no attacker and no intent.
This is a correctness / data-loss defect first, and a single-member weapon second.
References
Asset #1 — audit-comp-quantus-chain, branch audit-comp-ready, commit 3b243b8 (the audited commit in SCOPE.md; current tip of the branch):
Scheduler unconditional drop on failure —
pallets/scheduler/src/lib.rs:958(also:942,:948), and success-only request at:963-965Scheduler trait entry passing
request_preimage = true—pallets/scheduler/src/lib.rs:928Referenda 17-attempt enactment retry loop —
pallets/referenda/src/lib.rs:1105(MAX_ENACTMENT_SCHEDULE_RETRIESat:1077)Referenda submit-time preimage
request—pallets/referenda/src/lib.rs:597(existence/length guard at:589-595)Referenda single release point —
conclude_ongoing→T::Preimages::drop(proposal),pallets/referenda/src/lib.rs:1706Preimage counter-underflow assertion reached via
service_task—pallets/preimage/src/lib.rs:579Agenda cap and
LOWEST_PRIORITYreservation —runtime/src/configs/mod.rs:345; referendum-alarm priority128andnow + 1re-arm on every vote —pallets/referenda/src/lib.rs:1165(set_alarm),:936/:956(access_poll/try_access_poll)Regression commit —
3b243b8"V12 Vesting (#654)" (2026-08-10), which bundles the change "fix: pin referendum proposal preimage for its whole lifecycle"; builds on96de0cf"Frame fixes medium low (#617)" (2026-07-06) and5507d6c"V12 review node (#630)" (2026-07-25)v3 scheduler trait contract (an added, not transferred, reference): "NOTE: This will request
callto be made available" —frame/support/src/traits/schedule.rs:471
Link to Proof of Concept
https://gist.github.com/Ispolin11/cc0477c88fb65bc286fc41608a11798d
Proof of Concept
Six Rust tests reproduce the finding against the audited tree. They are added to pallets/referenda/src/tests.rs (the pallet's own unit-test module, so they reuse its mock runtime, ExtBuilder, run_to, set_tally, etc.).
The complete patch is also in the linked secret gist; the full source is reproduced below so this PoC is self-contained.
Environment
Asset #1 —
audit-comp-quantus-chain, branchaudit-comp-ready, commit3b243b8(the commitSCOPE.mdnames; current tip).Toolchain — Rust
1.93.0, pinned inrust-toolchain.tomland selected automatically byrustup.If the build cannot locate libclang, set
LIBCLANG_PATHto yourlibclang.sodirectory; no other setup is required.
Steps to reproduce
--release is required. pallet-preimage's debug_assert!(res.is_ok(), "…counter underflow?") trips on the corrupted accounting, so a debug build panics inside on_initialize before the impact assertion is reached.
Release compiles that check out and reproduces production behaviour — which is itself part of the finding. The poc_unpinned_enactment_is_silently_discarded test is therefore gated #[cfg(not(debug_assertions))].
These are pallet unit tests, so they run against pallet-referenda's mock runtime. The block numbers below (13 = enactment, 14 = retry) are the mock's timing (min_enactment_period = 4); the over-drop mechanism is config-independent — production timing differs but the accounting is identical.
The attack, step by step
Mapped to poc_enactment_retry_unpins_approved_proposal_preimage (the bug) and poc_unpinned_enactment_is_silently_discarded (the impact):
The control test (enactment_preimage_stays_pinned_when_first_attempt_succeeds) runs the identical flow with block 13 free: attempt #1 succeeds, status stays Requested { count: 1 }, unnote_preimage leaves the bytes intact, and enactment dispatches normally — proving the loss is caused solely by the retry.
Isolation and bounding
verify_scheduler_preimage_ownershipexercises only<T::Scheduler as ScheduleNamed>::schedule_named— the exact callschedule_enactmentmakes — from a state where the caller owns exactly one request, varying nothing but whether the agenda is full:Same start (
Requested { count: 1 }), same callResult
schedule_namedsucceedsRequested { count: 2 }— scheduler adds its own, caller's untouchedschedule_namedfailsUnrequested— scheduler released the caller'sThe success case forecloses the "referenda intends to hand ownership over" objection: if it did, the scheduler would not
requestagain on success, andconclude_ongoingwould have nothing to drop.verify_lowest_priority_reservation_bounds_permissionless_agenda_fillconfirms the honest limit on the attacker model:LOWEST_PRIORITYscheduling saturates atmax - max/5(40 of 50), so the permissionless surface alone can never fill an agenda — the reserved fifth needs higher-priority tasks (referendum alarms, i.e. votes).
Expected output
Verified on 3b243b8, rustc 1.93.0, release:
Full suite unaffected:
Honest scope of what the tests prove
The three lifecycle tests fill the enactment block's agenda directly (root-scheduled priority-100 tasks) to isolate the defect. They prove that given a full agenda at the enactment block, one failed retry unpins the approved proposal and its bytes become deletable, and the enactment is then discarded as CallUnavailable.
They do not claim the agenda-fill itself is permissionless.
Reachability of a full agenda on mainnet — a member's votes re-arming priority-128 alarms into a target block; permissionless reversible transfers capped at 40 by the reservation — is argued in the Description and bounded by verify_lowest_priority_reservation above. It is not re-derived inside these tests.
Test source
Add the following to the end of pallets/referenda/src/tests.rs.
Was this helpful?