Denial of Service Attacks
A Denial of Service (DoS) attack in smart contracts is any technique that makes a function permanently or temporarily uncallable, even if the attacker gains no direct financial profit. In traditional web security, DoS is temporary — restart the server and you're fine. On the blockchain, a permanently bricked function is catastrophic: no upgrades, no recovery, no rollback. Understanding the five DoS patterns is essential for every auditor.
Unlike financial exploits, DoS vulnerabilities may not produce immediate obvious losses. They can instead prevent a protocol from ever functioning correctly again — locking funds indefinitely, breaking governance permanently, or preventing emergency withdrawals that could save user deposits.
Type 1: Unbounded Loop DoS
Any loop that iterates over a dynamically-sized collection is a potential DoS vector. If an attacker can add entries to the collection (by depositing, registering, bidding, etc.), they can grow it until the function exceeds the block gas limit and becomes permanently uncallable.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract UnboundedLoopVulnerable {
address[] public participants;
uint256 public totalPrize;
function enter() external payable {
require(msg.value >= 0.01 ether);
participants.push(msg.sender);
totalPrize += msg.value;
}
// ❌ Iterates ALL participants every call
// 10,000 participants × ~5000 gas each = 50M gas → exceeds block limit (~30M)
// Attacker registers thousands of addresses → distributePrize() is permanently bricked
function distributePrize() external {
uint256 share = totalPrize / participants.length;
for (uint256 i = 0; i < participants.length; i++) {
// Each iteration: SLOAD + external call + SSTORE
(bool ok,) = participants[i].call{value: share}("");
require(ok);
}
}
}
// ✅ FIXED: Pagination — process N participants per call
contract PaginatedDistribution {
address[] public participants;
uint256 public lastProcessedIndex;
uint256 public constant BATCH_SIZE = 100;
function distributeBatch() external {
uint256 share = address(this).balance / participants.length;
uint256 end = lastProcessedIndex + BATCH_SIZE;
if (end > participants.length) end = participants.length;
for (uint256 i = lastProcessedIndex; i < end; ) {
(bool ok,) = participants[i].call{value: share}("");
require(ok);
unchecked { ++i; }
}
lastProcessedIndex = end;
}
}Type 2: Push Payment DoS
When a contract pushes ETH to multiple recipients in sequence, a single recipient that always reverts can block the entire distribution. This is different from unbounded loop DoS — even one malicious participant can permanently break the function.
contract PushPaymentVulnerable {
address[] public winners;
// ❌ If ANY winner's receive() reverts, the whole function fails
// Attacker enters with a contract that always reverts on receive()
// No legitimate winner ever gets paid
function payWinners() external {
uint256 share = address(this).balance / winners.length;
for (uint256 i = 0; i < winners.length; i++) {
(bool ok,) = winners[i].call{value: share}("");
require(ok, "Payment failed"); // ❌ One failure blocks all
}
}
}
// The griefing contract — always reverts ETH reception
contract GrieferContract {
receive() external payable {
revert("I refuse ETH"); // Permanently blocks payWinners()
}
}
// ✅ FIXED: Pull payment pattern
contract PullPaymentFixed {
mapping(address => uint256) public pendingWithdrawals;
function allocateWinnings(address[] calldata winners) external {
uint256 share = address(this).balance / winners.length;
for (uint256 i = 0; i < winners.length; ) {
pendingWithdrawals[winners[i]] += share; // ✅ Record, don't push
unchecked { ++i; }
}
}
// ✅ Each winner pulls their own funds — one failure doesn't affect others
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingWithdrawals[msg.sender] = 0;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "Withdrawal failed");
}
}Type 3: Block Gas Limit DoS
The Ethereum block gas limit is approximately 30 million gas. Any single function call that approaches this limit is vulnerable. An attacker can craft inputs that make the function use exactly that much gas — enough to fail, not enough to succeed.
contract GasLimitVulnerable {
mapping(address => uint256[]) public userOrders;
// ❌ No limit on orders per user — attacker submits millions of orders
function submitOrder(uint256 orderId) external {
userOrders[msg.sender].push(orderId);
}
// ❌ Iterates ALL orders — attacker causes gas exhaustion
function cancelAllOrders() external {
uint256[] storage orders = userOrders[msg.sender];
for (uint256 i = 0; i < orders.length; i++) {
_cancelOrder(orders[i]); // Each cancel is ~50k gas
}
delete userOrders[msg.sender];
}
// ✅ FIX: Cap the number of orders per user
uint256 public constant MAX_ORDERS = 100;
function submitOrderSafe(uint256 orderId) external {
require(userOrders[msg.sender].length < MAX_ORDERS, "Max orders reached");
userOrders[msg.sender].push(orderId);
}
function _cancelOrder(uint256) internal { /* implementation */ }
}Type 4: External Call Revert DoS
When Contract A's core functionality requires calling Contract B, and Contract B can be set to always revert, Contract B holds Contract A hostage. This is especially dangerous in governance and auction patterns.
contract BadAuction {
address public currentLeader;
uint256 public currentBid;
// ❌ Must refund the old leader before accepting new bid
// Old leader can be a contract that reverts on receive()
// Nobody can outbid them — they win by default
function bid() external payable {
require(msg.value > currentBid, "Bid too low");
// ❌ Refund to old leader — can be blocked by a malicious contract
(bool ok,) = currentLeader.call{value: currentBid}("");
require(ok, "Refund failed"); // ← This is the DoS vector
currentLeader = msg.sender;
currentBid = msg.value;
}
}
// ✅ FIXED: Use pull pattern — refunds are stored, not sent immediately
contract SafeAuction {
address public currentLeader;
uint256 public currentBid;
mapping(address => uint256) public refunds;
function bid() external payable {
require(msg.value > currentBid, "Bid too low");
refunds[currentLeader] += currentBid; // ✅ Queue the refund
currentLeader = msg.sender;
currentBid = msg.value;
}
function claimRefund() external {
uint256 amount = refunds[msg.sender];
require(amount > 0, "No refund");
refunds[msg.sender] = 0;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok);
}
}Type 5: Griefing with ETH — Balance Invariant Attacks
Some contracts use address(this).balance in conditional logic or require statements. Since ETH can be force-sent via selfdestruct, an attacker can grief a contract by pushing it into a state where its own balance invariant is violated.
contract BalanceInvariantVulnerable {
uint256 public trackedBalance;
// ❌ Checks that actual balance == tracked balance
// Force-sending ETH breaks this forever
function deposit() external payable {
require(
address(this).balance == trackedBalance + msg.value,
"Balance mismatch"
);
trackedBalance += msg.value;
}
// After force-send: balance > trackedBalance → deposit() reverts forever
// ✅ FIX: Use >= instead of == for balance checks
function depositFixed() external payable {
trackedBalance += msg.value; // Track internally, don't check actual balance
}
}Circuit Breakers: Emergency Stop Pattern
contract CircuitBreaker {
bool public paused;
address public admin;
modifier whenNotPaused() {
require(!paused, "Contract is paused");
_;
}
// Admin can pause to halt an ongoing DoS attack
function pause() external {
require(msg.sender == admin, "Not admin");
paused = true;
}
function unpause() external {
require(msg.sender == admin, "Not admin");
paused = false;
}
function criticalFunction() external whenNotPaused {
// Protected by circuit breaker
}
}Foundry Test: Loop DoS and Pull Payment Fix
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
contract DoSTest is Test {
PushPaymentVulnerable public vuln;
PullPaymentFixed public fixed_contract;
function setUp() public {
vuln = new PushPaymentVulnerable();
fixed_contract = new PullPaymentFixed();
}
/// @notice Push payment is bricked by a single reverting recipient
function test_pushPaymentDoS() public {
address alice = makeAddr("alice");
GrieferContract griefer = new GrieferContract();
address[] memory winners = new address[](2);
winners[0] = alice;
winners[1] = address(griefer); // Griefer is in the winner list
vm.deal(address(vuln), 2 ether);
// payWinners() reverts because griefer refuses ETH
vm.expectRevert();
vuln.payWinners();
// Alice never gets paid — DoS is complete
assertEq(alice.balance, 0);
}
/// @notice Pull payment is unaffected by reverting recipients
function test_pullPayment_notAffectedByGriefer() public {
address alice = makeAddr("alice");
GrieferContract griefer = new GrieferContract();
address[] memory winners = new address[](2);
winners[0] = alice;
winners[1] = address(griefer);
vm.deal(address(fixed_contract), 2 ether);
fixed_contract.allocateWinnings(winners);
// Alice withdraws successfully even though griefer exists
vm.prank(alice);
fixed_contract.withdraw();
assertEq(alice.balance, 1 ether); // Alice gets her share
}
}Real-World Exploits and Examples
| Protocol | Year | DoS Type | Impact |
|---|---|---|---|
| King of Ether | 2016 | Push Payment DoS | New bids permanently blocked when king was a contract |
| GovernorBravo | 2021 | Quorum manipulation | Attacker could prevent proposals from passing quorum by borrowing/voting/returning tokens |
| Fomo3D | 2018 | Block stuffing | Attacker filled blocks with high-gas-price transactions to prevent others from calling end() |
Key Takeaways
- Never iterate over unbounded arrays in a single transaction. Any collection that users can add to is a DoS target. Always cap the size or use pagination with an index pointer.
- Prefer pull payment over push payment for ETH distributions. If you push ETH to users in a loop, one reverting recipient blocks everyone. Let users withdraw their own funds.
- Never rely on address(this).balance for critical logic. ETH can be force-sent via selfdestruct. Use explicit accounting variables instead.
- Account for gas costs when designing loops. Calculate the maximum possible iterations multiplied by the gas cost per iteration. If it can approach 30M, redesign the function.
- Add circuit breakers to high-value contracts. Even if you cannot prevent a DoS, being able to pause the contract gives you time to fix the underlying issue.
DoS Detection: Slither and Manual Patterns
// Slither detectors for DoS patterns:
slither . --detect calls-loop # External calls inside loops
slither . --detect costly-loop # Loops that could be gas-expensive
slither . --detect msg-value-loop # msg.value used in loops
// Manual patterns to look for in code review:
// 1. Any for/while loop over a dynamic array that users can append to
// 2. External calls inside loops without try/catch
// 3. require(address(this).balance == X) — force-sendable
// 4. Direct push payments in batch operations
// 5. Whitelisted addresses that must be iterable for payouts
// Gas calculation for loop safety:
// Max safe iterations = blockGasLimit / gasPerIteration
// blockGasLimit ≈ 30_000_000 gas
// Typical iteration (SLOAD + SSTORE + external call) ≈ 15,000 gas
// Max safe iterations ≈ 30_000_000 / 15_000 = 2,000 iterations
// If your loop can ever have more than ~1000 iterations, redesign itWhitelisting and Rate Limiting as DoS Defenses
contract WhitelistedDistribution {
mapping(address => bool) public whitelisted;
address[] public participants;
address public admin;
uint256 public constant MAX_PARTICIPANTS = 500;
// ✅ Only admin can add participants — prevents unbounded growth
function addParticipant(address participant) external {
require(msg.sender == admin, "Not admin");
require(!whitelisted[participant], "Already whitelisted");
require(participants.length < MAX_PARTICIPANTS, "Max participants reached");
whitelisted[participant] = true;
participants.push(participant);
}
// Safe to iterate: admin controls size, capped at MAX_PARTICIPANTS
// MAX_PARTICIPANTS (500) × ~5000 gas = 2.5M gas — well within block limit
function distribute() external {
uint256 share = address(this).balance / participants.length;
for (uint256 i = 0; i < participants.length; ) {
(bool ok,) = participants[i].call{value: share}("");
if (!ok) {
// Failed payments go to a rescue account — don't block others
pendingRescue[participants[i]] += share;
}
unchecked { ++i; }
}
}
mapping(address => uint256) public pendingRescue;
}
// Rate limiting: prevent DoS by limiting how often a function can be called
contract RateLimitedOperation {
mapping(address => uint256) public lastActionTime;
uint256 public constant COOLDOWN = 1 hours;
function expensiveOperation() external {
// ✅ Rate limit prevents a single address from spamming
require(
block.timestamp >= lastActionTime[msg.sender] + COOLDOWN,
"Rate limited — wait before next action"
);
lastActionTime[msg.sender] = block.timestamp;
// Expensive operation...
}
}DoS Risk Classification Table
| Pattern | DoS Type | Exploitability | Fix |
|---|---|---|---|
| for loop over users[] | Unbounded loop | Anyone who enters | Paginate or cap array size |
| push ETH to recipients[] | Push payment DoS | One reverting recipient | Pull payment pattern |
| require(address(this).balance == x) | Balance invariant griefing | Selfdestruct attack | Use tracked variable, not raw balance |
| External call that must succeed | External revert DoS | Controlled recipient | Try/catch or pull pattern |
| No function caps | Block stuffing | High gas price | Gas efficient design + circuit breaker |
Resource Exhaustion via Storage Writes
Writing to storage is one of the most gas-expensive operations in the EVM (20,000 gas for a cold write). An attacker who can trigger many storage writes at the protocol's expense can exhaust the block gas limit and cause DoS. This is different from the unbounded loop pattern — even a loop with one iteration can be expensive if each iteration writes many slots.
// ❌ VULNERABLE: Protocol writes to storage for every depositor interaction
// Attacker creates 1000 accounts, each calling register() once
contract VulnerableRegistry {
mapping(address => UserInfo) public users;
address[] public allUsers; // Protocol iterates this on every reward distribution
function register() external {
require(!users[msg.sender].registered, "Already registered");
users[msg.sender] = UserInfo({registered: true, balance: 0});
allUsers.push(msg.sender); // Grows array unboundedly
}
// Iterates ALL users — DoSed once allUsers is large enough
function distributeRewards(uint256 total) external {
for (uint256 i; i < allUsers.length; ++i) {
users[allUsers[i]].balance += total / allUsers.length;
}
}
}
// ✅ FIXED: Use claimable rewards pattern — distribute is off-chain/lazy
contract LazyRewards {
uint256 public rewardPerShare; // Cumulative, increases over time
mapping(address => uint256) public rewardDebt;
// Claim is O(1) — no loop, no per-user write during distribution
function claimReward() external {
uint256 pending = (userShares[msg.sender] * rewardPerShare) / 1e18
- rewardDebt[msg.sender];
rewardDebt[msg.sender] = (userShares[msg.sender] * rewardPerShare) / 1e18;
if (pending > 0) _transfer(msg.sender, pending);
}
}Griefing via Minimum Deposit/Dust Attacks
Some protocols implement per-position accounting that becomes expensive when there are many tiny positions. An attacker creates thousands of dust positions (below dust thresholds) to inflate protocol storage and increase the cost of normal operations.
contract DustResistantLending {
uint256 public constant MIN_POSITION_SIZE = 0.01 ether; // Minimum viable position
uint256 public constant MAX_POSITIONS_PER_BLOCK = 50;
mapping(uint256 => uint256) public positionsOpenedInBlock;
modifier antiDust(uint256 amount) {
require(amount >= MIN_POSITION_SIZE, "Position below minimum");
require(
positionsOpenedInBlock[block.number] < MAX_POSITIONS_PER_BLOCK,
"Too many positions this block"
);
positionsOpenedInBlock[block.number]++;
_;
}
function openPosition(uint256 amount) external antiDust(amount) {
// Open lending position
}
}Key Takeaways
- Unbounded loops are the most common DoS vector — any loop that iterates over a user-controlled or growing collection is a ticking time bomb.
- Replace push payment distributions with pull claim patterns — instead of iterating all recipients, let each user claim their own share on demand.
- External calls that can revert (including ETH transfers to contracts) should never gate critical protocol functions — use try/catch or track failures separately.
- Griefing via ETH force-send (selfdestruct) or dust position spam can break protocols that rely on exact balance accounting or per-position iteration.
- Rate limiting at the contract level (max operations per block, minimum sizes) is a valid defense against systematic griefing attacks.
- The MasterChef/SushiSwap reward-per-share pattern (O(1) claiming) is the standard replacement for O(n) reward distribution across DeFi.