For the complete documentation index, see llms.txt. This page is also available as Markdown.

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

Three properties matter:

  • The guardian is an arbitrary caller-supplied AccountId. 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 require who to 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

1

An attacker selects a victim account

Any account someone might plausibly nominate as a guardian — for instance a published custody or treasury address. The pallet README explicitly contemplates this pattern:

The victim is never contacted and takes no action.

2

The attacker builds one batch of derivative enrollments

For each derivative index 0..32, wrap a set_high_security call naming the victim as guardian:

3

The attacker submits it as a single transaction

One signature, one fee. None of the 32 pseudonyms is funded.

4

The victim's capacity is now permanently consumed

Every slot is held by an unfunded pseudonym the victim never approved. There is no path to release them.

5

Legitimate users are refused

Any other account that now tries to nominate that victim as guardian fails:

6

But the denial is narrow (see §4)

The victim can still transact, still hold funds, and still enroll itself under a different guardian. Displaced users simply nominate a different address.

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:

Property
Result

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

  1. 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.

  2. 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.

  3. 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.

  4. Consider whether as_derivative pseudonyms 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

1

Clone the repository and check out the audited commit

2

Install and configure the toolchain

3

Append the test

Append the test in §6.3 to:

4

Run the test

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?