Back

SC10: Denial of Service (DoS) Attacks - Overview

What is a Smart Contract Denial of Service?

Denial of Service (DoS) in a smart contract means making the contract—or a critical function within it—permanently or temporarily unusable, or locking funds so no one can withdraw them. Unlike a web DoS, which floods a server and ends when the traffic stops, an on-chain DoS is often permanent: once a contract is wedged into a broken state, immutable code has no "restart" button. The funds and the logic can be frozen forever.

What makes this category distinct from a simple bug is that an attacker can deliberately engineer the broken state. By becoming a "poison" participant—a recipient that always reverts, or a user who inflates an array past the gas limit—a single actor can freeze a contract for everyone else. The attacker often loses nothing; they simply make the shared mechanism stop working.

Core Concept

Healthy contract:
  distribute()   -> loops recipients, each payment succeeds, everyone paid
  bid()          -> refunds previous bidder, records new highest bid
  withdraw()     -> each user pulls their own balance independently

Denial of Service:
  distribute()   -> ONE recipient reverts on receive -> whole loop reverts
                    -> no one is ever paid again
  bid()          -> previous "bidder" is a contract that rejects ETH
                    -> refund fails -> no new bid can ever succeed
  withdraw()     -> array grew unbounded -> loop exceeds block gas limit
                    -> the function can never complete

Why It's Critical for Smart Contracts

Several properties of the blockchain execution model make DoS uniquely damaging on-chain:

Why Does This Matter?

Business Impact

Technical Impact

Technical Context

Main DoS Patterns in Smart Contracts

1. DoS With Unexpected Revert (Push Payments)

// A function PUSHES payments in a loop.
for (uint i = 0; i < recipients.length; i++) {
    // If ONE recipient is a contract that revert()s on receive,
    // or a contract with no payable fallback, this call fails...
    recipients[i].transfer(amounts[i]);   // ...and reverts the ENTIRE loop.
}

The trap: One recipient can be a "poison" contract that always rejects ETH. Because the whole transaction is atomic, that single failure rolls back every other payment. No one is ever paid again. This is the classic auction-refund / "everyone gets pushed" DoS.

2. DoS With Block Gas Limit (Unbounded Loops)

// The array grows every time a user joins; nothing bounds it.
address[] public participants;

function payAll() external {
    for (uint i = 0; i < participants.length; i++) {   // O(n) over user input
        participants[i].transfer(share);
    }
}

The trap: An attacker (or ordinary growth) inflates participants until the loop costs more gas than fits in a block. From that point on, payAll() can never complete—it reverts with out-of-gas every time, for everyone.

3. Locking / Griefing via a Broken Dependency

// A required external call. If `oracle` is self-destructed or paused,
// every function that touches it reverts forever.
uint price = IPriceFeed(oracle).latestPrice();

// Or: an owner-only escape hatch whose owner key is lost.
function emergencyWithdraw() external onlyOwner { ... }  // uncallable if owner is gone

The trap: A hard dependency on code that can break—or a single point of privileged control that can be lost—turns a temporary problem into a permanent freeze.

4. Manipulating a Shared State Variable

// Others depend on this value to make progress.
// If an attacker can force it into a state that blocks the
// happy path (e.g. a "current leader" that can never be displaced),
// the mechanism stalls for everyone.
address public currentLeader;   // updated only if the refund to the old leader succeeds

The trap: When progress for all users depends on a mutable variable that one participant can pin in a hostile state, that participant can stall the whole system.

5. Forcing State via selfdestruct

// Logic that assumes the balance only changes through deposit()/withdraw():
require(address(this).balance == expected, "unexpected balance");

// An attacker can `selfdestruct(payable(target))` to FORCE ETH in,
// skewing address(this).balance and breaking the invariant.

The trap: ETH can be forced into any contract via selfdestruct (and a couple of other edge cases), so address(this).balance is not a value the contract fully controls. Critical logic that trusts it can be wedged.

Where DoS Hides

PatternRoot CauseConsequence
Push-payment loopContract sends funds to many recipients in one txOne reverting recipient blocks all payouts
Unbounded loopIteration count grows with user-controlled dataOperation exceeds block gas limit, never completes
Refund-on-outbidNew action must first refund the previous actorA poison previous actor blocks all future actions
Hard external dependencyRequired call to a contract that can break/pauseFunction frozen if the dependency is gone
Single-owner escape hatchRecovery gated on one key/addressFunds locked if the owner is lost
Balance-based invariantLogic trusts address(this).balanceForced ETH skews state, wedges the contract

