IntermediateMedium Severity

Denial of Service (DoS) Attacks

How gas limits, unbounded loops, unexpected reverts, and external call failures can be weaponized to permanently halt your smart contract.

8 min readUpdated Jan 2025By Hexific Security Team
Permanent Lockout
30M
Block Gas Limit
#5
Common Vuln Type
100%
Preventable

What is a DoS Attack?

A Denial of Service (DoS) attack in smart contracts occurs when an attacker can make a contract function unusable or the entire contract inoperable. Unlike traditional web DoS attacks that flood servers with traffic, smart contract DoS attacks exploit logic flaws to permanently or temporarily block functionality.

Permanent vs Temporary DoS

The scariest DoS attacks are permanent — funds can be locked forever with no way to recover them. Even temporary DoS can cause significant damage during time-sensitive operations like auctions or liquidations.

🎯 What Makes Smart Contract DoS Unique?

⚠️ No Recovery Option

Unlike servers that can be restarted, immutable contracts cannot be patched. A DoS vulnerability may lock funds forever.

⚠️ Gas Constraints

Every transaction has a gas limit (~30M per block). Operations exceeding this limit become impossible to execute.

⚠️ External Dependencies

Contracts calling external contracts can be blocked if those calls fail or consume excessive gas.

⚠️ Economic Incentives

Attackers may profit from DoS by blocking competitors, manipulating auctions, or preventing liquidations.

DoS Attack Vectors

1. Gas Limit DoS

Unbounded loops or operations that grow with user input can exceed block gas limits, making functions uncallable.

Most CommonLoops

2. Unexpected Revert DoS

When a function depends on external calls that can be made to fail, attackers can force the entire transaction to revert.

External CallsPush Pattern
📦

3. Block Stuffing

Flooding the network with high-gas transactions to prevent specific time-sensitive operations from being included in blocks.

Network LevelExpensive
💀

4. Griefing Attacks

Attackers make operations more expensive or inconvenient for others, even if they don't directly profit.

No Direct ProfitMalicious Intent
🔒

5. Owner Action Required

When critical functions require owner action, a lost/compromised owner key can permanently DoS the contract.

CentralizationKey Management

Gas Limit DoS Deep Dive

The most common DoS vulnerability occurs when a function's gas consumption grows unboundedly with user-controlled data. Once gas exceeds the block limit, the function becomes impossible to execute.

⛽ How Gas Limit DoS Works

Day 1:Contract has 100 users, function costs 500K gas ✓
Day 30:Contract has 5,000 users, function costs 10M gas ✓
Day 90:Contract has 50,000 users, function costs 100M gas ⚠️
Result:Block limit is 30M gas — FUNCTION PERMANENTLY BROKEN 💥

Common Patterns That Cause Gas Limit DoS

🔄 Iterating Over Arrays

Looping through all users, tokens, or stakes to calculate totals or distribute rewards.

for(uint i = 0; i < users.length; i++)

💰 Batch Transfers

Sending payments to all participants in a single transaction (airdrop, dividend distribution).

payable(user).transfer(amount)

🗑️ Array Cleanup

Deleting or resetting large arrays/mappings element by element.

delete largeArray;

📊 State Aggregation

Computing sums, averages, or rankings across all entries on-chain.

totalVotes += votes[i]

Unexpected Revert DoS

When your contract makes external calls and those calls can fail or be made to fail, attackers can use this to block your contract's functionality.

1

Contract Has Push Payment Pattern

Auction sends refunds to outbid participants automatically

2

Attacker Deploys Malicious Contract 🎭

Contract with no receive() function or one that always reverts

3

Attacker Places Bid 💥

Uses malicious contract as the bidder address

4

No One Can Outbid

Any new bid tries to refund attacker → refund fails → entire tx reverts

5

Auction Permanently Broken 🔒

Attacker wins with minimal bid, or auction is stuck forever

Not Just ETH Transfers

This applies to any external call: ERC20 transfers (some tokens revert on failure), callback functions, oracle calls, or any interaction with external contracts.

Block Stuffing Attacks

Block stuffing is a network-level DoS where an attacker fills blocks with their own transactions to prevent target transactions from being included.

📦 Block Stuffing Mechanics

Target: Time-sensitive operations like:
  • • Auction endings
  • • Liquidation deadlines
  • • Option expiries
  • • Governance vote deadlines
Method: Submit many high-gas-price transactions that consume the entire block space, pushing out victim transactions.
Cost: Very expensive on Ethereum mainnet (~$50K+ per block), but cheaper on L2s and alt-L1s.

Famous Example: Fomo3D (2018)

