Back to Cheat Sheets

🚫 Denial of Service Attacks

OWASP Smart Contract Top 10 (2025) - SC10

MEDIUM RISK

📋 What Is It?

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 that ends when the traffic stops, an on-chain DoS is often permanent: immutable code has no "restart" button, so a wedged contract can trap funds forever. What makes it distinct from a plain bug is that an attacker can deliberately engineer the broken state-becoming a "poison" recipient that always reverts, or inflating an array past the gas limit-and a single actor can freeze the shared mechanism for everyone else while usually losing nothing themselves.

SC10 OWASP Rank
Often Permanent Impact
Cheap to Trigger Attacker Cost

⚠️ Common Exploits

  • Poison recipient: one reverting receive() in a push-payment loop blocks all payouts
  • Refund-on-outbid freeze: a bidder that rejects ETH can never be refunded, so no one can outbid
  • Unbounded loop: an array grown with user input eventually exceeds the block gas limit
  • Gas griefing: a recipient burns all forwarded gas so the surrounding logic fails
  • Broken dependency: a required call to a paused or self-destructed contract reverts forever
  • Lost single owner: recovery gated on one key is uncallable if that key is gone
  • Forced-balance jam: selfdestruct force-sends ETH to break an exact-balance invariant

🔴 Attack Flow

1. Study: find a loop, push payment, refund-on-outbid, or hard dependency
↓
2. Position: become a recipient/bidder the contract MUST interact with
↓
3. Poison: reject ETH, burn all gas, inflate the array, or break the dependency
↓
4. Freeze: the critical path now reverts or runs out of gas for everyone
↓
5. IMPACT: Frozen functionality and permanently locked funds!

❌ Vulnerable Code

// Bad: push loop - one poison recipient reverts the whole batch function refundEveryone() external { for (uint256 i = 0; i < buyers.length; i++) { address buyer = buyers[i]; uint256 amount = paid[buyer]; paid[buyer] = 0; payable(buyer).transfer(amount); ← one revert freezes every refund } } // Bad: unbounded loop - cost grows until it exceeds the block gas limit address[] public participants; function join() external { participants.push(msg.sender); } ← anyone can grow it function distribute(uint256 share) external { for (uint256 i = 0; i < participants.length; i++) { payable(participants[i]).transfer(share); ← O(n) over user input } } // Bad: refund-on-outbid PUSHED to previous bidder inside bid() function bid() external payable { require(msg.value > highestBid, "bid too low"); if (highestBidder != address(0)) payable(highestBidder).transfer(highestBid); ← poison leader blocks all bids highestBidder = msg.sender; ← never reached if refund reverts highestBid = msg.value; } // Bad: hard external dependency - no fallback if the oracle breaks function settle(uint256 qty) external { uint256 price = oracle.latestPrice(); ← paused/destroyed dep = permanent freeze } // Bad: exact-balance invariant an attacker can jam with forced ETH function finalize() external { require(address(this).balance == goal, "not exactly at goal"); ← selfdestruct forces it false }

✅ Secure Code

// Good: pull over push - each user withdraws independently (isolate failure) mapping(address => uint256) public pendingRefund; function _creditRefund(address user, uint256 amount) internal { pendingRefund[user] += amount; // just bookkeeping, no external call } function withdrawRefund() external { uint256 amount = pendingRefund[msg.sender]; require(amount > 0, "nothing to withdraw"); pendingRefund[msg.sender] = 0; // effects BEFORE interaction (bool ok, ) = payable(msg.sender).call{value: amount}(""); require(ok, "withdraw failed"); ← only THIS caller is affected } // Good: bounded pagination when a loop is truly unavoidable function distributeBatch(uint256 start, uint256 count, uint256 share) external { require(count <= 100, "batch too large"); ← hard upper bound per call uint256 end = start + count; require(end <= participants.length, "out of range"); for (uint256 i = start; i < end; i++) payable(participants[i]).transfer(share); } // Good: isolate the batch - credit failures instead of reverting the whole loop function distribute(address[] calldata to, uint256 amount) external { require(to.length <= 100, "batch too large"); for (uint256 i = 0; i < to.length; i++) { (bool ok, ) = payable(to[i]).call{value: amount, gas: 2300}(""); if (!ok) pendingRefund[to[i]] += amount; ← fall back to pull; keep going } } // Good: swappable dependency + try/catch - one bad call is recoverable function currentPrice() public view returns (uint256 price, bool ok) { try IPriceFeed(oracle).latestPrice() returns (uint256 p) { return (p, true); } catch { return (0, false); // caller decides; no hard revert } } // Good: internal accounting + >= so forced ETH can't jam the invariant uint256 public totalDeposited; function deposit() external payable { totalDeposited += msg.value; } function finalize() external { require(totalDeposited >= goal, "goal not reached"); ← forced ETH can't move this } // Good: robust ownership - two-step transfer prevents an unrecoverable owner import "@openzeppelin/contracts/access/Ownable2Step.sol"; contract Vault is Ownable2Step, ReentrancyGuard { // pendingOwner must acceptOwnership(); use a multisig as owner }

