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

Unbounded loop in _replaceScope can cause Out of Gas (DoS) permanently bricking the pool scope

Author Revealed upon completion

Root + Impact

Description

  • Normal Behavior:
    The pool owner can update the allowed scope of BattleChain accounts using the setPoolScope function before the scope is locked. This function calls _replaceScope to clear the old accounts and add the new ones.

  • Specific Issue/Problem:
    The _replaceScope function uses two unbounded for loops. The first loop iterates over the existing _scopeAccounts to set their mappings to false. The second loop iterates over the new accounts array and makes an external call (IAgreement.isContractInScope) for every single address. Because there is no upper limit on accounts.length, adding a massive array will exceed the block gas limit (OOG).
    Even worse, if the owner successfully adds a large array that barely fits the block limit, any future calls to setPoolScope (even with a small array) will revert because the first loop clearing the old array will now hit the block gas limit, permanently locking the scope early.

// Root cause in the codebase: Unbounded loops and external calls
function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
address[] memory old = _scopeAccounts;
// @> Unbounded loop 1 (can OOG if old.length is too large)
for (uint256 i; i < old.length; ++i) {
isAccountInScope[old[i]] = false;
}
delete _scopeAccounts;
// @> Unbounded loop 2 with external calls
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
// @> External call inside a loop massively increases gas cost
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
}

Risk

Likelihood:

  • Reason 1 // The protocol sponsor/owner attempts to whitelist a large number of BattleChain accounts at once for a massive pool.

  • Reason 2 // There is no upper limit enforced on the length of the accounts array to prevent gas limit exhaustion.

Impact:

  • Impact 1 // The setPoolScope function becomes permanently bricked (Denial of Service) due to the Out-of-Gas error on the first loop.

  • Impact 2 // The pool sponsor permanently loses the ability to modify the pool scope even before scopeLocked becomes true.

Proof of Concept

Explanation:
The provided Proof of Concept (PoC) below demonstrates how the lack of array length validation can permanently brick the pool scope functionality. In the first step, the pool owner submits a massive array of 3,000 addresses. Due to the external calls inside the loop, this will likely hit the block gas limit and fail. However, if it somehow succeeds, the state becomes severely bloated. In the subsequent step, if the owner tries to update the scope again with just 1 address, the transaction will definitively revert with an Out-of-Gas (OOG) error because the first loop must iterate 3,000 times just to clear the previous state. This permanently locks the owner out of using setPoolScope.

// Foundry PoC demonstrating the gas exhaustion concept
function test_ReplaceScope_Out_Of_Gas_DoS() public {
// 1. Owner prepares a massive array of accounts
uint256 massiveAmount = 3000;
address[] memory massiveAccounts = new address[](massiveAmount);
for(uint160 i = 0; i < massiveAmount; i++) {
massiveAccounts[i] = address(1000 + i);
// Assume IAgreement mock returns true for all these
}
// 2. The transaction will either fail here due to block gas limit...
vm.prank(owner);
pool.setPoolScope(massiveAccounts);
// 3. OR, if it passes, trying to update it again with just 1 address will fail
// because the first loop in _replaceScope has to iterate 3000 times to clear state
address[] memory newSmallArray = new address[](1);
newSmallArray[0] = address(9999);
vm.prank(owner);
// This will revert with Out-Of-Gas (OOG)
pool.setPoolScope(newSmallArray);
}

Recommended Mitigation

Explanation:
To mitigate this Denial of Service (DoS) vulnerability, the protocol must enforce a strict upper limit on the size of the accounts array passed to the function. By introducing a maximum length check (e.g., maximum 200 or 500 addresses per call, depending on the protocol's expected operational needs), we can mathematically guarantee that the for loops and the external calls will never exceed the block gas limit. This simple bounding check ensures the function remains operational at all times and prevents the contract from being accidentally bricked.

function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
+ // Add a reasonable upper bound to prevent Block Gas Limit DoS
+ if (accounts.length > 200) revert TooManyAccounts();
address[] memory old = _scopeAccounts;

Support

FAQs

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

Give us feedback!