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
🎯 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.
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.
3. Block Stuffing
Flooding the network with high-gas transactions to prevent specific time-sensitive operations from being included in blocks.
4. Griefing Attacks
Attackers make operations more expensive or inconvenient for others, even if they don't directly profit.
5. Owner Action Required
When critical functions require owner action, a lost/compromised owner key can permanently DoS the contract.
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
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.
Contract Has Push Payment Pattern
Auction sends refunds to outbid participants automatically
Attacker Deploys Malicious Contract 🎭
Contract with no receive() function or one that always reverts
Attacker Places Bid 💥
Uses malicious contract as the bidder address
No One Can Outbid
Any new bid tries to refund attacker → refund fails → entire tx reverts
Auction Permanently Broken 🔒
Attacker wins with minimal bid, or auction is stuck forever
Not Just ETH Transfers
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
- • Auction endings
- • Liquidation deadlines
- • Option expiries
- • Governance vote deadlines
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).
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.
🗳️ Governance Blocking
Using enough tokens to block quorum or veto proposals without any intention to participate constructively.
💸 Dust Attacks
Sending tiny amounts to many addresses to bloat state or make accounting more complex.
⚔️ Competitive Blocking
Blocking competitor transactions or making them fail to gain market advantage.
Real-World Examples
GovernMental Ponzi - ETH Locked Forever
2016The 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.
King of the Ether - Auction Stuck
2016The 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.
Fomo3D - Block Stuffing Winner
2018Winner used block stuffing to prevent other players from extending the timer, ensuring they won the jackpot of 10,469 ETH.
Akutars NFT - 34M USD Locked
2022A 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.
Vulnerable Code Patterns
❌ Pattern 1: Unbounded Loop
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
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
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
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
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
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
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
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-loopFoundry Gas Report
forge test --gas-reportEchidna
Fuzz testing for gas consumption bounds
Tenderly
Simulate transactions with varying gas limits
✅ DoS Prevention Checklist
📚 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