88397 bc medium self directed reversible transfers record wormhole zk tree leaves for credits that never happen
Submitted on Aug 13th 2026 at 01:13:30 UTC by @humbleservant for Audit Comp | Quantus
Report ID: #88397
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
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
Every balance credit on Quantus is turned into a leaf of the wormhole ZK tree, and that tree is the only structure an exit proof proves membership against. Neither of the two mechanisms that create those leaves checks that value actually moved, and pallet_reversible_transfers reaches both of them with a credit where none did. Cancelling a one-time schedule returns the held principal to the sender with transfer_on_hold(A -> A), which emits a TransferOnHold event that the event scanner turns into a leaf. Letting a schedule pointed at the sender's own address run to execution takes the source == dest early return inside the transfer primitive, moves nothing, and the pallet's explicit recorder mints a leaf anyway. Either way, anyone with a funded account writes arbitrary amounts of credit into the wormhole's deposit record for value that never changed hands, at the price of a transaction fee.
Vulnerability Details
Leaves are created by two disjoint mechanisms. WormholeProofRecorderExtension scans the events a transaction emitted and converts four pallet_balances events into leaves, covering everything that happens in transaction context. Pallets whose credits land in hook context, where no transaction extension runs, instead call TransferProofRecorder::record_transfer_proof directly. Both mechanisms infer "value moved" from a proxy rather than checking it, and pallet_reversible_transfers breaks the proxy on both.
Instance one: the event scanner and cancel
post_dispatch_details matches three of its four arms unconditionally:
// runtime/src/transaction_extensions.rs:336-381
match event_record.event {
RuntimeEvent::Balances(pallet_balances::Event::Transfer { from, to, amount }) => {
let pot = vesting_pot.get_or_insert_with(
pallet_vesting::Pallet::<Runtime>::pot_account_id,
);
(&from != pot && &to != pot).then_some((None, from, to, amount))
},
RuntimeEvent::Balances(pallet_balances::Event::Minted { who, amount }) => {
let minting_account = crate::configs::MintingAccount::get();
Some((None, minting_account, who, amount))
},
RuntimeEvent::Balances(pallet_balances::Event::TransferOnHold {
source, dest, amount, ..
}) => Some((None, source, dest, amount)),
RuntimeEvent::Balances(pallet_balances::Event::ReserveRepatriated {
from, to, amount, ..
}) => Some((None, from, to, amount)),
_ => None,
}The only exclusion anywhere in the scanner is an address comparison against the vesting pot, and it sits inside the Transfer arm. Nothing checks the property that actually matters, which is whether the source and the destination are the same account.
ReversibleTransfers::cancel reaches the unguarded TransferOnHold arm. cancel_transfer reads the recipient out of state frozen at schedule time:
For a schedule created through schedule_transfer_with_delay, pending.guardian is the sender, so apply_fee is false and recipient == pending.from. release_held_funds_with_fee then issues a self-directed hold transfer:
fungible::hold::Mutate::transfer_on_hold has no source == dest short circuit and calls done_transfer_on_hold unconditionally (frame/support/src/traits/tokens/fungible/hold.rs:378-385), which emits TransferOnHold { source: A, dest: A, amount } (pallets/balances/src/impl_fungible.rs:227-240). The scanner sees it, calls record_transfer_proof(None, A, A, amount), and a leaf crediting A the full amount lands in the ZK tree. A's total balance is unchanged by the release; the funds moved from held to free inside one account.
Instance two: the explicit recorder and scheduled execution
The same pallet reaches the other mechanism with the same kind of credit. do_execute_transfer releases the hold, dispatches an inner transfer_keep_alive, and records a leaf on the strength of the dispatch result alone:
post_info.is_ok() is not evidence that value moved. Schedule a transfer to your own address and the inner transfer_keep_alive takes the source == dest early return, moves nothing, and returns Ok. The hold has already been released back to the sender, so the sender ends the block with exactly the balance it started with, and a leaf for the full scheduled amount is inserted. No Balances::Transfer event exists for that execution at all, so the scanner never even sees it. My third test asserts both halves of that.
This matters for the fix. A source != dest guard on the TransferOnHold match arm stops instance one and leaves instance two running, which I confirmed by applying it and re-running.
Why this is a gap and not intended behaviour
record_transfer_proof is the single chokepoint every recorder passes through, and it already drops one class of value-less credit, with a comment that describes this bug's harm precisely:
A self-directed credit moves no value for exactly the reason a zero-amount credit does. The chokepoint filters on amount == 0 and never on source == dest.
The transfer primitives point the same way. The unheld path short-circuits self-transfers before done_transfer, so a plain self-directed transfer_keep_alive emits no event and produces no leaf:
The if source == dest line there is inherited from upstream, but the surrounding reasoning is not: commit db94e2a ("Harden FRAME primitives against audit findings") reworked that exact branch and added the comment about nothing being debited on the self path. Commit 96de0cf ("Frame fixes medium low") separately edited transfer_on_hold in hold.rs, adding the destination-existence check for Restriction::OnHold mode, and left the function with no self-transfer short circuit at all. So both transfer primitives were reviewed and hardened in this repository, the self-directed case was reasoned about explicitly on one of them, and the recorder that consumes their events was written here from scratch (runtime/src/transaction_extensions.rs has 37 commits, all of them Quantus's). The gap is between those pieces, not inside any of them.
Commit 3b243b87 shows the classifier being worked on two days before the code froze: "fix: fee-charge proof inserts for cancel, recover_funds and close_recovery. The static count_transfers classifier let ReversibleTransfers::cancel ... fall through to the zero arm, even though they emit TransferOnHold / ReserveRepatriated events ... that the post-dispatch scanner turns into ZK-tree proof inserts." The weight charged for the insert was corrected. Whether the insert should happen at all on the self-directed branch was not asked.
The reason no existing test catches this is the harness. runtime/tests/transactions/reversible_integration.rs drives the pallet with RuntimeOrigin::signed directly, so the transaction-extension pipeline never runs and no leaf is ever recorded in those tests. My PoC goes through Executive::apply_extrinsic with real ML-DSA-65 signatures and the complete TxExtension tuple, which is the only way to observe the scanner.
What this is not
Both instances credit an account that signed for the schedule, so leaf.to = hash_bytes(ml_dsa_pubkey) (primitives/dilithium-crypto/src/traits.rs:191-201). Spending a leaf requires proving knowledge of a secret s with H(H("wormhole" || s)) == leaf.to (wormhole/circuit/src/circuit.rs:247-255 into unspendable_account.rs:215-237). Both sets are Poseidon2 images and landing in both at once is a preimage problem, so these leaves are not exitable and this is not unauthorized minting today.
Instance two is the one worth checking carefully, because pending.to is fully attacker-chosen there and could name a wormhole address. It cannot produce a phantom leaf when it does: the no-op only happens when to == from, and any to != from moves the value for real, which makes the leaf correct. I also checked every origin-rewriting route that could put a non-signing address into pending.from, since that is the other way to break the tie: Utility::as_derivative, Utility::dispatch_as (Root only), Recovery::as_recovered (requires the account to have created its own recovery configuration), and Multisig::as_multi_threshold_1. Each derived address is a Blake2 image, so aiming one at a chosen wormhole address is a cross-family collision search. execute_transfer itself is gated on who == Self::account_id() (pallets/reversible-transfers/src/lib.rs:443), a keyless pallet account, so no signed origin reaches it. set_high_security does accept an arbitrary guardian address, but both cancel and recover_funds require the guardian to sign, so a keyless wormhole address can never end up as the credited party there either.
Impact Details
This maps to the in-scope Blockchain/DLT impact "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". A defect in the runtime's proof recorder produces a wormhole deposit leaf and a NativeTransferred event for a credit that never occurred, and no funds are at direct risk because the leaf cannot be spent.
On the wording: Quantus supports no token standards and ships no contracts pallet, so "smart contract behavior" has no referent on this chain at all. That impact nonetheless appears on this program's own Blockchain/DLT list, so its operative clause here is unintended behaviour in the layer-1 code with no funds at direct risk, which is what this is. If you read that impact strictly by its wording instead, the fallback is the Medium "Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)": there is no profit motive, and the damage is a permanently wrong wormhole deposit record plus a stream of transfer events that describe transfers nobody made.
Three concrete consequences on mainnet:
The ZK tree permanently contains leaves asserting credits that never happened, and nothing on chain reconciles recorded leaf value against real value any more. The pallet dropped its
PotentialWormholeBalanceandTotalWormholeExitscounters in the v1 to v2 storage migration five days before the code froze (pallets/wormhole/src/lib.rs:246-258), so the recorded total is now the only account of what the tree claims, and it is wrong by an attacker-chosen amount.A
NativeTransferred { from: A, to: A, amount }event fires for a transfer that did not occur. The pallet documents these as the events indexers such as Subsquid consume (pallets/wormhole/src/lib.rs:459-467), so downstream balance and volume reconstruction sees traffic that never existed, at whatever scale an attacker chooses to pay for.TransferCount[A]advances for a credit that never happened, which is the second harm the zero-amount guard names in its own comment.
There is also a forward risk I would not file on its own but which changes how I would prioritise the fix. The moment any path lets a keyless or wormhole-derived address occupy pending.from, whether through a pallet-scheduled reversible transfer, a hold placed on a vesting or treasury sub-account, or a future recovery flow, these same three lines start minting exit capacity that a secret holder can spend. The guard that currently keeps this inert is an accident of which accounts can reach the path, not a property the recorder checks.
I am deliberately not claiming this as a cheap state-growth attack. An ordinary transfer already inserts one leaf for one fee, which is cheaper per leaf than the two fees a schedule-and-cancel pair costs, and the known issue about zk-tree depth covers volume-driven capacity exhaustion on its own terms. The defect here is that a leaf records value that never moved, not that leaves can be created quickly.
References
runtime/src/transaction_extensions.rs:336-381and:423-435, :450-473. The event scan, the vesting-pot filter that covers only theTransferarm, theprepare()snapshot ofevent_count(), and thepost_dispatch_detailsthat consumes it.pallets/reversible-transfers/src/lib.rs:865-909(cancel_transfer),:917-954(release_held_funds_with_fee),:411-416(thecanceldispatchable),:465-484(schedule_transfer_with_delay),:693-738(do_execute_transferand itspost_info.is_ok()record),:434-446(execute_transferand its keyless-origin gate).frame/support/src/traits/tokens/fungible/hold.rs:346-386.transfer_on_holdwith no self-transfer short circuit.frame/support/src/traits/tokens/fungible/regular.rs:318-343. The self-transfer short circuit that the unheld path does have.pallets/balances/src/impl_fungible.rs:227-240.done_transfer_on_holdemits the event unconditionally.pallets/wormhole/src/lib.rs:1341-1392(record_transfer_proofand its zero-amount drop),:246-258(the storage migration that removed the wormhole balance counters),:459-467(theNativeTransferredevent contract).wormhole/circuit/src/circuit.rs:247-255andwormhole/circuit/src/unspendable_account.rs:215-237inqp-zk-circuitsv4.2.0, for the constraint that makes the leaf unspendable.Commit
3b243b87("fix: fee-charge proof inserts for cancel, recover_funds and close_recovery").Checked against the program's three listed known issues (referendum vote removal, plaintext miner-node connection, zk-tree depth versus transaction volume) and against the four public audit reports (Equilibrium on
qp-poseidon,qp-zk-circuitsandchain, Neodyme onqp-rusty-crystals). None of them covers a leaf recorded for a credit that did not occur.
Proof of Concept
Four tests: the scanner instance, the explicit-recorder instance, a control on the sibling branch of the same function where value genuinely moves, and a repetition test. Every extrinsic in all four goes through Executive::apply_extrinsic with a real ML-DSA-65 signature and the full TxExtension tuple, which is the only way to reach the scanner at all.
One precision note on the third test. Its scheduling half is submitted as a signed extrinsic like everything else, but the execution half is driven by calling Scheduler::on_finalize and Scheduler::on_initialize around System::set_block_number, which does not run frame_system::initialize the way a real block does. That is faithful for what it demonstrates, because the recorder call at pallets/reversible-transfers/src/lib.rs:726 is explicit rather than event-derived, so no scan window is involved and the event indices are irrelevant to it. In a real block the scheduler dispatch runs inside on_initialize, whose events sit below every extrinsic's snapshot (frame/executive/src/lib.rs:641 resets events before the hooks at :662-665), which changes nothing about which recorder fires here.
Save as runtime/tests/transactions/wormhole_phantom_leaf.rs and add pub mod wormhole_phantom_leaf; to runtime/tests/transactions/mod.rs.
Run command, from the repository root on WSL2 Debian with the repository's pinned 1.93.0 toolchain:
RUSTFLAGS="-A unused_imports" works around an unrelated rustc 1.93.0 internal compiler error in check_unused_traits, which the two pre-existing unused trait imports at runtime/tests/governance/tech_collective.rs:29 and :45 trigger intermittently. Without it the test crate sometimes fails to build with "error: the compiler unexpectedly panicked".
Expected output:
To show the tests measure this defect and nothing else, and to show where the fix has to go, I ran two candidate patches.
The narrow patch, on the TransferOnHold arm at runtime/src/transaction_extensions.rs:360-365:
stops the scanner instance and leaves the explicit-recorder instance running:
The chokepoint patch, in record_transfer_proof at pallets/wormhole/src/lib.rs:1354-1356, immediately after the existing zero-amount drop:
stops all three defect tests while the legitimate guardian path still records its leaf:
Suggested fix
Drop the credit at the chokepoint, as in the second patch above. record_transfer_proof is the single point every recorder passes through, it already establishes the convention of returning false for a deliberately dropped credit so weight reconciliation stays honest, and it covers the ReserveRepatriated arm and any future recorder at the same time. Patching the TransferOnHold match arm alone is not sufficient, as the run above shows. If you would rather also fix the pallet locally, have cancel_transfer call release instead of transfer_on_hold when recipient == pending.from, which is what that branch means, and have do_execute_transfer record on the inner dispatch's actual moved amount rather than on post_info.is_ok().
Was this helpful?