3.8.6 Denial of Service

A denial of service vulnerability in a smart contract is any bug that prevents legitimate users from interacting with the contract. The contract is not drained, no funds are stolen — but the operations that should work, don't. For protocols where availability is critical (DEXes, lending markets, governance contracts), DoS bugs can be as damaging as direct theft. Users with locked funds, governance votes that can't be cast, liquidations that can't complete: all are forms of value loss that don't show up as a transferred token.

Smart contract DoS has nothing to do with traditional network-level DoS. There is no flood of packets to filter, no rate-limit at the network edge. Smart contract DoS is logical: the contract has a code path that, under attacker-chosen conditions, becomes uncallable or behaves incorrectly. The attacker doesn't overwhelm the system; they trick it into refusing service.

This section covers the four DoS patterns that produce most production losses: unbounded loops, push-payment DoS via reverting recipients, force-fail callbacks, and unbounded storage growth. Section 3.7.5 (Defensive Patterns) covers the operational defenses (pause, rate limit) that contain DoS damage when it happens; this section covers the code-level bugs that create the vulnerability in the first place.

The historical examples are instructive. The GovernMental Ponzi (2016) had ~1100 ETH locked in a contract whose payout function tried to iterate over all participants — the loop hit the block gas limit and the contract became uncallable. King of the Ether Throne (2016) lost the throne to whichever address had a reverting receive(), since no subsequent claim could pay them off. The Cover Protocol (2020) had a vulnerability where a forced revert during a critical interaction caused legitimate operations to fail. None of these contracts were "hacked" in the conventional sense — they were jammed.

Unbounded Loops Over User-Controlled Sets

The classic smart contract DoS pattern. A contract maintains a collection (array, mapping with iteration support) that any user can add to, and a critical operation iterates over the entire collection. The iteration cost grows linearly with the collection size; eventually the cost exceeds the block gas limit and the operation becomes impossible.

Vulnerable Example

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

contract Lottery {
    address[] public players;
    mapping(address => bool) public hasEntered;

    function enter() external payable {
        require(msg.value == 0.1 ether, "wrong entry fee");
        require(!hasEntered[msg.sender], "already entered");
        players.push(msg.sender);
        hasEntered[msg.sender] = true;
    }

    // BUG: iterates over all players to pay them
    function distribute() external {
        uint256 amount = address(this).balance / players.length;
        for (uint256 i = 0; i < players.length; ++i) {
            payable(players[i]).transfer(amount);
        }
    }
}

Two compounding problems:

  1. players.push() is unbounded. Anyone willing to pay 0.1 ETH can add an entry. There is no cap on how large the array grows.

  2. distribute() iterates the entire array. Each iteration costs ~25,000 gas (a transfer plus a storage read). With Ethereum's ~30 million gas per block, the function can handle roughly 1200 players before exceeding the block gas limit and becoming uncallable.

An attacker can deliberately stuff the array (sybil entries with separate addresses) to push it past the safe iteration threshold. The contract's funds become permanently inaccessible.

This is exactly what happened to the GovernMental Ponzi in 2016 — ~1100 ETH locked because the payout function tried to iterate over the participant list and the gas cost exceeded the block limit. The funds remain stuck on-chain to this day.

Fixed Example: Pull-Based Pattern

Restructure so each user pulls their share rather than the contract pushing to all:

contract Lottery {
    address[] public players;
    mapping(address => bool) public hasEntered;
    mapping(address => uint256) public withdrawable;
    bool public distributionFinalized;

    function enter() external payable {
        require(msg.value == 0.1 ether, "wrong entry fee");
        require(!hasEntered[msg.sender], "already entered");
        players.push(msg.sender);
        hasEntered[msg.sender] = true;
    }

    function finalize() external {
        require(!distributionFinalized, "already finalized");
        require(players.length > 0, "no players");
        distributionFinalized = true;

        uint256 share = address(this).balance / players.length;
        // Don't iterate — store the share, let users withdraw
        sharePerPlayer = share;
    }

    uint256 public sharePerPlayer;
    mapping(address => bool) public claimed;

    function claim() external {
        require(distributionFinalized, "not finalized");
        require(hasEntered[msg.sender], "not a player");
        require(!claimed[msg.sender], "already claimed");
        claimed[msg.sender] = true;
        payable(msg.sender).transfer(sharePerPlayer);
    }
}

The total work is the same, but it's distributed across N separate transactions instead of one. Each user pays their own gas to withdraw. If any single user can't be paid (reverting receive(), etc.), only their share is affected — the rest of the players can still claim.

Section 3.7.1 covers Pull-over-Push as a control flow pattern; this is exactly its application.

Fixed Example: Batch Processing

When iteration genuinely cannot be avoided, structure it in chunks:

