← Back to Blog

Unbounded Loop Denial of Service Solidity: Array DoS

2026-07-09 solidity smart-contract-security denial-of-service dos-attack gas-limit blockchain-security contractscan web3-security

Unbounded Loop Denial of Service Solidity: How Array Growth Bricks Your Contract

Smart contract developers often prioritize protecting protocol funds against complex flash loan exploits or reentrancy vectors. However, one of the most destructive security flaws in Web3 is far simpler: the unbounded loop denial of service solidity vulnerability. While reentrancy steals assets, unbounded loop DoS bricks contracts permanently, locking up user funds without any hope of recovery. When a smart contract's critical state transition logic relies on iterating over a dynamic array that grows indefinitely, it creates a severe vulnerability. As the array size increases, the gas required to execute the loop scales linearly ($O(N)$), eventually exceeding the block gas limit and rendering the contract entirely unusable.


Understanding the Unbounded Loop Denial of Service Solidity Vulnerability

To understand how unbounded loops freeze contracts, we must examine the gas mechanics of the Ethereum Virtual Machine (EVM). Every operation executed on-chain consumes a deterministic amount of gas. The EVM imposes a hard limit on the total gas that can be consumed within a single block—known as the block gas limit. On the Ethereum Mainnet, this limit is targeted at 30,000,000 gas. Other Layer-2 networks have their own specific block gas limits and gas metering models, but the constraint remains identical: no single transaction can consume more gas than the block limit.

When a contract loops over a dynamic array, each iteration performs several EVM operations. Under the EVM gas schedule, state storage operations are particularly expensive:
* SLOAD (reading from storage): Costs 2,100 gas for a "cold" storage slot and 100 gas for a "warm" slot.
* SSTORE (writing to storage): Costs up to 20,000 gas for initializing a slot and 5,000 gas for modifying it.
* CALL (external transfer or execution): Costs 2,600 gas for a "cold" address and 100 gas for a "warm" address.

If a loop contains a storage read, a state write, and an external call, a single iteration can easily cost 10,000 to 15,000 gas. If the array grows to 3,000 elements, the transaction will require approximately 30,000,000 to 45,000,000 gas to execute. This exceeds the block gas limit, causing the transaction to consistently run out of gas and revert. The contract functions that depend on this loop are permanently broken.


Vulnerable Smart Contract Pattern

A classic pattern where this vulnerability manifests is in batch distributions, user registries, or voting systems. The following Solidity contract demonstrates a vulnerable reward distributor that iterates over a dynamic array of recipients to push payments.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VulnerableDistributor {
    address[] public recipients;
    mapping(address => uint256) public balances;
    address public owner;

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function addRecipient(address _recipient) external {
        require(balances[_recipient] == 0, "Already added");
        recipients.push(_recipient);
        balances[_recipient] = 0.01 ether; // Mock reward amount
    }

    /**
     * @notice Distribute rewards to all registered recipients.
     * @dev VULNERABLE: If the recipients array grows too large, the gas cost 
     * to execute the loop will exceed the block gas limit, causing all transactions to revert.
     */
    function distributeRewards() external onlyOwner {
        uint256 length = recipients.length;

        // VULNERABLE LOOP: Iterates over the entire dynamic array
        for (uint256 i = 0; i < length; i++) { 
            address recipient = recipients[i];
            uint256 amount = balances[recipient];
            if (amount > 0) {
                balances[recipient] = 0;
                (bool success, ) = recipient.call{value: amount}("");
                require(success, "Transfer failed");
            }
        }
    }

    receive() external payable {}
}

In the VulnerableDistributor contract, the addRecipient function allows anyone to add new addresses to the recipients array. As the size of recipients increases, the contract must perform multiple storage reads (SLOAD), state writes (SSTORE), and external message calls (CALL) inside the loop. If the array grows into the thousands, the cumulative gas cost escalates rapidly. Because the array can grow indefinitely, the gas required to execute distributeRewards will eventually surpass the block gas limit, permanently locking the rewards inside the contract.


Exploit Vector & Attack Mechanics

An unbounded loop vulnerability does not require a sophisticated exploit to cause damage; normal user adoption can organically trigger the denial of service. However, a malicious actor can deliberately exploit this pattern to execute a griefing or denial-of-service attack. The asymmetry in gas costs makes this attack highly attractive to griefers: the cost to append an element to an array is minor compared to the cost to iterate and process that element.

