89762 bc medium owner cancelling a reversible transfer mints an unbacked zk tree leaf permanent permissionless state growth
Submitted on Aug 21st 2026 at 01:42:17 UTC by @jokerbuena for Audit Comp | Quantus
Report ID: #89762
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
Summary
Any ordinary signed account can mint unbacked leaves into the wormhole ZK tree, permanently and repeatably, by scheduling a reversible transfer and immediately cancelling it. No value moves, no balance changes, no privileged actor is involved — yet the tree grows by one leaf per cancel and leaves are never removed.
Measured on a fresh clone of the in-scope asset at the audited commit: 15 cancels produced 15 leaves of face value 1000000000000 each — 15000000000000 of nominal ZK exit capacity — while the attacker's free balance, total balance and the chain's total issuance were byte-identical before and after.
Scope
The defect is in Quantus's own code:
runtime/src/transaction_extensions.rs—WormholeProofRecorderExtension(Quantus extension)pallets/reversible-transfers/src/lib.rs— Quantus palletpallets/wormhole/src/lib.rs— Quantus pallet
All three sit inside the listed asset "The Substrate L1 node — runtime, FRAME pallets, QPoW consensus, quantus-node".
Upstream fungible::MutateHold::transfer_on_hold and pallet_balances::done_transfer_on_hold appear in the causal chain as context only: they behave as designed and emit a truthful event. What is not designed is Quantus's recorder treating that event as a credit and minting a ZK leaf from it. This report asks for no change to unmodified upstream code.
No privileged actor. Both extrinsics (schedule_transfer_with_delay, cancel) are callable by any ordinary signed account. The Technical Collective plays no part.
Root cause
cancel routes to release_held_funds_with_fee. Both of its branches force the destination to a signing account — pallets/reversible-transfers/src/lib.rs:876 (ensure!(who == &pending.guardian) → pending.guardian) and :879 (ensure!(who == &pending.from) → pending.from):
For a one-time schedule pending.guardian == pending.from, so the owner branch sets recipient = pending.from and :943 then calls:
That is a hold-to-free conversion on a single account. No credit occurs. But the balances pallet emits TransferOnHold { source: X, dest: X, amount }, and Quantus's WormholeProofRecorderExtension post-dispatch event scan treats that arm as a credit and records a wormhole leaf for X. The recorder already guards the neighbouring ReserveRepatriated arm for exactly this reason ("self-repatriations return early"); the TransferOnHold arm has no such guard.
The runtime's own weighting confirms it expects the insert: WormholeProofRecorderExtension::<Runtime>::count_transfers(&cancel_call) == 1.
Impact
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol) — the in-scope Medium impact, matched word for word.
A core invariant is permanently false. Leaves are meant to be 1:1 with real credits. After this sequence they are not, and leaves are never removed.
Permissionless, unbounded, permanent state growth. Two extrinsics per leaf, repeatable at ordinary fee cost, by any signed account, with no delay wait.
No extractable value, and that is stated deliberately. The leaf's
tois a signing account; the exit path (Wormhole::verify_private_batch/verify_public_batch) is gated by ZK verification inValidateUnsigned::pre_dispatch, and the circuit bindsleaf.to == WA(secret). A signing account is not a Poseidon wormhole address, so no proof can be produced against these leaves. That is precisely why the Griefing line — the entry that does not require extractable value — is the one claimed here.
Latent escalation, named not claimed: if any future recipient of release_held_funds_with_fee can be a Poseidon address, these leaves become spendable and the class changes to unauthorised minting. Not currently reachable — both cancel branches force a signing account.
Recommendation
Add a (&source != &dest) guard on the TransferOnHold arm of WormholeProofRecorderExtension's event scan, mirroring the guard the recorder already applies to ReserveRepatriated. One line, in Quantus's own code.
A source == dest early return in upstream transfer_on_hold would also stop the symptom, but that is unmodified upstream Substrate and out of scope for this program; it is named only to show the guard is absent on both sides.
Not a duplicate
The three audits shipped inside the in-scope repository (audits/EigerWormholeAudit.pdf, audits/quantus-substrate-audit-20260513.pdf, audits/Proof-of-Work and Poseidon Security Review 1.1.pdf) do not cover this path. Text-extracted and searched: transfer_on_hold 0, TransferOnHold 0, unbacked 0, leaf_count 0 — against positive controls wormhole 207, Quantus 205, pallet 389, so the search discriminates.
The single cancel_transfer occurrence in the substrate audit is the opposite defect: a scheduler split-counter bug where cancel_named lookup fails and cancellation fails with CancellationFailed, leaving funds held. Here cancellation succeeds and mints a leaf. Different root cause, opposite symptom, different fix.
Distinct from my other reports: not QN-92 (execute_transfer → transfer_keep_alive(self) no-op → explicit record_transfer_proof; different call, different recorder path, and no scheduler delay here), not QN-60 (fee 10 vs 4), not QN-61 (min-exit 10 vs 1), not QN-70 (miner fee credit without a leaf — the opposite direction).
Link to Proof of Concept
https://gist.github.com/JokerBUENA/b5650909ce1c3b7dccf067d21b150c43
Proof of Concept
PoC
Re-run on a fresh clone on 2026-08-21 (cargo 1.93.0), git status showing only the test file touched — the audited source is untouched:
The first printed line is the discriminating control: a plain transfer_keep_alive(self) driven through the same run_lifecycle records zero leaves. The 15 leaves therefore come from the cancel path, not from the harness.
Verbatim output (2026-08-17, 3b243b8, then reverted)
PoC source — QN-93-runtime-self-cancel.rs
Append into the test module named above.
Was this helpful?