function distributeBatch(uint256 startIndex, uint256 batchSize) external {
    require(distributionFinalized, "not finalized");
    uint256 end = startIndex + batchSize;
    if (end > players.length) end = players.length;

    for (uint256 i = startIndex; i < end; ++i) {
        address player = players[i];
        if (!distributed[player]) {
            distributed[player] = true;
            payable(player).transfer(sharePerPlayer);
        }
    }
}

The caller controls the batch size; multiple transactions complete the full distribution. Each transaction stays well within the block gas limit. The distributed[player] check prevents double-payment if the same range is processed twice.

The trade-off is operational complexity — someone must initiate the batches, and the contract must track completion. This pattern is appropriate when the iteration is inherent to the operation (e.g., snapshotting balances across many holders) and pull-based payment doesn't fit the model.

Foundry Test for Gas-Limit DoS

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

import "forge-std/Test.sol";
import "../src/Lottery.sol";

contract LotteryDoSTest is Test {
    Lottery lottery;

    function setUp() public {
        lottery = new Lottery();
    }

    function test_distributeWithManyPlayersExceedsGasLimit() public {
        // Add many players, each with a fresh address
        for (uint256 i = 0; i < 2000; ++i) {
            address player = address(uint160(0x1000 + i));
            vm.deal(player, 0.1 ether);
            vm.prank(player);
            lottery.enter{value: 0.1 ether}();
        }

        // distribute() should now exceed reasonable gas budget
        uint256 gasBefore = gasleft();
        try lottery.distribute{gas: 30_000_000}() {
            uint256 gasUsed = gasBefore - gasleft();
            assertGt(gasUsed, 25_000_000, "should consume near-block-limit gas");
        } catch {
            // Expected: out of gas
        }
    }
}

This test proves the failure mode exists. A test against the fixed version should show that distribution completes in many small transactions, each within reasonable gas. The corresponding tests assert that no single transaction exceeds, say, 1M gas.

Push-Payment DoS via Reverting Recipient

A function makes payments to multiple recipients in a single transaction. If any one recipient cannot accept the payment — because their receive() reverts, their contract has no payable fallback, or they consume all forwarded gas — the entire batch reverts.

Vulnerable Example

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

contract Auction {
    address public highestBidder;
    uint256 public highestBid;

    function bid() external payable {
        require(msg.value > highestBid, "bid too low");

        if (highestBidder != address(0)) {
            // BUG: push refund to previous bidder
            payable(highestBidder).transfer(highestBid);
        }

        highestBidder = msg.sender;
        highestBid = msg.value;
    }
}

The attacker:

  1. Deploys a contract whose receive() always reverts:
    contract Blocker {
        receive() external payable {
            revert("nope");
        }
        function attack(Auction auction, uint256 bidAmount) external payable {
            auction.bid{value: bidAmount}();
        }
    }
    
  2. Bids through Blocker. The auction now has highestBidder == Blocker.
  3. Any subsequent legitimate bid triggers payable(Blocker).transfer(...) to refund the Blocker's bid — which reverts. The new bid also reverts.
  4. The auction is permanently stuck at Blocker's bid.

King of the Ether Throne (2016) is the canonical example of this pattern. The contract paid the previous "king" when a new player claimed the throne by paying more than the current price. Someone deployed a reverting contract as the king, and no subsequent player could claim the throne because the refund to the malicious king always reverted.

Fixed Example: Pull-Over-Push

contract Auction {
    address public highestBidder;
    uint256 public highestBid;
    mapping(address => uint256) public pendingReturns;

    function bid() external payable {
        require(msg.value > highestBid, "bid too low");

        if (highestBidder != address(0)) {
            // Credit the previous bidder; let them pull
            pendingReturns[highestBidder] += highestBid;
        }

        highestBidder = msg.sender;
        highestBid = msg.value;
    }

    function withdraw() external {
        uint256 amount = pendingReturns[msg.sender];
        require(amount > 0, "nothing to withdraw");
        pendingReturns[msg.sender] = 0;
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");
    }
}

The previous bidder's refund goes to pendingReturns rather than being pushed. If they can't withdraw (their receive() reverts on their own call), only they suffer; the auction continues. This is the same pattern from Section 3.7.1, applied as a defense rather than a pattern.

When Push Is Mandatory

Some flows genuinely need to push — single-transaction settlement, atomic protocol operations, etc. When push is mandatory:

function payOrCredit(address recipient, uint256 amount) internal {
    (bool ok, ) = recipient.call{value: amount}("");
    if (!ok) {
        // Push failed — fall back to credit, no revert
        pendingReturns[recipient] += amount;
    }
}

This pattern tries the push but tolerates failure by falling back to pull. The transaction succeeds regardless of recipient behavior. Use cautiously — it creates a state where pendingReturns represents debts to potentially malicious recipients, and the protocol must handle the accounting.