To execute this attack, an attacker can deploy a contract that programmatically registers thousands of unique addresses into the target contract.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IVulnerableDistributor {
    function addRecipient(address _recipient) external;
}

contract SybilAttacker {
    IVulnerableDistributor public target;

    constructor(address _target) {
        target = IVulnerableDistributor(_target);
    }

    /**
     * @notice Flood the target contract with recipient addresses.
     * @param iterations The number of dummy addresses to generate and register.
     */
    function attack(uint256 iterations) external {
        for (uint256 i = 0; i < iterations; i++) {
            // Deploy a minimal, unique contract to act as a distinct recipient address
            DummyRecipient dummy = new DummyRecipient();
            target.addRecipient(address(dummy));
        }
    }
}

contract DummyRecipient {
    receive() external payable {}
}

How the Exploit Unfolds:

  1. Target Identification: The attacker identifies the VulnerableDistributor contract and notes that the distributeRewards function loops over the entire recipients array.
  2. Flooding State: The attacker calls the attack function on the SybilAttacker contract, passing a high iteration count (e.g., 1,500).
  3. Sybil Registration: The SybilAttacker contract deploys 1,500 unique DummyRecipient instances and registers each one as a recipient.
  4. Gas Exhaustion: The target contract's recipients array now has 1,500 entries. When the contract owner attempts to call distributeRewards, the transaction consumes more gas than the block limit allows and fails.
  5. Permanent Denial of Service: Since there is no administrative function to remove recipients, the owner can never distribute the rewards. The ether sent to the contract is trapped forever.

This exploit highlights the core vulnerability: a public, write-access function (addRecipient) directly affects the execution complexity of an administrative function (distributeRewards). This violates the principle of isolated complexity.


Mitigations and Best Practices

To prevent unbounded loop denial of service solidity vulnerabilities, developers must avoid executing loops over dynamic storage collections. Two primary patterns are widely adopted to secure these contracts: the Pull-over-Push (Withdrawal) Pattern and Pagination.

Mitigation 1: Pull-over-Push (Withdrawal) Pattern

The most effective solution is to eliminate the loop entirely by shifting the responsibility of executing the transfer from the administrator to the recipient. Instead of the contract "pushing" funds to all users in a single transaction, users "pull" their own funds individually.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract PullDistributor {
    mapping(address => uint256) public balances;

    event RewardClaimed(address indexed recipient, uint256 amount);

    function addReward(address _recipient, uint256 _amount) external payable {
        require(msg.value == _amount, "Incorrect value");
        balances[_recipient] += _amount;
    }

    /**
     * @notice Allows recipients to withdraw their own rewards.
     * @dev SAFE: No loops are executed. Gas costs are shifted to the claiming user.
     */
    function claimReward() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No reward to claim");

        balances[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        emit RewardClaimed(msg.sender, amount);
    }
}

By using the Pull-over-Push pattern, the gas complexity of claiming rewards is $O(1)$ for each user. An attacker registering thousands of addresses only hurts themselves, as they must pay the transaction fees to claim each balance individually. This pattern aligns with standard Solidity security practices, such as the Checks-Effects-Interactions pattern. It is also highly recommended when reviewing your code before a smart contract audit report.

Mitigation 2: Pagination Pattern

In rare scenarios where batch processing is mandatory and the pull pattern cannot be applied, developers must implement pagination. This technique allows the administrator to process the array in smaller, controlled chunks (batches) over multiple transactions.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract PaginatedDistributor {
    address[] public recipients;
    mapping(address => uint256) public balances;
    address public owner;
    uint256 public nextIndex; // Cursor to track progress

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function addRecipient(address _recipient) external {
        require(balances[_recipient] == 0, "Already added");
        recipients.push(_recipient);
        balances[_recipient] = 0.01 ether;
    }

    /**
     * @notice Distribute rewards in bounded batches.
     * @param batchSize The maximum number of recipients to process in this transaction.
     * @dev SAFE: The loop is bounded by the batchSize parameter, preventing block gas limit issues.
     */
    function distributeBatch(uint256 batchSize) external onlyOwner {
        uint256 length = recipients.length;
        uint256 end = nextIndex + batchSize;

        if (end > length) {
            end = length;
        }

        for (uint256 i = nextIndex; i < end; i++) { // SAFE: Bounded loop
            address recipient = recipients[i];
            uint256 amount = balances[recipient];
            if (amount > 0) {
                balances[recipient] = 0;
                (bool success, ) = recipient.call{value: amount}("");
                require(success, "Transfer failed");
            }
        }

        nextIndex = end;
    }
}

