For the complete documentation index, see llms.txt. This page is also available as Markdown.

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-78

  • The four-call whitelist: runtime/src/configs/mod.rs:683-693

  • is_call_allowed / is_whitelisted: primitives/high-security/src/lib.rs:132-147

  • Extension ordering (index 7 vs index 9): runtime/src/lib.rs:154-176

  • Uncapped tip folded into the fee: pallets/transaction-payment/src/lib.rs:844

  • Full withdrawal from the signer: pallets/transaction-payment/src/payment.rs:136-145

  • Tip never refunded, including on failed dispatch: pallets/transaction-payment/src/lib.rs:1051-1064

  • Fee handler wiring: runtime/src/configs/mod.rs:479-480

  • Fees minted to the block author: pallets/mining-rewards/src/lib.rs:316-318, :132, :193

  • Stolen value receives a wormhole leaf: pallets/mining-rewards/src/lib.rs:230-237

  • Block author address is a wormhole address: primitives/wormhole/src/lib.rs:146-148

  • The stated guarantee: pallets/reversible-transfers/README.md:90, pallets/reversible-transfers/src/lib.rs:357-358

  • Origin-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.

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

1

Clone the in-scope asset and check out the frozen branch

2

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:

3

Run the test

Expected output:

Step by step

1

Precondition

Assert Charlie is high-security (ReversibleTransfers::is_high_security(&victim).is_some()) and that his guardian is Alice (get_guardian(&victim) == Some(alice())). Record his starting free balance.

2

The attacker picks a whitelisted call

ReversibleTransfers::cancel { tx_id: H256::default() }. It needs no setup and will error at dispatch, which is irrelevant.

3

The high-security gate passes

validate_with(victim, &call) returns Ok. This is the only high-security check applied to a signed extrinsic, and it has now been satisfied by a call that moves nothing.

4

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.

5

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.

6

Assert the theft

The victim's balance has fallen by at least the named tip, and by more than 95% of his starting balance in this single extrinsic. Measured on the fixture: 100,000,000,000 units before, 98,108,157,000 removed.

7

Assert the guardian is powerless

pending_transfers_by_sender(&victim) is empty, so cancel has nothing to act on. Then call recover_funds from Alice, the guardian's strongest power, and assert the balance is still below the starting value: the tipped funds are gone irreversibly.

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:

  1. Make the high-security check see the whole extrinsic, not just the call. Have ReversibleTransactionExtension::validate receive 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.

  2. Cap the tip for high-security signers. Introduce a MaxTip for accounts in HighSecurityAccounts, enforced in ChargeTransactionPayment or 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?