FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: low
Likelihood: low

Unbounded loop in `_replaceScope` allows scope updates to be DOS'd by prior scope size

Author Revealed upon completion

Root + Impact

A prior large scope makes subsequent setPoolScope calls prohibitively expensive or impossible — the owner cannot update the pool's BattleChain account scope. Scope is informational (not load-bearing for any fund movement), but the denial of a documented owner capability is a permanent impairment of protocol functionality. _replaceScope clears every old-scope account by iterating the entire existing scope array before validating and applying the new scope. Each old entry costs an SSTORE (isAccountInScope[old[i]] = false). The cost scales linearly with the existing scope size with no upper bound.

function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
// Clear existing scope first so this is a wholesale replacement.
address[] memory old = _scopeAccounts;
// @> for (uint256 i; i < old.length; ++i) {
// @> isAccountInScope[old[i]] = false;
// @> }
for (uint256 i; i < old.length; ++i) {
isAccountInScope[old[i]] = false;
}
delete _scopeAccounts;
// Apply the new scope and validate against the agreement.
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
emit ScopeUpdated(accounts);
}

Description

  • When the pool owner calls setPoolScope to update the BattleChain accounts the pool monitors, _replaceScope first iterates the entire existing scope array to clear isAccountInScope for every old account, then applies the new scope. This is a valid wholesale-replacement pattern — the owner controls the input scope and is trusted to keep it reasonably sized.
    The loop that clears the old scope iterates over _scopeAccounts with no upper bound on its length. Each iteration executes an SSTORE (isAccountInScope[old[i]] = false). If a prior scope contained thousands of accounts, the cumulative gas cost of the clearing loop alone can exceed the block gas limit or become economically prohibitive, permanently preventing any subsequent scope update.

function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
// Clear existing scope first so this is a wholesale replacement.
address[] memory old = _scopeAccounts;
// @> for (uint256 i; i < old.length; ++i) {
// @> isAccountInScope[old[i]] = false;
// @> }
for (uint256 i; i < old.length; ++i) {
isAccountInScope[old[i]] = false;
}
delete _scopeAccounts;
// Apply the new scope and validate against the agreement.
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
emit ScopeUpdated(accounts);
}

Risk

Likelihood:

  • The owner must have previously set a scope with thousands of accounts for the clearing loop to consume prohibitive gas. Normal scope sizes are expected to be tens to low hundreds of accounts.

  • The window for scope updates is limited: scopeLocked locks after the registry leaves pre-attack staging (first observation of any other state).

Impact:

  • Scope is informational only, never read by any fund-moving or bonus-computation logic. Stakers, claims, and sweeps are unaffected.

  • The owner cannot update the pool's BattleChain account list, impairing a documented owner capability.

  • The existing scope remains in place and functional; only updates are blocked.

Proof of Concept

  1. Owner calls setPoolScope with 5,000 accounts during pre-attack staging.

  2. Later, owner calls setPoolScope again with a small updated set.

  3. The _replaceScope function must SSTORE isAccountInScope to false for all 5,000 old accounts before it can write the 10 new ones.

  4. At ~5k gas per non-zero-to-zero SSTORE (net of refunds), the clearing loop alone costs ~25M gas, pushing the transaction close to or beyond the block gas limit.

  5. The call reverts with OOG. The old scope is preserved but cannot be replaced.

function testClearingLoopIsUnbounded() external {
// 1. Owner sets a 3,000-account scope during pre-attack staging.
uint256 N = 3_000;
address[] memory big = new address[](N);
for (uint256 i; i < N; ++i) {
big[i] = address(uint160(1 + i));
agreementContract.setContractInScope(big[i], true);
}
pool.setPoolScope(big);
// 2. Later, owner needs to update the scope (10 accounts).
address[] memory small = new address[](10);
for (uint256 i; i < 10; ++i) {
small[i] = address(uint160(N + 1 + i));
agreementContract.setContractInScope(small[i], true);
}
// 3. _replaceScope must first clear all 3,000 old accounts (SSTORE each)
// before it can write the 10 new ones. Gas cost ~600k for clearing alone.
uint256 g = gasleft();
pool.setPoolScope(small);
uint256 gasUsed = g - gasleft();
// The clearing loop costs O(N). At N=30,000 the clearing exceeds block gas.
// No on-chain cap prevents the owner from reaching that size.
assertTrue(gasUsed > 500_000);
}

Recommended Mitigation

Three options, ordered by preference:

Option A (minimal): Remove the old-scope clearing loop. The new scope's accounts are independently set to true in the second loop. Stale accounts from the old scope remain in isAccountInScope as true but do not affect the protocol (scope is not read by any core logic). The _scopeAccounts array is still overwritten by delete + push.

Option B (refund-safe): Move the clear loop AFTER the validation loop so the revert-on-failure atomicity is preserved, and limit per-transaction scope size.

Option C (bound): Keep the current structure but enforce accounts.length <= MAX_SCOPE_SIZE (e.g., 500) in setPoolScope. The owner must batch scope updates within the limit.

function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
// Remove the old-scope clearing loop entirely.
// address[] memory old = _scopeAccounts;
// for (uint256 i; i < old.length; ++i) {
// isAccountInScope[old[i]] = false;
// }
delete _scopeAccounts;
// ...rest unchanged...
}
//option B
function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
// Validate all new accounts against the agreement FIRST.
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
}
// All new accounts validated. Now safely clear old scope.
address[] memory old = _scopeAccounts;
for (uint256 i; i < old.length; ++i) {
isAccountInScope[old[i]] = false;
}
delete _scopeAccounts;
// Apply new scope.
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
emit ScopeUpdated(accounts);
}
//Option C (bound):** Keep the current structure but enforce `accounts.length <= MAX_SCOPE_SIZE` (e.g., 500) in `setPoolScope`. The owner must batch scope updates within the limit.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!