Denial of Service (DoS) Vulnerabilities in Smart Contracts
A denial of service (DoS) vulnerability in a smart contract is any condition that allows an attacker—or even an unintentional edge case—to permanently or temporarily block legitimate users from executing a function. Unlike web-server DoS attacks, Ethereum's execution model makes certain classes of DoS trivially cheap to trigger and, in many cases, irreversible. Once a contract is stuck, there is no patch deployment and no system restart: the funds or state are frozen for good.
This guide covers the most common denial of service smart contract patterns, shows concrete vulnerable and fixed code, and explains how automated tooling detects them.
Why Smart Contracts Are Uniquely Vulnerable to DoS
Every Ethereum transaction runs inside a block that has a hard gas limit. Functions that consume gas proportional to unbounded state can silently become un-callable as the contract accumulates data. Additionally, Solidity's external call semantics mean that a single reverted sub-call can halt an entire batch operation, giving a single malicious or broken address the power to freeze a contract that processes many addresses at once.
These issues cluster into four practical attack families:
- Unbounded loops over dynamic arrays or mappings
- Gas-limit griefing via fallback or receive functions
- Blocking state transitions through failed external calls
- Griefing via deliberate reverts in callbacks
Unbounded Loops
The most straightforward DoS vector is a loop whose iteration count grows with contract usage. Consider a dividend-distribution contract that pushes payments to every registered investor:
// VULNERABLE: O(n) loop over a user-controlled array
address[] public investors;
function distributeRewards() external {
uint256 share = address(this).balance / investors.length;
for (uint256 i = 0; i < investors.length; i++) {
payable(investors[i]).transfer(share); // may revert; grows unboundedly
}
}
Two problems compound here. First, as investors grows toward thousands of addresses, the gas cost of the loop exceeds the block gas limit and the function becomes permanently unexecutable. Second, each transfer call can revert if the recipient's fallback reverts, halting the entire distribution.
The correct approach disaggregates distribution from withdrawal using the pull-over-push pattern:
// FIXED: pull-over-push
mapping(address => uint256) public pendingRewards;
function allocateRewards() external {
uint256 share = totalRewards / investorCount;
for (uint256 i = 0; i < investors.length; i++) {
pendingRewards[investors[i]] += share; // no external call; just state update
}
}
function withdraw() external {
uint256 amount = pendingRewards[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingRewards[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}
Allocation still loops, but the loop body is now a cheap storage write with no external calls. The actual ETH transfer is isolated to a single-user withdrawal, so one misbehaving address cannot affect others. If the allocation loop is still too long, split it into paginated batches or use a Merkle-proof claim pattern.
Gas-Limit DoS via Fallback Functions
When a contract unconditionally forwards ETH to an address it does not control, that address can deploy a contract whose receive or fallback function consumes all available gas or reverts, causing the outer transaction to fail:
// ATTACKER CONTRACT
receive() external payable {
revert(); // or: while(true) {} to consume gas
}
Any contract that calls transfer or send to an arbitrary recipient—or uses call{value: ...}("") without checking the return value—is potentially exposed. The Unchecked External Calls and the Risks of call, send and transfer guide covers the return-value checking problem in depth; the key DoS implication is that a single poisoned address in a push-payment loop freezes the entire function.
Blocking State Transitions Through Failed External Calls
A subtler variant occurs in contracts with a finite state machine where advancing state requires a successful external call:
// VULNERABLE: state is stuck if escrowReturn reverts
function finalizeAuction() external {
require(state == State.Ended, "Not ended");
// refund the previous highest bidder before updating state
(bool ok, ) = previousBidder.call{value: previousBid}("");
require(ok, "Refund failed"); // <-- attack surface
highestBidder = msg.sender;
state = State.Finalized;
}
If previousBidder is a contract that always reverts on receipt, finalizeAuction can never succeed. The fix is to decouple state advancement from the refund. Update state first, then let the old bidder withdraw independently:
// FIXED: state advances unconditionally; refund is pulled separately
function finalizeAuction() external {
require(state == State.Ended, "Not ended");
state = State.Finalized;
highestBidder = msg.sender;
pendingRefunds[previousBidder] += previousBid; // no external call here
}
function claimRefund() external {
uint256 amount = pendingRefunds[msg.sender];
require(amount > 0, "No refund");
pendingRefunds[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}
This is structurally identical to the pull-over-push pattern and also eliminates the reentrancy risk discussed in the Reentrancy Attacks in Solidity guide, because state is updated before any ETH moves.
Griefing via Deliberate Reverts in Callbacks
ERC-721 and ERC-1155 standards include safety callbacks (onERC721Received, onERC1155Received) that the recipient contract must implement. If a marketplace or game contract iterates over token recipients and calls safeTransferFrom inside a loop, a malicious recipient can deliberately revert its callback to block every transfer in the batch. The mitigation is the same: never mix iteration with external calls. Batch operations should record intended transfers in state and let each recipient pull their token.
Owner-Controlled DoS
A less-discussed variant is a privileged function that can be called by the owner to freeze all user operations without any on-chain time-lock or governance. While not an external attack, it is a centralization risk that auditors flag as a DoS vector because users cannot withdraw funds if the owner calls a pause function maliciously. Mitigation requires time-locks, multi-sig controls, or decentralized governance before any pause mechanism is deployed in production.
How Automated Tools Detect DoS Vulnerabilities
Static and dynamic analysis tools catch different aspects of the DoS surface:
- Slither has detectors for unbounded loops (
costly-loop) and calls inside loops (calls-loop), both of which are strong DoS signals. - Aderyn flags push-payment patterns and unchecked call return values that can cause state-blocking failures.
- Mythril uses symbolic execution to find execution paths that always revert due to gas exhaustion or forced revert conditions.
- Echidna can be given invariants such as "the withdraw function must always be callable" and will fuzz for inputs that violate them.
- Semgrep custom rules match structural patterns like loops containing
.transfer(or.call{value:calls.
VRF7 runs all of these tools in parallel on your uploaded contract and surfaces each finding with the originating tool clearly labeled, so you know whether a loop warning comes from Slither's static analysis or from Echidna discovering a concrete failing input. You can run an automated scan to get a consolidated report across all tools without setting up any local toolchain. Automated scanning identifies the most common patterns reliably; complex economic griefing scenarios and novel state-machine bugs still benefit from manual review by an experienced auditor.
For more on the static analysis tools specifically, see the Static Analysis for Solidity with Aderyn and Semgrep guide.
Summary of Mitigations
- Pull over push: Never send ETH or tokens inside a loop. Record balances in a mapping and let users withdraw individually.
- Bound or paginate loops: If iteration over a dynamic array is unavoidable, cap iteration count per transaction and track progress with a cursor.
- Separate state advancement from external calls: Update all internal state before making any external call, and never require an external call to succeed in order to advance a state machine.
- Check call return values: A failed low-level
callthat is ignored silently can leave funds undelivered or state inconsistent. - Use time-locks on privileged pause functions: Any owner-controlled mechanism that can halt withdrawals needs on-chain delay and multi-sig controls.
Scan your contract before you ship
Run an automated, transparent security scan — seven industry tools in parallel, every finding labeled with its source tool. It is not a substitute for a full manual audit, but it is a fast first line of defense.
Scan a contractFrequently asked questions
What is a denial of service vulnerability in a smart contract?
A denial of service (DoS) vulnerability is any condition that lets an attacker or a contract's own logic permanently or temporarily prevent legitimate users from calling a function. In smart contracts this commonly manifests as unbounded loops that exceed the block gas limit, external calls inside loops that can be forced to revert, or state machines that cannot advance because a required external call always fails.
What is the pull-over-push pattern and why does it prevent DoS?
The pull-over-push pattern separates the recording of a payment obligation from the actual transfer of funds. Instead of looping over recipients and pushing ETH or tokens to each one, the contract stores each recipient's owed balance in a mapping. Each recipient then calls a withdraw function to pull their own funds. This means a single recipient reverting or consuming excess gas cannot affect any other user's ability to withdraw.
Can a malicious contract cause a DoS by reverting its fallback function?
Yes. If your contract pushes ETH to an address you do not control using transfer, send, or a low-level call, and that address is a contract whose receive or fallback function reverts, your entire transaction will fail. If this push happens inside a loop, the whole batch is blocked. The fix is to adopt the pull-over-push pattern so the malicious address only affects its own withdrawal, not everyone else's.
How do static analysis tools detect unbounded loop DoS issues?
Tools like Slither include dedicated detectors such as costly-loop and calls-loop that flag loops whose bounds depend on dynamic storage arrays and loops that contain external calls respectively. Symbolic execution tools like Mythril can find execution paths that always revert due to gas exhaustion, and fuzzers like Echidna can test invariants like 'this function must always be callable' against a range of contract states.
Is an automated scan sufficient to find all DoS vulnerabilities?
Automated scanning reliably catches the most common structural patterns—unbounded loops, calls inside loops, unchecked return values, and push-payment anti-patterns. However, complex economic griefing scenarios, novel state-machine exploits, and logic-level issues that require understanding of the protocol's business rules still require manual review by a security auditor. Automated tools are best used as a first pass to eliminate known vulnerability classes before a manual audit.