With pagination, the administrator controls the batchSize parameters on each call. If gas costs rise or the network's gas rules change, the administrator can scale down the batch size (e.g., from 100 to 50) to guarantee the transaction fits within the block gas limit.

Comparison of Mitigation Strategies

Strategy Gas Complexity User Experience (UX) Developer Complexity Primary Drawbacks
Pull-over-Push $O(1)$ per user Requires active user claims Low High friction for passive users
Pagination $O(B)$ where $B$ is batch size Automatic distribution Medium High administrative overhead, state sync risks
Fixed Array Limits $O(N)$ with strict limit Automatic distribution Low Hard caps on protocol scaling

Historical Case Study: The GovernMental Lockup

One of the most famous real-world examples of an unbounded loop denial of service solidity vulnerability is the GovernMental smart contract incident of 2016. GovernMental was an early Ethereum-based Ponzi scheme contract that managed a jackpot of approximately 1,100 ETH.

The contract logic required clearing two dynamic arrays (creditorAddresses and creditorAmounts) to reset the state when a round ended and distribute payouts. As the round progressed, thousands of participants joined, and both arrays grew significantly.

When the conditions were met to end the round, the contract attempted to clear these arrays by looping through them in a single transaction. However, the gas cost of clearing thousands of storage slots exceeded the block gas limit of the Ethereum network at that time. As a result:
* The transaction to finalize the payouts consistently ran out of gas and reverted.
* The 1,100 ETH jackpot remained frozen inside the contract.
* The contract was completely locked because the clearing logic was a prerequisite for starting a new round or withdrawing funds.

The funds were trapped for a long period of time. Eventually, the jackpot was only claimed when the Ethereum network's block gas limit was increased, allowing a user to submit a transaction with enough gas to complete the clearing loop.

While the GovernMental contract was deployed during the early days of Ethereum, modern protocols still make similar mistakes. On modern Layer-2 networks, while gas is cheaper, the concept of a block gas limit or transactional gas limits still exists. A similar lockup today on a non-upgradeable contract would lead to a permanent loss of millions in Total Value Locked (TVL). For more on early vulnerability types, see our guide on Reentrancy Vulnerabilities.


Pre-Deployment Security Checklist

Before deploying any smart contract to production, run through this checklist to ensure you are safe from unbounded loop DoS vulnerabilities:


Frequently Asked Questions (FAQ)

1. Does Solidity 0.8+ prevent unbounded loop DoS vulnerabilities?

No. While Solidity 0.8.0 introduced default checks for arithmetic overflow and underflow, it does not protect against gas limits or storage growth. Unbounded loops are a logical and architectural vulnerability, not an arithmetic one. If your loop grows too large, it will run out of gas and revert regardless of the compiler version.

2. Can I use enumerable sets from OpenZeppelin to prevent this issue?

OpenZeppelin's EnumerableSet is an excellent tool for tracking unique elements in storage, but it does not prevent DoS. Under the hood, EnumerableSet stores elements in an array. If you loop over the entire set using a helper function, you are still executing an unbounded loop. You must combine EnumerableSet with pagination or the Pull-over-Push pattern.

3. What is the maximum safe array size to loop over in Solidity?

There is no fixed "safe" size because gas consumption depends on what occurs inside the loop. A loop that only reads from memory can handle thousands of elements, while a loop that performs external calls and updates storage slots may fail at fewer than 100 elements. As a rule of thumb, avoid looping over dynamic storage arrays entirely. If you must, keep the array strictly capped at under 100 elements.

4. How does EIP-150 affect gas limits and loops?

EIP-150 reserves 1/64th of the available gas when making external calls. If your unbounded loop makes external calls (e.g., sending ether or calling other contracts), the gas reservation rule means the transaction will fail even earlier, as only 63/64ths of the gas is forwarded to the sub-call. This compounding gas overhead makes external calls inside loops even more vulnerable to DoS. Detailed gas costs are documented in the Ethereum yellow paper.


Secure Your Smart Contracts Against DoS

Unbounded loops are a quiet but lethal vulnerability that can lock user funds and destroy protocol credibility. Do not leave your smart contract security to chance.

Scan your code for unbounded loops and other critical vulnerabilities for free with ContractScan.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →