← Back to Blog

Denial of Service Attacks in Solidity: Gas Griefing, Unbounded Loops, and Forced ETH

2026-04-17 solidity security dos gas griefing loops selfdestruct defi 2026

Denial of service vulnerabilities in Solidity don't just crash a contract — they permanently lock funds, block user withdrawals, and let attackers grief protocol operations with targeted gas manipulation.

Unlike most smart contract exploits, DoS attacks often don't require the attacker to steal tokens. The goal is to make the contract permanently unusable, which can be just as catastrophic for a DeFi protocol.


DoS Category 1: Unbounded Loops

The most common DoS pattern: a loop that iterates over an unbounded array. As the array grows, the loop eventually hits the block gas limit and the function becomes uncallable.

// VULNERABLE: loop over unbounded storage array
contract VulnerableAirdrop {
    address[] public recipients;
    mapping(address => uint256) public balances;

    function addRecipient(address user, uint256 amount) external onlyOwner {
        recipients.push(user);
        balances[user] += amount;
    }

    // DANGER: this fails once recipients.length is large enough
    function distributeAll() external onlyOwner {
        for (uint256 i = 0; i < recipients.length; i++) {
            payable(recipients[i]).transfer(balances[recipients[i]]);
            balances[recipients[i]] = 0;
        }
    }
}

With enough recipients (typically 1000-5000+ depending on per-iteration cost), distributeAll() exceeds the block gas limit and permanently reverts. The funds can never be distributed via this path.

Real example: Multiple NFT projects have had distributeRewards() functions that worked fine during testing with 50 holders but became permanently locked after reaching 5000+ token holders.

Fix: pagination + pull pattern

// SAFE: paginated batch distribution
function distributeBatch(uint256 start, uint256 end) external onlyOwner {
    end = end > recipients.length ? recipients.length : end;
    for (uint256 i = start; i < end; i++) {
        uint256 amt = balances[recipients[i]];
        if (amt > 0) {
            balances[recipients[i]] = 0;
            payable(recipients[i]).transfer(amt);
        }
    }
}

// BETTER: pull pattern — users claim their own funds
mapping(address => uint256) public claimable;

function claim() external {
    uint256 amount = claimable[msg.sender];
    require(amount > 0, "Nothing to claim");
    claimable[msg.sender] = 0;
    payable(msg.sender).transfer(amount);
}

The pull pattern moves the iteration cost to individual users, making the protocol gas-cost O(1) per operation rather than O(n).


DoS Category 2: Reverting External Calls in Critical Paths

If a contract calls an external address that can revert, a malicious recipient can make the entire transaction fail permanently.

// VULNERABLE: single failing recipient blocks all withdrawals
contract VulnerableEscrow {
    mapping(address => uint256) public deposits;
    address[] public depositors;

    function refundAll() external onlyOwner {
        for (uint256 i = 0; i < depositors.length; i++) {
            address user = depositors[i];
            uint256 amount = deposits[user];
            if (amount > 0) {
                deposits[user] = 0;
                // If ANY recipient is a contract that reverts on receive(),
                // this entire loop fails — no one gets refunded
                payable(user).transfer(amount);
            }
        }
    }
}

An attacker deploys a contract that reverts in its receive() function, deposits 1 wei, and ensures their address is processed before legitimate depositors. Every subsequent refundAll() call reverts.

Attack contract:

// Deployed by attacker to grief refundAll()
contract Griefer {
    receive() external payable {
        revert("I refuse ETH");
    }
}

Fix: use call with failure handling

// SAFE: non-reverting ETH send + track failures
function refundAll() external onlyOwner {
    for (uint256 i = 0; i < depositors.length; i++) {
        address user = depositors[i];
        uint256 amount = deposits[user];
        if (amount > 0) {
            deposits[user] = 0;
            // Use call instead of transfer; handle failure gracefully
            (bool success, ) = payable(user).call{value: amount}("");
            if (!success) {
                // Restore balance so user can claim later
                deposits[user] = amount;
            }
        }
    }
}

Or better yet: use the pull pattern to eliminate the loop entirely.


DoS Category 3: Forced ETH Balance Disruption

Some contracts use address(this).balance for logic (e.g., detecting deposits). An attacker can forcibly send ETH to any contract using selfdestruct — bypassing receive() and fallback() entirely.

// VULNERABLE: business logic depends on exact ETH balance
contract VulnerableVault {
    uint256 public constant STAKE_AMOUNT = 1 ether;

    function stake() external payable {
        // Expects exact 1 ETH deposit
        require(msg.value == STAKE_AMOUNT, "Must stake exactly 1 ETH");
        require(address(this).balance == STAKE_AMOUNT, "Unexpected balance"); // ← exploitable
        // ... process stake
    }
}

Attack:

contract EthForcer {
    constructor(address target) payable {
        selfdestruct(payable(target));
        // Forces ETH into target, bypassing any receive() check
    }
}

After EthForcer runs with 0.001 ETH, address(target).balance is permanently 0.001 ETH. No user can ever successfully call stake() because address(this).balance == STAKE_AMOUNT will never hold.

Fix: track deposits internally, never rely on address(this).balance for logic

// SAFE: internal accounting instead of address(this).balance
contract SafeVault {
    uint256 public totalStaked;
    mapping(address => uint256) public stakes;

    function stake() external payable {
        require(msg.value == 1 ether, "Must stake exactly 1 ETH");
        // Internal state tracks legitimate deposits only
        totalStaked += msg.value;
        stakes[msg.sender] += msg.value;
    }
}

Any ETH received via selfdestruct or coinbase transfers becomes irrelevant — the logic uses totalStaked, not address(this).balance.


DoS Category 4: Gas Griefing via Malicious Return Data

When a contract calls another contract with a fixed gas stipend, a malicious callee can return enormous amounts of data to exhaust the caller's gas.

// VULNERABLE: large returndata from untrusted contract exhausts gas
contract VulnerableAggregator {
    function executeCall(
        address target,
        bytes calldata data
    ) external {
        // target is attacker-controlled
        (bool success, bytes memory returnData) = target.call(data);
        // 'returnData' can be arbitrarily large — copying it costs gas
        require(success, string(returnData));  // returnData copy happens here
    }
}

An attacker's contract returns millions of bytes. Copying the return data into memory can cost more gas than the block limit.

Fix: limit or discard return data

// SAFE: use assembly to limit return data copy
function safeCall(address target, bytes calldata data) internal returns (bool success) {
    assembly {
        // Only copy first 32 bytes of return data
        success := call(gas(), target, 0, add(data, 0x20), mload(data), 0, 0)
    }
}

// Or: use a fixed-size returndata check
(bool ok, bytes memory ret) = target.call{gas: 50000}(data);
require(ret.length <= 64, "Excessive return data");

DoS Category 5: Block Stuffing

Attackers with enough capital can fill blocks with high-gas-price transactions to delay critical protocol operations (e.g., liquidations, auction bids, oracle updates).

// VULNERABLE: price-sensitive operation with no staleness protection
contract VulnerableAMM {
    uint256 public price;
    uint256 public lastUpdate;

    function updatePrice(uint256 newPrice) external onlyOracle {
        price = newPrice;
        lastUpdate = block.timestamp;
    }

    function swap(uint256 amountIn) external {
        // If block stuffing delayed oracle update, price is stale
        // Attacker profits from stale price while stuffing blocks
        uint256 amountOut = amountIn * price;
        // ...
    }
}

Fix: add staleness checks

function swap(uint256 amountIn) external {
    require(block.timestamp - lastUpdate <= MAX_PRICE_AGE, "Price too stale");
    // ...
}

For high-value auctions or liquidations, consider circuit breakers that pause operations when price data becomes stale.


What Scanners Detect

DoS Type Slither Mythril Semgrep AI
Unbounded loop over storage array ⚠️ ⚠️
transfer() in loop (revert griefing) ⚠️ ⚠️
Logic depending on address(this).balance ⚠️
Return data copy size attack
Block stuffing / staleness vulnerability

Static analysis handles the structural patterns (loops, transfer) well. But the contextual cases — recognizing that a contract uses balance-based logic that's selfdestruct-vulnerable, or that a call copies unbounded return data — require understanding contract intent, which is where AI-based analysis adds coverage.


Quick Checklist

Before deploying contracts that handle ETH or user funds:


Summary

DoS vulnerabilities are particularly dangerous because they often have no fix after deployment — a permanently unusable withdrawal function is a permanent loss, even if no tokens were stolen. The pull pattern eliminates most loop-based DoS attack surface by construction, and internal accounting eliminates the selfdestruct class entirely.

Scan your contracts for DoS vulnerabilities with ContractScan — the AI engine detects balance-dependency patterns and unbounded loop structures that static analysis tools flag inconsistently.


Related: Flash Loan Attack Deep Dive — single-transaction economic attacks that exploit similar protocol assumptions.

Related: Foundry Invariant Fuzzing — test DoS invariants with fuzz testing before deployment.

Scan your contract now
Slither + AI analysis — Unlimited quick scans. No signup required.
Try Free Scan →