88222 bc medium one signer can permanently saturate any account s guardian enrollment capacity without consent using unfunded derivative origins
Submitted on Aug 12th 2026 at 09:55:05 UTC by @shadowHunter for Audit Comp | Quantus
Report ID: #88222
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
1. Summary
set_high_security lets any signed origin nominate an arbitrary account as its "guardian". The nominated account is never asked. Each enrollment appends the enrolling account to a guardian-keyed bounded vector capped at MaxGuardianAccounts (32 in production, 10 in the test mock).
Because the enrolling origin need not be funded and need not exist, a single funded signer can — in one transaction with one signature and one fee — occupy every slot of a chosen account's index using Utility::batch over Utility::as_derivative pseudonyms.
There is no guardian acceptance step, no per-slot deposit, no expiry, no pruning, and no removal path. The saturation is permanent.
What this actually denies: other users can no longer nominate that particular account as their guardian. Nothing else.
2. Description
2.1 Enrollment is consent-free and free of charge
Three properties matter:
The
guardianis an arbitrary caller-suppliedAccountId. There is no check that the guardian agreed, exists, or is funded.There is no deposit, reserve, or existential-deposit requirement on the enrolling origin. Writing
HighSecurityAccounts::insert(who, ..)does not requirewhoto be a funded, provider-referenced account.Capacity is consumed on the guardian's key, so the cost is borne by a third party.
2.2 The cap is small and shared
The bound is declared as type MaxGuardianAccounts: Get<u32>; at pallets/reversible-transfers/src/lib.rs:158, and consumed as the bound on the index vector at :252:
Production is therefore 32; the pallet's own test mock uses 10. The PoC in §6 reads the constant rather than hardcoding either.
2.3 There is no way to free a slot
Every reference to GuardianIndex in the tree:
Both mutation sites only add. There is no remove, no expiry, no guardian-side rejection, and no disavow extrinsic. Reaping an unfunded enrolling account does not clear the pallet storage.
Note also that GuardianIndex is never read by any pallet logic — the in-code comment at :991 describes it as existing "so guardian can look up their protected accounts", i.e. an off-chain convenience index. The only functional consequence of it being full is the try_push failure inside set_high_security. This is central to the severity assessment in §4.
2.4 as_derivative makes the attack essentially free
Utility::as_derivative(index, call) dispatches call with the effective origin set to a pseudonym derived from (b"modlpy/utilisuba", caller, index). Each index yields a distinct signed origin. Those pseudonyms need no balance and no prior existence — note that nothing between ensure_signed and call.dispatch requires the pseudonym to exist, hold a balance, or be provider-referenced.
The high-security call filter does not block this: the pseudonym is not itself a high-security account at the moment it enrolls, so is_call_allowed(&pseudonym, &call) passes.
Wrapping 32 of these in one Utility::batch means the attacker signs and pays once.
3. Steps to reproduce
4. Impact — and the deliberate ceiling on it
What the attacker achieves: permanent, consent-free consumption of a third party's bounded state, for the cost of a single transaction. The state mutation is genuinely unauthorized: the victim never agreed to be anyone's guardian.
What the attacker does not achieve — proven, not assumed. The proof-of-concept below asserts each of the following and they all pass:
Victim's funds affected
No
Victim can still transact
Yes
Victim can still enroll under another guardian
Yes (asserted)
Displaced user can use a different guardian
Yes (asserted)
Existing high-security relationships broken
No
Because GuardianIndex is never read by pallet logic (§2.3), a full index does not disable any protection, freeze any funds, or block any existing guardian relationship. It blocks exactly one thing: new nominations naming that specific address.
Since guardianship requires no setup, consent, or state from the guardian, an affected party's remedy is to nominate a different address — and the "victim" can publish a fresh address at will. The cost of that remedy is reputational/operational (re-publishing a well-known custody address), not financial.
5. Recommendation
Require guardian consent. A two-step nomination (propose → guardian accepts) removes the entire class. The guardian is a trusted role; enrolling someone into it unilaterally is questionable independently of the capacity issue.
Do not let a third party consume a bounded, victim-keyed resource. If the index is only a lookup convenience (as the code comment suggests), it should not be able to gate enrollment. Prefer a paginated or unbounded-with-deposit relationship index, or derive the guardian's view by iteration/off-chain indexing rather than a capped on-chain vector.
If a cap must remain, charge a per-slot deposit to the effective enrolling origin and provide guardian-side rejection plus pruning, so slots can be reclaimed.
Consider whether
as_derivativepseudonyms should be able to create deposit-free permanent state at all. This finding is one instance of a general pattern: an unfunded derivative origin writing durable storage that a funded account would have had to pay for.
Proof of Concept
6. Proof of concept
6.1 Nature of this PoC
This is a project-native test, added to the pallet's own suite and executed with the repository's own toolchain against the audited commit. It exercises the real runtime composition — Utility::batch → Utility::as_derivative → ReversibleTransfers::set_high_security — in the pallet's mock runtime, which includes pallet_utility at index 6.
The mock's MaxGuardianAccounts is 10 rather than the production 32; the mechanism is identical and the test reads the constant rather than hardcoding it.
6.2 Setup
6.3 The test, complete and unmodified
6.4 PoC output
6.5 Explanation of the output
Every pseudonym's balance is asserted 0 both before the batch and after it appears in the index, so the attacker demonstrably pays nothing per slot — only the single outer transaction. Each occupied slot is asserted to equal derivative_account_id(attacker, i) and asserted != attacker, proving the slots are held by distinct pseudonymous origins rather than by the signer. The legitimate user's enrollment then fails with TooManyGuardianAccounts, and the index length is re-checked afterwards to show the failure is permanent rather than transient.
Was this helpful?