88215 bc medium any account can permanently and irrevocably consume another address s bounded guardian index with no consent and no removal path
Submitted on Aug 12th 2026 at 09:30:08 UTC by @Valisthea for Audit Comp | Quantus
Report ID: #88215
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/immunefi-team/audit-comp-quantus-chain
Impacts:
Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
Brief/Intro
set_high_security writes the caller into the guardian's GuardianIndex, a BoundedVec capped at MaxGuardianAccounts = 32, without any consent from, signature by, or even on-chain existence of the account being written into. The pallet contains no path that ever removes an entry, and set_high_security is documented as irreversible. Any attacker can therefore spend 32 ordinary transactions to permanently exhaust the guardian index of any chosen address, after which no honest user can ever name that address as their guardian. On mainnet this permanently denies the pallet's core protective feature to any published guardian address (a custody provider, an institutional cold-wallet guardian, a well-known community address), and the address can be pre-squatted before its owner ever transacts. There is no in-protocol remedy: not for the victim, not for the occupiers, not for the guardian itself.
Vulnerability Details
The write
set_high_security pushes the caller into the guardian's index:
// pallets/reversible-transfers/src/lib.rs:394-396
GuardianIndex::<T>::try_mutate(guardian.clone(), |accounts| {
if !accounts.contains(&who) {
accounts.try_push(who.clone()).map_err(|_| Error::<T>::TooManyGuardianAccounts)into a bounded container:
Why this is a defect and not the documented bound
The pallet already ships a test asserting the bound holds (tests/test_reversible_transfers.rs:1630, guardian_index_respects_max_limit): fill the index, and the next set_high_security correctly fails with TooManyGuardianAccounts. That test is correct, and this report does not dispute it. The bound working as designed is the premise here, not the finding.
That test establishes that the bound holds. It never asks the two questions that turn the bound into a griefing primitive:
Q1. Who is permitted to consume the bound? Anyone, against any address. The guardian argument to set_high_security is an arbitrary T::AccountId. It never signs, is never consulted, and need not exist on chain. The only guards on the call are guardian != who, !HighSecurityAccounts::contains_key(&who), and validate_delay (lib.rs:383-390). There is no deposit, no reserve, and no balance requirement attached to the index entry.
Q2. Can a consumed slot ever be released? No. GuardianIndex::<T> has exactly two write sites in the entire pallet, and both are push-only:
lib.rs:394— the signedtry_pushshown abovelib.rs:992— the genesis builder
No extrinsic, hook, or helper ever removes an entry. This is confirmed dynamically in the PoC by exercising all six dispatchables (call_index 0 through 5) and observing the index is unchanged.
Permanence is then locked in by design on the other side as well. lib.rs:347 documents: "Enabling high security mode is a one-way operation that cannot be undone." An enrolled account cannot un-enroll, so its index entry can never lapse.
The composition
Four properties compose into the issue, and no single one of them is a bug on its own:
The written-into account gives no consent and need not exist.
The entry carries no deposit, so occupying a slot is free beyond the transaction fee.
There is no removal path anywhere in the pallet.
Enrollment is irreversible by design, so entries never expire.
Together these make "accounts protected by guardian G" a scarce, per-address, globally-consumable resource that any unprivileged party can permanently exhaust for the cost of 32 transactions, with no reclamation and no consent from G.
Why the obvious mitigations do not work
Raising
MaxGuardianAccountsdoes not repair it. It is a compile-timepub constinsideparameter_types!(runtime/src/configs/mod.rs:528), not a storage-backed parameter, so changing it requires a full runtime upgrade. More importantly, squatting simply refills any newly granted capacity at the same trivial cost. The bound is not a defence; it is the resource being consumed.Purging existing squatters requires a storage migration, not a parameter change, because the pallet has no eviction path at all.
Governance is narrower than it appears.
runtime/src/lib.rs:253-259leaves pallet index 10 (Referenda) and index 12 (ConvictionVoting) vacant, commented "removed with the public/token-weighted governance lane". OnlyTechCollectiveandTechReferendaremain, so remediation is a permissioned technical runtime upgrade with a migration, available to neither the victim nor ordinary token holders.
Impact Details
Selected impact: Griefing (no profit motive for an attacker, but damage to the users or the protocol). Every element of that definition is satisfied literally:
No profit motive. The attacker gains nothing. The squatting accounts hold only their own balances, and those are in fact recoverable by the victim, since the victim is their guardian and
recover_fundssucceeds against every one of them (asserted in the PoC). The attack partially self-funds its own target.Damage to the users. A chosen address is permanently denied the ability to serve as a guardian. Every honest user who tries to name it receives
TooManyGuardianAccountsand is left withis_high_security() == None, that is, no protection at all. The victim has no way to clear the index and no way to refuse the entries in the first place.Damage to the protocol. The pallet's core protective feature is permanently degraded for the targeted address, and the degradation is unbounded in time and irreparable without a runtime upgrade plus a storage migration.
The addresses this targets are precisely the ones that would be published and reused: a custody or guardianship-as-a-service provider, an institutional cold-wallet guardian, a well-known community address. Because the guardian need not exist on chain, an address can be pre-squatted before its owner ever transacts, so a service can be denied its own published identity from day zero.
Scale: one target address costs the attacker 32 transactions. Denying N published guardian addresses costs 32N transactions and is trivially parallelizable.
Honest bounding, stated rather than omitted: no funds are lost or frozen by this attack, and a user can choose a different guardian address, so the denial is per-address rather than global. Rotating a published service address is operationally costly rather than impossible. What resists downgrade to Insight is that the victim has no in-protocol remedy whatsoever and the natural mitigation is defeated at negligible cost.
References
Write site:
pallets/reversible-transfers/src/lib.rs:394-396Storage definition:
pallets/reversible-transfers/src/lib.rs:248-253Production bound:
runtime/src/configs/mod.rs:528Irreversibility documented:
pallets/reversible-transfers/src/lib.rs:347Genesis write site (the only other one):
pallets/reversible-transfers/src/lib.rs:992Governance lanes removed:
runtime/src/lib.rs:253-259The project's own bound test, which this report does not dispute:
pallets/reversible-transfers/src/tests/test_reversible_transfers.rs:1630
Link to Proof of Concept
https://gist.github.com/Valisthea/b2fb4324db71630e1a3d8476ada64704
Proof of Concept
Two runnable tests, both passing against the in-scope asset at commit 3b243b87. The full existing suite still passes alongside them (53 passed, 0 failed), so nothing here is achieved by breaking or altering the project's own tests. The mock's MaxGuardianAccounts is deliberately left at its existing value of 10 rather than raised to the production 32, because raising it would break the project's own guardian_index_respects_max_limit, and because the bound's value is not the defect: it changes only how many transactions the attacker sends.
Setup
Expected output:
Test 1 — a slot is consumed without consent, against an account that does not exist
This is the step the pallet's own guardian_index_respects_max_limit never performs: it establishes that the party whose bounded resource is consumed is not the party consuming it, and has no say in the matter.
Test 2 — no dispatchable in the pallet can release an occupied slot
Exercise every dispatchable
Exercise every one of the pallet's six dispatchables, from every origin with plausible standing, and observe that none removes an entry:
call_index 0,
set_high_security, as the guardian itselfcall_index 1,
cancel, as the guardiancall_index 2,
execute_transfer, as the guardiancall_index 3,
schedule_transfer, as the guardiancall_index 4,
schedule_transfer_with_delay, as the guardiancall_index 5,
recover_funds, as the guardian, against every squatter. This is the strongest authority the pallet grants a guardian and it is asserted withassert_ok!, so the remediation genuinely executes rather than silently failing.set_high_securityagain as an occupier, showing the occupier has no exit either.
Several of these return Err (a dummy tx_id does not exist, for instance). That is not the point and is stated openly: the point is that no code path, successful or failed, ever removes an entry. The static counterpart is that GuardianIndex::<T> has exactly two write sites in the pallet, lib.rs:394 (try_push) and lib.rs:992 (genesis), and both are push-only.
Recommended fix
In descending order of preference:
Drop
GuardianIndexfrom consensus state. It exists only for UI lookup (lib.rs:243-245: "This allows the UI to efficiently query all accounts for which a given account is a guardian"). Critically,recover_fundsauthorizes againstHighSecurityAccounts, not against the index, so the index is load-bearing for no authorization decision. An off-chain indexer reconstructs it from theHighSecuritySetevent. The bounded on-chain index buys nothing and costs a permanent griefing surface.Require guardian consent, via a two-step propose/accept flow.
Charge a refundable deposit per index entry, making saturation costly and reclaimable.
Add an eviction path (
renounce_guardianship, guardian-initiated), which the pallet lacks entirely.
Any fix also needs a storage migration to purge entries created before it lands.
Was this helpful?