Force-Fail Callbacks During Critical State Transitions

A more subtle variant. The contract makes an external call during a critical operation, and the call's failure propagates upward, reverting the entire operation. An attacker who controls the called contract can force the failure.

Vulnerable Example

contract Marketplace {
    struct Listing {
        address seller;
        uint256 price;
        bool active;
    }
    mapping(uint256 => Listing) public listings;

    function list(uint256 tokenId, uint256 price) external {
        IERC721 nft = IERC721(nftContract);
        require(nft.ownerOf(tokenId) == msg.sender, "not owner");
        listings[tokenId] = Listing({
            seller: msg.sender,
            price: price,
            active: true
        });
        // BUG: external call inside critical state transition
        nft.safeTransferFrom(msg.sender, address(this), tokenId);
    }
}

The safeTransferFrom invokes a hook on the recipient (address(this)) and, depending on the token, also a hook on the sender. If the contract has an onERC721Received hook that performs additional logic (registry updates, fee calculations, etc.), and that hook reverts under some condition, listing becomes impossible.

More dangerously: a token contract that the marketplace doesn't control can have hooks that revert. If the marketplace accepts arbitrary ERC-721 tokens, any token can be weaponized as a DoS vector against the marketplace.

Fixed Example: Isolate External Calls

function list(uint256 tokenId, uint256 price) external {
    IERC721 nft = IERC721(nftContract);
    require(nft.ownerOf(tokenId) == msg.sender, "not owner");
    nft.safeTransferFrom(msg.sender, address(this), tokenId);

    // State update happens after the transfer
    listings[tokenId] = Listing({
        seller: msg.sender,
        price: price,
        active: true
    });
}

By moving the state write after the transfer, a failing transfer simply reverts the entire function — no listing is created. The marketplace's critical state remains consistent.

Alternatively, allow listings without requiring an immediate transfer:

function list(uint256 tokenId, uint256 price) external {
    IERC721 nft = IERC721(nftContract);
    require(nft.ownerOf(tokenId) == msg.sender, "not owner");
    require(nft.getApproved(tokenId) == address(this), "approve first");

    listings[tokenId] = Listing({
        seller: msg.sender,
        price: price,
        active: true
    });
    // NFT remains with seller until purchase; transfer happens then
}

This avoids the transfer entirely during listing. The buy function does the actual transfer, where a revert means "this listing can't be completed" — but only for that specific buy, not for the entire marketplace.

The Try/Catch Pattern

For interactions with potentially-malicious external contracts, try/catch (introduced in Solidity 0.6) provides graceful failure handling:

function listAndAttemptCallback(uint256 tokenId, uint256 price, address callbackTarget) external {
    listings[tokenId] = Listing({
        seller: msg.sender,
        price: price,
        active: true
    });

    // Best-effort callback that cannot break the listing
    try IListingCallback(callbackTarget).onListing(tokenId, price) {} catch {}
}

The try/catch consumes any revert from the callback without propagating it. The listing succeeds even if the callback fails. The trade-off is loss of failure signaling — the calling code doesn't know whether the callback worked, so the callback target must be designed to be idempotent or independently observable.

Cross-reference: Section 3.8.2 covers the reentrancy variants of external-call exposure; safeTransfer is also a reentrancy vector. The DoS angle and the reentrancy angle are two consequences of the same mechanic.

Storage Growth and Unbounded State

A specific instance of the unbounded-loop problem but worth treating separately. A contract maintains state that grows without bound, and certain operations have cost proportional to the state size. Eventually, those operations become too expensive to call.

Vulnerable Example

contract Logger {
    struct Entry {
        address user;
        uint256 timestamp;
        bytes data;
    }
    Entry[] public allEntries;
    mapping(address => uint256[]) public entriesByUser;

    function log(bytes calldata data) external {
        uint256 index = allEntries.length;
        allEntries.push(Entry({
            user: msg.sender,
            timestamp: block.timestamp,
            data: data
        }));
        entriesByUser[msg.sender].push(index);
    }

    // BUG: returns all entries — gas grows with array size
    function getUserEntries(address user) external view returns (Entry[] memory) {
        uint256[] memory indices = entriesByUser[user];
        Entry[] memory result = new Entry[](indices.length);
        for (uint256 i = 0; i < indices.length; ++i) {
            result[i] = allEntries[indices[i]];
        }
        return result;
    }
}

The log() function is the wedge — anyone can call it, including with large data payloads. The getUserEntries() view function returns all of a user's entries; if a user has logged 10,000 entries, this function may run out of gas (even for view calls, RPC providers impose gas caps).

For view functions called via eth_call, gas limits are usually high (50M-100M depending on provider) but not unlimited. For storage-modifying functions, the block gas limit applies, and unbounded growth can render the contract uncallable.

