88841 bc critical high security accounts are drained irreversibly through the transaction tip which no high security check in the runtime models the guardian delay cancel and recover funds are
Submitted on Aug 15th 2026 at 05:59:52 UTC by @Valisthea for Audit Comp | Quantus
Report ID: #88841
Report Type: Blockchain/DLT
Report severity: Critical
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
Brief/Intro
The reversible-transfers pallet exists to guarantee that an attacker who steals a high-security account's key still cannot move its funds: every outflow must be delayed and cancellable by the guardian, or recoverable by the guardian. That guarantee is enforced by a single gate, ReversibleTransactionExtension, which inspects the extrinsic's call against a four-entry whitelist. The transaction tip is not a call. It rides the same extrinsic, is settled two slots later by ChargeTransactionPayment, is uncapped, is never refunded even when the dispatch fails, and is paid in full to the block author. An attacker holding a high-security key therefore drains the account by signing a whitelisted no-op with a tip equal to the balance. No pending transfer is created, so the guardian has nothing to cancel, and recover_funds reaches only what is left, which is nothing. Because QPoW is permissionless the attacker can mine the block and collect the tip himself, converting the bypass into direct theft. The attached PoC drains 98% of a high-security account in a single extrinsic against the real runtime.
Vulnerability Details
The guarantee, in the project's own words
pallets/reversible-transfers/README.md:90:
"This ensures that users always have a straightforward path to unrestricted funds, while attackers cannot bypass the delay period and guardian oversight."
pallets/reversible-transfers/src/lib.rs:357-358:
"This design is intentional to provide maximum security guarantees: an attacker who gains access to the account cannot simply disable the protections."
An attacker holding the key is therefore the pallet's own declared threat model. The guardian, the delay and recover_funds exist for no other reason.
Path A: the enforcement, which models only the call
The sole high-security gate on a signed extrinsic is runtime/src/transaction_extensions.rs:76-78:
It resolves to a closed whitelist of four calls (runtime/src/configs/mod.rs:683-693):
The team closed every origin-rewriting hole against that same whitelist: pallets/utility/src/lib.rs:298 (as_derivative), pallets/utility/src/lib.rs:692 (each batch child, re-checked against live state), pallets/recovery/src/lib.rs:456 (as_recovered), pallets/multisig/src/lib.rs:1142. The call surface is covered carefully. Nothing models a value outflow that is not a call.
Path B: the tip, on the same extrinsic, two slots later
runtime/src/lib.rs:154-176 defines the extension tuple. ReversibleTransactionExtension is index 7; pallet_transaction_payment::ChargeTransactionPayment is index 9. They ride the same extrinsic, and the payment extension never consults HighSecurityAccounts.
The tip is uncapped. pallet_transaction_payment::Config in runtime/src/configs/mod.rs:477-490 declares no maximum, and the fee is computed straight from the caller-supplied value (pallets/transaction-payment/src/lib.rs:844):
It is then withdrawn in full (pallets/transaction-payment/src/payment.rs:136-145):
and it is never given back. post_dispatch_details (pallets/transaction-payment/src/lib.rs:1051-1064) recomputes compute_actual_fee(..., tip), which always re-adds the tip, so a failed dispatch refunds the weight but never the tip.
Finally it is paid to the block author. payment.rs:205 hands the credits to OU::on_unbalanceds, wired at runtime/src/configs/mod.rs:479-480 to pallet_mining_rewards::TransactionFeesCollector, which accumulates into CollectedFees (pallets/mining-rewards/src/lib.rs:316-318); on_finalize takes them (:132) and mints them to the miner (:193):
Why the guardian is powerless
The whitelisted call used by the attack is ReversibleTransfers::cancel { tx_id: H256::zero() }. It requires no setup and its dispatch errors with PendingTxNotFound, which is irrelevant because fees and tips are charged regardless. Critically, no pending transfer is ever created: no hold is placed, no PendingTransfers entry is written, and no TransactionScheduled event is emitted. The guardian therefore observes nothing to cancel, and recover_funds can only reach the balance still present after the tip has already gone.
The value arrives laundered
pallets/mining-rewards/src/lib.rs:230-237 records a wormhole zk-tree leaf immediately after crediting the miner:
and the QPoW block author's address is a wormhole address by construction (primitives/wormhole/src/lib.rs:146-148). The stolen tip therefore lands already exitable through the zk-wormhole, so the attacker can withdraw it privately.
Impact Details
Selected impact: Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield.
An attacker who has compromised a high-security account's key drains its entire spendable balance, in a single block, with no delay window, nothing for the guardian to cancel and nothing left for recover_funds to recover. Because QPoW is permissionless (no validator set, no bonding), the attacker runs his own node, withholds the extrinsic from gossip, includes it in his own block template and receives the tip as block author. The theft is complete and the proceeds arrive pre-loaded with a wormhole leaf, so they can be withdrawn privately.
Even when the attacker does not mine the block, the outcome is still a payable Critical: the funds leave the victim irreversibly and are paid to some other miner. That is Direct loss of funds, and the guardian still has no recourse.
The scale is every high-security account on the chain. The feature exists precisely to protect large, long-lived holdings from key compromise, so the accounts that adopt it are the ones with the most at stake. The bypass costs one transaction and requires no privileged position beyond the key the threat model already assumes the attacker has.
There is no partial mitigation. The tip has no cap, the payment extension has no notion of high security, and the guardian's strongest power operates on a balance that is already gone.
References
The sole high-security gate:
runtime/src/transaction_extensions.rs:76-78The four-call whitelist:
runtime/src/configs/mod.rs:683-693is_call_allowed/is_whitelisted:primitives/high-security/src/lib.rs:132-147Extension ordering (index 7 vs index 9):
runtime/src/lib.rs:154-176Uncapped tip folded into the fee:
pallets/transaction-payment/src/lib.rs:844Full withdrawal from the signer:
pallets/transaction-payment/src/payment.rs:136-145Tip never refunded, including on failed dispatch:
pallets/transaction-payment/src/lib.rs:1051-1064Fee handler wiring:
runtime/src/configs/mod.rs:479-480Fees minted to the block author:
pallets/mining-rewards/src/lib.rs:316-318,:132,:193Stolen value receives a wormhole leaf:
pallets/mining-rewards/src/lib.rs:230-237Block author address is a wormhole address:
primitives/wormhole/src/lib.rs:146-148The stated guarantee:
pallets/reversible-transfers/README.md:90,pallets/reversible-transfers/src/lib.rs:357-358Origin-rewriting holes the team did close:
pallets/utility/src/lib.rs:298,:692,pallets/recovery/src/lib.rs:456,pallets/multisig/src/lib.rs:1142
Not covered by the prior audits
The Equilibrium/Eiger Substrate review (chain/audits/quantus-substrate-audit-20260513.pdf) reads this same whitelist twice, and both times about something else: EQ-QNT-MULTISIG-F-07 (non-native assets stranded at multisig dissolution) and a note that the README omits recover_funds. It asks which calls are whitelisted. It never asks whether value can leave a high-security account through a channel that is not a call.
Link to Proof of Concept
https://gist.github.com/Valisthea/a8b62d24a2acd6938009b8de4c1abe52
Proof of Concept
One runnable test against the real runtime, passing at commit 3b243b87. The full runtime suite still passes alongside it (50 passed / 0 failed in the lib suite, 41 passed / 0 failed / 1 pre-existing ignored in the integration suite), so nothing here is achieved by breaking or altering the project's own tests.
The test lives in the runtime's own extension test module, which already provides the fixture: new_test_ext() configures Charlie as a high-security account with Alice as guardian and a delay of 10, straight from pallet_reversible_transfers::GenesisConfig.
Setup
Add the test
Paste the attached test into runtime/src/transaction_extensions.rs, inside mod tests, at the end of the module. It reuses the existing new_test_ext, alice, charlie and validate_with helpers and needs no new dependencies.
The full test is also available as a secret Gist at https://gist.github.com/Valisthea/a8b62d24a2acd6938009b8de4c1abe52:
Step by step
The attacker names the tip
All but one existential deposit of the reducible balance. The single ED left behind is headroom for the inclusion fee, because withdraw_fee settles inclusion_fee + tip under Preservation::Preserve. That is an accounting boundary, not a defence: the attacker names one ED less and repeats the extrinsic until the account is empty.
Settle the payment
Construct ChargeTransactionPayment::<Runtime>::from(tip) and run its validate then prepare with a zero-weight DispatchInfo, which isolates the tip from the inclusion fee. Both succeed: the payment extension accepts an arbitrary tip from a high-security signer without ever consulting HighSecurityAccounts.
Recommended fix
The root cause is that the high-security model is expressed over RuntimeCall while a value outflow can also occur through the extension pipeline. Two options, in descending order of preference:
Make the high-security check see the whole extrinsic, not just the call. Have
ReversibleTransactionExtension::validatereceive and inspect the tip (it is available to the extension pipeline) and reject any transaction from a high-security signer whose tip exceeds a small fixed bound. This closes the class rather than the instance.Cap the tip for high-security signers. Introduce a
MaxTipfor accounts inHighSecurityAccounts, enforced inChargeTransactionPaymentor in a preceding extension. A cap on the order of the inclusion fee preserves ordinary priority bidding while removing the drain.
Whichever is chosen, the same audit question should be applied to every other non-call value outflow in the extension pipeline, since the defect class is "the high-security model is call-shaped and the extrinsic is not".
Was this helpful?