✓ Prevention Checklist

  • Prefer pull over push - let each user withdraw their own funds
  • Bound every loop; never iterate over unbounded user-controlled data
  • Use pagination with a hard per-call cap when a loop is unavoidable
  • Isolate external calls - credit failures instead of reverting the batch
  • Convert refund-on-outbid into a pull credit, never a pushed transfer
  • Avoid hard dependencies; make them swappable and fail gracefully
  • Wrap risky external calls in try/catch
  • Use per-user mappings and internal accounting over arrays you must sweep
  • Never trust address(this).balance; use >=, not ==
  • Use multisig + two-step ownership (Ownable2Step) for recovery
  • Forward a bounded gas stipend, or use pull, to defeat gas griefing
  • Fuzz array sizes and add gas-bound assertions for batch functions

🔍 Detection & Tools

Analysis Tools:

Slither Mythril Echidna Foundry Manticore

Secure Libraries:

OpenZeppelin Ownable2Step ReentrancyGuard PullPayment

How to Test:

  • Deploy a poison RevertOnReceive recipient and assert others are still paid or credited
  • Fuzz array sizes to prove batch functions stay under a gas ceiling or paginate
  • Add gas-bound assertions: critical functions cost a safe fraction of the block limit
  • Test a gas-burning recipient that consumes all forwarded gas
  • Force ETH in via selfdestruct and assert balance-based logic still works
  • Simulate a paused / self-destructed dependency and confirm graceful failure
  • Monitor on-chain: alert if a critical function starts reverting or nears the gas ceiling

🌍 Real-World Incident Classes

  • Push-payment refund DoS (auctions and "King"-style games): a contract refunds the previous holder by pushing ETH when a new participant takes the position. An attacker takes the position from a contract that rejects incoming ETH, so every future attempt must first refund that poison contract-which always fails-freezing the mechanism with the attacker permanently on top. Mechanism: a state change that depends on a payment to an untrusted recipient succeeding.
  • Unbounded-loop gas DoS (growing arrays): a contract iterates over an array of investors or payouts that grows without bound. As it grows-organically or because an attacker cheaply adds many entries-the per-transaction cost eventually exceeds the block gas limit, and the batch operation can never fit in a block again. Mechanism: a critical operation whose cost scales with user-controlled input.
  • Locked-funds griefing (broken dependency / lost owner): a contract depends on an external library, oracle, or wallet for a required step, or gates all recovery behind a single owner. When that shared dependency is self-destructed or paused-or the sole owner key is lost-every dependent function reverts with no alternate code path, and large balances become permanently inaccessible. Mechanism: a single hard dependency or single point of privileged control with no fallback.
  • Forced-balance invariant griefing: logic that trusts address(this).balance with an exact-equality check can be jammed by force-sending ETH via selfdestruct, making the equality permanently false so the function can never succeed. Mechanism: the raw balance is not fully under the contract's control.

Note: these are recurring classes of vulnerability, not claims about any single named project. Exact loss figures vary by report; the durable lesson is that DoS is common, cheap to trigger, and frequently irreversible on immutable contracts.

📌 Quick Tips

  • DO NOT push ETH/tokens to many recipients in one loop
  • DO NOT loop over arrays that grow with user input
  • DO NOT make a new action depend on refunding an untrusted party
  • DO prefer pull payments and per-user accounting
  • DO bound and paginate every batch operation
  • DO use multisig + two-step ownership and swappable dependencies
  • DO use internal counters and >=, never trust the raw balance