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:
-
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. -
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:
- 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}(); } } - Bids through
Blocker. The auction now hashighestBidder == Blocker. - Any subsequent legitimate bid triggers
payable(Blocker).transfer(...)to refund the Blocker's bid — which reverts. The new bid also reverts. - 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 Pattern | What goes wrong | Defense |
|---|---|---|
| Unbounded loops over user-controlled sets | Iteration cost exceeds block gas limit as set grows | Pull-based pattern; batch processing with caller-controlled chunks |
| Push payment to malicious recipient | Single reverting recipient blocks all payments | Pull-over-push; or try-push-fallback-to-credit |
| External call in critical state transition | Failed callback reverts entire operation | Move state updates after external calls; or use try/catch |
| Unbounded storage growth | View/state-changing functions exceed gas as data grows | Pagination; aggregate roll-up pattern with event indexing |
| Forwarded subcall consumes all gas | Single griefing subcall drains the loop's gas budget | Cap 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