Skip to main content
Sigvex

Remediating Loop Gas Exhaustion

How to prevent DoS via block gas limit exhaustion by adding maximum iteration limits, pagination, and pull-over-push patterns.

Remediating Loop Gas Exhaustion

Overview

Related Detector: Loop Gas Exhaustion

Loop gas exhaustion occurs when a loop’s iteration count is unbounded and derived from user input or mutable state. An attacker provides input that forces enough iterations to exceed the block gas limit. The fix is to cap iterations with a constant maximum, implement pagination, or redesign the operation to avoid unbounded loops.

Add a Maximum Iteration Limit

// BEFORE: Unbounded loop from calldata
function distribute(address[] calldata recipients, uint256[] calldata amounts) external {
    for (uint256 i = 0; i < recipients.length; i++) {
        token.transfer(recipients[i], amounts[i]);
    }
}

// AFTER: Bounded with maximum
uint256 constant MAX_BATCH = 100;

function distribute(address[] calldata recipients, uint256[] calldata amounts) external {
    require(recipients.length <= MAX_BATCH, "Batch exceeds maximum");
    require(recipients.length == amounts.length, "Length mismatch");
    for (uint256 i = 0; i < recipients.length; i++) {
        token.transfer(recipients[i], amounts[i]);
    }
}

Alternative Mitigations

Pagination for Growing Datasets

For operations over storage arrays that grow over time, use paginated processing:

uint256 constant PAGE_SIZE = 50;

function processRewards(uint256 fromIndex) external returns (uint256 nextIndex) {
    uint256 total = stakers.length;
    uint256 end = fromIndex + PAGE_SIZE;
    if (end > total) end = total;

    for (uint256 i = fromIndex; i < end; i++) {
        _distributeReward(stakers[i]);
    }
    return end;
}

Pull-Over-Push for Withdrawal Operations

Replace push-based distribution with pull-based claims:

// BEFORE: Push pattern with unbounded loop
function distributeRewards() external {
    for (uint256 i = 0; i < stakers.length; i++) {
        payable(stakers[i]).transfer(rewards[stakers[i]]);
    }
}

// AFTER: Pull pattern -- no loop needed
mapping(address => uint256) public pendingRewards;

function claimReward() external {
    uint256 amount = pendingRewards[msg.sender];
    require(amount > 0, "Nothing to claim");
    pendingRewards[msg.sender] = 0;
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "Transfer failed");
}

Off-Chain Computation with On-Chain Verification

For complex batch operations, compute the result off-chain and submit a Merkle proof:

function claimBatchReward(bytes32[] calldata proof, uint256 amount) external {
    bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, amount))));
    require(MerkleProof.verify(proof, rewardRoot, leaf), "Invalid proof");
    require(!claimed[msg.sender], "Already claimed");
    claimed[msg.sender] = true;
    token.transfer(msg.sender, amount);
}

Common Mistakes

Mistake: Using Storage Array Length as Loop Bound

address[] public users;

function addUser(address user) external {
    users.push(user);  // Array grows without bound
}

function processAllUsers() external {
    for (uint256 i = 0; i < users.length; i++) {  // Unbounded
        _process(users[i]);
    }
}

Either cap the array size in addUser() or paginate processAllUsers().

Mistake: Setting the Maximum Too High

uint256 constant MAX_BATCH = 10_000;  // Still too many if loop body is expensive

Calculate the maximum based on the gas cost per iteration: MAX = block_gas_limit / gas_per_iteration / safety_factor. For a loop body costing 45,000 gas with a 30M gas limit, MAX = 30M / 45K / 2 = 333.

Mistake: Bounding the Loop but Not the Array Growth

function processUsers(uint256 count) external {
    require(count <= MAX_BATCH, "Too many");
    for (uint256 i = 0; i < count; i++) {
        _process(users[i]);
    }
}
// users array still grows without bound -- processing future batches
// becomes impossible when users.length >> MAX_BATCH

Cap both the processing batch size and the data structure growth rate.

References