88609 bc critical high security whitelist is incomplete the transaction tip bypasses the guardian delay entirely
Submitted on Aug 14th 2026 at 06:16:53 UTC by @Naresh_Kandula for Audit Comp | Quantus
Report ID: #88609
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
Summary
Quantus ships a "high-security account" feature whose entire purpose is to make funds unstealable even when the signing key is compromised: every value-moving call from such an account must go through schedule_transfer, which holds the funds for a configured delay during which a guardian can cancel or recover_funds.
The policy that enforces this inspects only the RuntimeCall. A transaction's tip is not part of the call — it is a field of a different transaction extension (ChargeTransactionPayment) that runs after the high-security gate in the TxExtension tuple. The tip is unbounded, is paid immediately, is never refunded, and is credited in full to the block author.
An attacker holding the key therefore attaches an arbitrary tip to a whitelisted call that is guaranteed to do nothing and removes the account's entire balance in a single block — with no delay, no PendingTransfer, and nothing for the guardian to cancel.
Measured on the frozen code: 99.9994 % of a protected account's balance leaves in one block.
Root cause
1 — the policy predicate takes only the call (chain/runtime/src/configs/mod.rs:683-694):
2 — the gate discards every other input (chain/runtime/src/transaction_extensions.rs:60-81). validate receives _info and _len and ignores both; the tip is not even reachable from here, because it lives inside another extension's own struct:
3 — the tip is charged after the gate (chain/runtime/src/lib.rs, TxExtension tuple):
6
CheckWeight
7
ReversibleTransactionExtension ← high-security gate (call only)
8
WormholeProofRecorderExtension
9
ChargeTransactionPayment::from(tip) ← withdraws fee + tip
ChargeTransactionPayment::prepare withdraws inclusion_fee + tip from the signer. The tip is never refunded on failure — post_dispatch only corrects the weight component. There is no tip cap anywhere in the runtime (grep -rn "tip" runtime/src/ returns no fee-related logic).
4 — the value is credited to the block author. The fee sink is pallet_mining_rewards::TransactionFeesCollector (chain/runtime/src/configs/mod.rs:479), and on_finalize re-mints 100 % of CollectedFees to the miner (chain/pallets/mining-rewards/src/lib.rs:193):
So this is not burn-griefing: an attacker who mines the block receives the swept balance. QPoW mining is permissionless.
The invariant this breaks — stated by the project itself
chain/pallets/reversible-transfers/README.md:65-72:
"An attacker who compromises account keys cannot disable protections to steal funds immediately … attackers cannot bypass the delay period and guardian oversight."
chain/primitives/high-security/src/lib.rs:82-86 states the whitelist "includes only operations that are reversible or delayed." The tip is neither reversible nor delayed, and the whitelist never sees it.
Proof of Concept
Self-contained runtime integration test. It runs through the real pipeline — a genuine ML-DSA-65 signature over the real SignedPayload, submitted with Executive::apply_extrinsic and the complete TxExtension tuple. No extension is driven by hand. The account is funded inside the harness.
Steps to reproduce
Proof-of-concept source (complete, self-contained)
Output
The test asserts, in order:
The victim enrols in high security via a real
set_high_securityextrinsic (guardian set, 100-block delay).The control genuinely works — a plain
Balances::transfer_keep_alivefrom the account is rejected by the gate and moves nothing.The same account's
ReversibleTransfers::cancel { tx_id: H256::default() }is accepted with a maximal tip. The call itself fails (PendingTxNotFound) — it exists only to carry the tip.> 99.99 %of the balance is gone in that one block; the account is deliberately left above the existential deposit so nothing about the enrolment is disturbed.PendingTransfersBySender::get(victim).is_empty()— no delayed transfer was ever created, so the guardian window never opens andcancel/recover_fundshave no target.The swept value sits in
MiningRewards::collected_fees(), queued for the block author.
Mitigations, and why none of them save it
Preservation::Preserve on the fee withdrawal
Only reserves the existential deposit. Measured residual: 5.875 × ED out of 1000 UNIT.
Fortitude::Polite
Only protects funds already held/frozen. A high-security account's free balance is not held — holds are created by schedule_transfer, which is exactly the path being bypassed.
Guardian cancel
Requires a PendingTransfer. None is ever created (asserted in the PoC).
Guardian recover_funds
Sweeps the remaining free balance. After the attack that is dust.
The delay
Never starts. The drain completes inside the block the transaction is included in.
A tip cap
Does not exist anywhere in the runtime.
Fee estimation / wallet UI
The attacker constructs and signs the extrinsic directly; the victim's wallet is not involved.
Novelty
Not in any of the four in-repo audit reports.
ChargeTransactionPayment, "transaction tip", andtipin the fee sense appear zero times across all four extracted texts (Equilibrium PoW/Poseidon 2025-10, Neodyme 2025-12, Eiger 2026-03, Equilibrium Substrate 2026-05).Not in any commit body:
git log --allover 635 commits has notiphit in the fee sense (the only matches are "chain tip" in mempool code).Not one of the three published Immunefi Known Issues.
The four kill-listed
EQ-QNT-REVERSIBLE-*findings are enrolment permanence (F-01), asset weights (F-02), orphaned holds (F-03) and documentation (O-01/02) — none touches fees.The public post-audit hardening of this exact policy (#610 origin-rewriting wrappers, #639, #654's
ensure_nested_call_allowedforbatch/batch_all/force_batch/if_else) all extends the call-based check. This report is the input the predicate is still missing.
Scope
The defect is in runtime/src/configs/mod.rs, runtime/src/transaction_extensions.rs and primitives/high-security/src/lib.rs — all Quantus-original code with no upstream baseline. pallets/transaction-payment is byte-identical to upstream pallet-transaction-payment 45.0.0 and is cited only as the sink; it behaves exactly as upstream specifies. The bug is that Quantus's own policy layer does not account for it.
Per the program's scope rules, this is an actor exceeding its attributed privileges: the attributed privilege of a high-security signer is precisely the four whitelisted, delayed-or-reversible operations. An unbounded, immediate, irreversible transfer of the full balance to a third party is not among them — it is the exact outcome the feature is advertised to prevent.
Recommended fix
Make the high-security policy a function of the whole transaction, not just the call. Either:
Give
ReversibleTransactionExtensionaccess to the tip and reject any non-zero tip from a high-security account (simplest, no new state); orCap the tip for high-security accounts at a small constant, and route any excess through
schedule_transferso it inherits the delay and the guardian's authority.
Option 1 is a two-line change and closes the class. Also worth auditing for the same shape: any future extension placed after the gate that can move value out of the signer's account.
Was this helpful?