Real-World Impact

The incidents below are described as classes of vulnerability that have recurred across many contracts, not as claims about any single named project's specifics.

Case Class 1: Push-Payment Refund DoS (Auctions and "King" Games)

Vulnerability:

Impact:

Root Cause: Pushing payments to untrusted recipients inside a function whose success depends on that payment succeeding. The fix is to let recipients pull their own refunds.

Case Class 2: Unbounded-Loop Gas DoS (Growing Arrays)

Vulnerability:

Impact:

Root Cause: Designing a critical operation whose cost scales with user-controlled input. The fix is pagination, pull payments, and per-user accounting instead of one giant loop.

Case Class 3: Locked-Funds Griefing (Broken Dependency / Lost Owner)

Vulnerability:

Impact:

Root Cause: A single hard dependency or single point of privileged control with no fallback. The fix is robust ownership (multisig, two-step transfer), avoiding hard dependencies, and building recovery/upgrade paths.

Prevalence and Statistics

Denial of Service is included in the OWASP Smart Contract Top 10 (2025) as SC10 because the pattern recurs across auctions, crowdsales, staking pools, payment splitters, and games—anywhere a contract loops over participants or pushes value to untrusted addresses.

Rather than cite precise counts (which vary by source and year), the defensible picture is:

Note: exact figures differ between reports. Treat any single number as illustrative; the durable takeaway is that DoS is common, cheap to trigger, and frequently irreversible on immutable contracts.

Common Misunderstandings

Myth 1: "A revert just fails safely—no harm done"

Reality: A revert is safe for that transaction, but if the reverting step is on the only path that lets the contract make progress, the revert is permanent for everyone. Safe-per-call is not safe-per-system.

Myth 2: "My loop is fine; there are only a few recipients today"

Reality: If the number of recipients can grow with user input, "a few today" becomes "too many to fit in a block tomorrow." Unbounded is unbounded regardless of the current count.

Myth 3: "I'll just send funds to users so they don't have to do anything"

Reality: Pushing funds hands control to untrusted recipient code and couples everyone's outcome to the worst recipient. Pull payments isolate each user's failure to that user.

Myth 4: "Only my functions can change my contract's balance"

Reality: ETH can be forced in via selfdestruct (and as a coinbase/pre-funded address), so address(this).balance can move without any of your functions running. Never base critical logic on it.

Myth 5: "An external call to a trusted contract is safe forever"

Reality: "Trusted" contracts can be paused, upgraded to something incompatible, or self-destructed. A hard dependency with no fallback is a latent freeze waiting to happen.

Myth 6: "One owner key is simpler and therefore fine"

Reality: A single owner is a single point of failure. Lose the key (or let it fall into a broken contract) and every owner-gated recovery becomes uncallable—locking whatever those functions were meant to protect.

How DoS Differs from Related Issues

AspectDenial of Service (SC10)Reentrancy (SC05)Unchecked External Calls (SC06)
Attacker goalFreeze the contract / lock fundsDrain funds via re-entryExploit a silently ignored failure
Core mechanismForce a revert or exceed gas limitsCall back in before state updatesIgnore a call's boolean return
Typical fixPull payments, bounded loops, robust ownershipChecks-Effects-Interactions, guardsCheck return values / use call safely
OutcomeUnusable contract, trapped valueStolen valueInconsistent state, lost value

Key Takeaways

  1. On-chain DoS is often permanent—immutable code has no restart, so a wedged contract can trap funds forever.
  2. Atomicity turns one failure into everyone's failure—a single reverting recipient in a loop rolls back the whole batch.
  3. Unbounded loops are a time bomb—anything that scales with user input can eventually exceed the block gas limit.
  4. Prefer pull over push—let each user withdraw their own funds so one bad actor cannot block the rest.
  5. Design for recovery—avoid hard external dependencies, use robust ownership, and never trust address(this).balance for critical logic.

How to Identify if You're Vulnerable

Ask these questions about your contract:

If you answered "yes" to the push/loop/dependency questions or "no" to the recovery questions, you likely have an exploitable DoS today.

Next Steps