The winner of Fomo3D's jackpot used block stuffing to prevent anyone else from buying keys in the final moments. By filling blocks with high-gas transactions, they ensured no one could extend the timer, winning 10,469 ETH (~$3M at the time).

Gaming$3M Profit

Griefing Attacks

Griefing attacks don't aim to profit — they aim to cause harm or inconvenience to other users, often at a cost to the attacker.

🎯 Storage Bombing

Attacker fills storage slots with data, making future operations more expensive or hitting storage limits.

Example: Creating thousands of tiny positions to make iteration expensive.

🗳️ Governance Blocking

Using enough tokens to block quorum or veto proposals without any intention to participate constructively.

Example: Voting against every proposal to stall protocol development.

💸 Dust Attacks

Sending tiny amounts to many addresses to bloat state or make accounting more complex.

Example: Sending 1 wei to every address that interacted with a protocol.

⚔️ Competitive Blocking

Blocking competitor transactions or making them fail to gain market advantage.

Example: Front-running liquidation bots to make their transactions fail.

Real-World Examples

GovernMental Ponzi - ETH Locked Forever

2016

The payout function iterated over all investors to reset their balances. When the investor count grew too large, the function exceeded the block gas limit. 1,100 ETH remained permanently locked in the contract.

Gas Limit DoSPermanent Lock

King of the Ether - Auction Stuck

2016

The game used push payments to refund the previous "king" when dethroned. When the refund failed (malicious contract), the entire game broke as no new king could be crowned.

Unexpected RevertPush Pattern

Fomo3D - Block Stuffing Winner

2018

Winner used block stuffing to prevent other players from extending the timer, ensuring they won the jackpot of 10,469 ETH.

Block Stuffing$3M Profit

Akutars NFT - 34M USD Locked

2022

A DoS bug combined with a logical error permanently locked $34M worth of ETH in the Akutars NFT contract. The contract required processing all refunds before withdrawal, but the refund logic was flawed.

Logic DoS$34M Locked

Vulnerable Code Patterns

❌ Pattern 1: Unbounded Loop

VulnerableRewards.sol
1// 🚨 VULNERABLE: Gas grows with user count
2contract VulnerableRewards {
3    address[] public stakers;
4    mapping(address => uint256) public stakes;
5    
6    function distributeRewards() external {
7        uint256 totalStake = 0;
8        
9        // 💥 First loop: calculate total (gas grows O(n))
10        for (uint i = 0; i < stakers.length; i++) {
11            totalStake += stakes[stakers[i]];
12        }
13        
14        // 💥 Second loop: distribute (gas grows O(n))
15        for (uint i = 0; i < stakers.length; i++) {
16            uint256 share = rewards * stakes[stakers[i]] / totalStake;
17            payable(stakers[i]).transfer(share);  // Also vulnerable!
18        }
19        
20        // With 10,000 stakers: ~50M gas (exceeds block limit)
21    }
22}

❌ Pattern 2: External Call in Loop

VulnerableAuction.sol
1// 🚨 VULNERABLE: One failed transfer blocks everyone
2contract VulnerableAuction {
3    address public highestBidder;
4    uint256 public highestBid;
5    
6    function bid() external payable {
7        require(msg.value > highestBid, "Bid too low");
8        
9        // 💥 If previous bidder is a contract that reverts...
10        // This entire function becomes uncallable!
11        payable(highestBidder).transfer(highestBid);  
12        
13        highestBidder = msg.sender;
14        highestBid = msg.value;
15    }
16}

❌ Pattern 3: Array Deletion DoS

VulnerableReset.sol
1// 🚨 VULNERABLE: Deleting large arrays costs unlimited gas
2contract VulnerableReset {
3    uint256[] public data;
4    
5    function addData(uint256 value) external {
6        data.push(value);  // Array grows over time
7    }
8    
9    function resetAllData() external {
10        // 💥 If data has 100,000 elements:
11        // delete costs ~500M gas (impossible to execute)
12        delete data;
13    }
14    
15    function clearOneByOne() external {
16        // 💥 Same problem, different syntax
17        while (data.length > 0) {
18            data.pop();  // Each pop costs ~5000 gas
19        }
20    }
21}

❌ Pattern 4: Required Owner Action

VulnerableVault.sol
1// 🚨 VULNERABLE: Lost owner key = permanent DoS
2contract VulnerableVault {
3    address public owner;
4    bool public paused = true;
5    
6    modifier onlyOwner() {
7        require(msg.sender == owner, "Not owner");
8        _;
9    }
10    
11    // Users deposit funds...
12    function deposit() external payable {
13        // deposits work fine
14    }
15    
16    // 💥 But withdrawals require owner to unpause first!
17    function withdraw() external {
18        require(!paused, "Contract paused");  
19        // If owner loses keys, funds are locked forever
20        payable(msg.sender).transfer(balances[msg.sender]);
21    }
22    
23    function unpause() external onlyOwner {
24        paused = false;
25    }
26}