Fixed Example: Pagination

Always provide bounded-size accessors:

function getUserEntries(address user, uint256 startIndex, uint256 count)
    external view returns (Entry[] memory)
{
    uint256[] memory indices = entriesByUser[user];
    uint256 end = startIndex + count;
    if (end > indices.length) end = indices.length;
    if (startIndex >= end) return new Entry[](0);

    Entry[] memory result = new Entry[](end - startIndex);
    for (uint256 i = startIndex; i < end; ++i) {
        result[i - startIndex] = allEntries[indices[i]];
    }
    return result;
}

function getUserEntryCount(address user) external view returns (uint256) {
    return entriesByUser[user].length;
}

The off-chain caller (dApp, indexer) computes how many entries to fetch and over how many pages. Each call is bounded; total work is the same but distributed across calls. The pattern is universal: any function that returns "all of X" should also accept a range parameter.

Fixed Example: Roll-Up Pattern

When the actual goal is aggregate data rather than individual entries, maintain a running aggregate instead of storing each entry:

contract Logger {
    mapping(address => uint256) public entryCount;
    mapping(address => uint256) public totalDataBytes;
    // Don't store individual entries on-chain

    event Logged(address indexed user, uint256 timestamp, bytes data);

    function log(bytes calldata data) external {
        entryCount[msg.sender]++;
        totalDataBytes[msg.sender] += data.length;
        emit Logged(msg.sender, block.timestamp, data);
    }
}

The individual entries are accessible via event indexing (off-chain) rather than on-chain storage. Aggregates are O(1) regardless of history. This pattern fits when the contract needs to know "how many entries" but not "what's in each entry."

Out-of-Gas via Forwarded Subcall

A specific case where one contract calls another, and the called contract's gas consumption is unbounded but the caller has reserved limited gas. The called contract consumes all forwarded gas, leaving the caller with nothing to continue.

Vulnerable Example

contract DistributionHub {
    address[] public recipients;

    function distributeFunds() external payable {
        uint256 share = msg.value / recipients.length;
        for (uint256 i = 0; i < recipients.length; ++i) {
            // BUG: forwards all remaining gas — single bad recipient drains it
            payable(recipients[i]).call{value: share}("");
        }
    }
}

If one recipient is a contract that consumes all the gas it's given (a gas-griefing recipient), subsequent calls in the loop fail. Even if the calls don't propagate reverts (they use call and don't check return values), the loop runs out of gas before completing.

Fixed Example: Bounded Gas Per Subcall

function distributeFunds() external payable {
    uint256 share = msg.value / recipients.length;
    for (uint256 i = 0; i < recipients.length; ++i) {
        // Cap gas per subcall to prevent griefing
        (bool ok, ) = payable(recipients[i]).call{value: share, gas: 30_000}("");
        // Even if `ok` is false, we keep going
        if (!ok) {
            failedTransfers[recipients[i]] += share;
        }
    }
}

Capping gas per subcall ensures one griefing recipient can't consume the entire transaction's gas budget. Failed transfers are tracked separately so they can be retried (as withdrawals) later.

The 30,000 gas figure is illustrative — actual figures depend on what legitimate recipients need to do. For simple ETH transfers to EOAs, 21,000 gas is enough. For transfers to contracts that emit events on receipt, ~50,000 may be appropriate. The historical 2300 gas stipend (from .transfer() and .send()) is too small for most modern recipients after EIP-2929; see Section 3.7.7 for that history.

Quick Reference

DoS PatternWhat goes wrongDefense
Unbounded loops over user-controlled setsIteration cost exceeds block gas limit as set growsPull-based pattern; batch processing with caller-controlled chunks
Push payment to malicious recipientSingle reverting recipient blocks all paymentsPull-over-push; or try-push-fallback-to-credit
External call in critical state transitionFailed callback reverts entire operationMove state updates after external calls; or use try/catch
Unbounded storage growthView/state-changing functions exceed gas as data growsPagination; aggregate roll-up pattern with event indexing
Forwarded subcall consumes all gasSingle griefing subcall drains the loop's gas budgetCap gas per subcall; track failures for retry

Cross-References

  • Pull-over-Push — Section 3.7.1 covers the pattern that defends against push-payment DoS
  • Defensive patterns — Section 3.7.5 covers circuit breakers and rate limits that contain DoS damage
  • Gas optimization — Section 3.6 covers gas optimization without compromising security
  • Reentrancy — Section 3.8.2 covers the related external-call hazards that produce reentrancy bugs from the same call sites
  • Real exploits — Section 3.10 includes historical DoS incidents (GovernMental, King of the Ether Throne)
  • Auditor's view — Section 4.11.6 (Gas Vulnerabilities) and 4.11.7 (DoS Attacks) cover detection during audit