Prevention Strategies

📤

Pull Over Push

Instead of sending funds to users, let users withdraw their funds. One user's failed withdrawal doesn't affect others.

📊

Pagination / Batching

Process large operations in fixed-size batches across multiple transactions instead of all at once.

Gas Limits on Calls

Limit gas forwarded to external calls so malicious contracts can't consume all available gas.

🔀

Isolate External Calls

Don't let one failed external call revert the entire transaction. Use try/catch or low-level calls with error handling.

📈

Incremental State Updates

Track running totals instead of recalculating from scratch. Update on each deposit/withdrawal rather than looping.

🔐

Escape Hatches

Provide emergency withdrawal mechanisms that don't depend on complex state or external calls.

✅ Secure Pattern: Pull Payments

SecureAuction.sol
1// ✅ SECURE: Pull pattern - users withdraw their own funds
2contract SecureAuction {
3    address public highestBidder;
4    uint256 public highestBid;
5    mapping(address => uint256) public pendingReturns;
6    
7    function bid() external payable {
8        require(msg.value > highestBid, "Bid too low");
9        
10        // ✅ Don't transfer immediately - record for later withdrawal
11        if (highestBidder != address(0)) {
12            pendingReturns[highestBidder] += highestBid;
13        }
14        
15        highestBidder = msg.sender;
16        highestBid = msg.value;
17    }
18    
19    // ✅ Users pull their own funds - one failure doesn't affect others
20    function withdraw() external {
21        uint256 amount = pendingReturns[msg.sender];
22        require(amount > 0, "Nothing to withdraw");
23        
24        // CEI pattern
25        pendingReturns[msg.sender] = 0;
26        
27        (bool success, ) = payable(msg.sender).call{value: amount}("");
28        require(success, "Transfer failed");
29    }
30}

✅ Secure Pattern: Pagination

SecureRewards.sol
1// ✅ SECURE: Process in batches with pagination
2contract SecureRewards {
3    address[] public stakers;
4    uint256 public lastProcessedIndex;
5    uint256 public constant BATCH_SIZE = 100;
6    
7    // Store running total (don't recalculate)
8    uint256 public totalStaked;
9    mapping(address => uint256) public stakes;
10    mapping(address => uint256) public pendingRewards;
11    
12    // ✅ Track totals incrementally
13    function stake(uint256 amount) external {
14        stakes[msg.sender] += amount;
15        totalStaked += amount;  // O(1) update
16        stakers.push(msg.sender);
17    }
18    
19    // ✅ Process in fixed-size batches
20    function distributeRewardsBatch(uint256 rewardAmount) external {
21        uint256 endIndex = lastProcessedIndex + BATCH_SIZE;
22        if (endIndex > stakers.length) {
23            endIndex = stakers.length;
24        }
25        
26        for (uint i = lastProcessedIndex; i < endIndex; i++) {
27            address staker = stakers[i];
28            uint256 share = rewardAmount * stakes[staker] / totalStaked;
29            pendingRewards[staker] += share;
30        }
31        
32        lastProcessedIndex = endIndex;
33        
34        // Reset for next distribution cycle
35        if (lastProcessedIndex >= stakers.length) {
36            lastProcessedIndex = 0;
37        }
38    }
39    
40    // ✅ Users claim their own rewards
41    function claimRewards() external {
42        uint256 amount = pendingRewards[msg.sender];
43        require(amount > 0, "No rewards");
44        pendingRewards[msg.sender] = 0;
45        payable(msg.sender).transfer(amount);
46    }
47}

✅ Secure Pattern: Isolated External Calls

SecureDistributor.sol
1// ✅ SECURE: Isolate external calls with try/catch
2contract SecureDistributor {
3    mapping(address => uint256) public failedTransfers;
4    
5    function distribute(address[] calldata recipients, uint256[] calldata amounts) external {
6        for (uint i = 0; i < recipients.length; i++) {
7            // ✅ Try the transfer, but don't revert if it fails
8            (bool success, ) = payable(recipients[i]).call{
9                value: amounts[i],
10                gas: 50000  // Limit gas to prevent griefing
11            }("");
12            
13            if (!success) {
14                // ✅ Record failed transfer for manual claim
15                failedTransfers[recipients[i]] += amounts[i];
16                emit TransferFailed(recipients[i], amounts[i]);
17            }
18        }
19    }
20    
21    // ✅ Fallback for failed transfers
22    function claimFailedTransfer() external {
23        uint256 amount = failedTransfers[msg.sender];
24        require(amount > 0, "No failed transfers");
25        failedTransfers[msg.sender] = 0;
26        payable(msg.sender).transfer(amount);
27    }
28}

Testing for DoS Vulnerabilities

DoSTest.t.sol
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.19;
3
4import "forge-std/Test.sol";
5import "../src/SecureAuction.sol";
6
7contract DoSTest is Test {
8    SecureAuction auction;
9    MaliciousContract attacker;
10    
11    function setUp() public {
12        auction = new SecureAuction();
13        attacker = new MaliciousContract();
14    }
15    
16    // ✅ Test: Malicious contract can't block auction
17    function test_MaliciousContractCannotBlockBids() public {
18        // Attacker places initial bid from malicious contract
19        vm.deal(address(attacker), 1 ether);
20        vm.prank(address(attacker));
21        auction.bid{value: 1 ether}();
22        
23        // Legitimate user should be able to outbid
24        address user = makeAddr("user");
25        vm.deal(user, 2 ether);
26        vm.prank(user);
27        auction.bid{value: 2 ether}();  // Should NOT revert
28        
29        assertEq(auction.highestBidder(), user);
30        assertEq(auction.pendingReturns(address(attacker)), 1 ether);
31    }
32    
33    // ✅ Test: Gas doesn't grow unboundedly
34    function test_GasUsageIsBounded() public {
35        // Add many users
36        for (uint i = 0; i < 1000; i++) {
37            address user = address(uint160(i + 1));
38            vm.deal(user, 1 ether);
39            vm.prank(user);
40            auction.bid{value: 1 wei * (i + 1)}();
41        }
42        
43        // New bid should still work in bounded gas
44        address newBidder = makeAddr("new");
45        vm.deal(newBidder, 10 ether);
46        
47        uint256 gasBefore = gasleft();
48        vm.prank(newBidder);
49        auction.bid{value: 10 ether}();
50        uint256 gasUsed = gasBefore - gasleft();
51        
52        // Should use reasonable gas (not grow with user count)
53        assertLt(gasUsed, 100000, "Gas should be bounded");
54    }
55    
56    // ✅ Fuzz: Pagination handles any batch size
57    function testFuzz_PaginationWorks(uint8 numUsers) public {
58        vm.assume(numUsers > 0);
59        
60        for (uint i = 0; i < numUsers; i++) {
61            address user = address(uint160(i + 1));
62            vm.deal(user, 1 ether);
63            vm.prank(user);
64            rewards.stake{value: 0.1 ether}();
65        }
66        
67        // Should be able to process all in batches
68        uint256 batches = (numUsers + BATCH_SIZE - 1) / BATCH_SIZE;
69        for (uint i = 0; i < batches; i++) {
70            rewards.distributeRewardsBatch(1 ether);
71        }
72        
73        // All users should have pending rewards
74        for (uint i = 0; i < numUsers; i++) {
75            address user = address(uint160(i + 1));
76            assertGt(rewards.pendingRewards(user), 0);
77        }
78    }
79}
80
81// Malicious contract that always reverts on ETH receive
82contract MaliciousContract {
83    receive() external payable {
84        revert("No ETH accepted");  // Always reverts
85    }
86    
87    function bid(address auction) external payable {
88        SecureAuction(auction).bid{value: msg.value}();
89    }
90}

🛠️ DoS Detection Tools

Slither
slither . --detect locked-ether,costly-loop
Foundry Gas Report
forge test --gas-report
Echidna

Fuzz testing for gas consumption bounds

Tenderly

Simulate transactions with varying gas limits

✅ DoS Prevention Checklist

No unbounded loops over user-controlled data
Pull pattern used instead of push payments
External calls isolated with try/catch
Gas limits set on external calls
Pagination for batch operations
Running totals instead of on-demand aggregation
Emergency withdrawal mechanisms exist
No critical functions requiring owner action
Tested with malicious/reverting contracts
Gas usage profiled at scale

📚 Quick Reference

🚫 Never Do

  • • Loop over all users
  • • Push payments to unknown addresses
  • • Delete large arrays at once
  • • Depend on external calls succeeding

✅ Always Do

  • • Use pull pattern for payments
  • • Paginate batch operations
  • • Track running totals
  • • Handle external call failures

⚠️ Gas Limits

  • • Block limit: ~30M gas
  • • Safe tx limit: ~10M gas
  • • SSTORE: ~20K gas
  • • Transfer: ~21K gas