Web3 & smart contracts
Partial13 minDenial of Service & Gas Exhaustion
A required operation becomes uncallable: an unbounded loop grows past the block gas limit, a single reverting recipient blocks a whole batch, or a griefer forces a critical path to consume so much gas it can never complete. The harm is unavailability — not, by itself, a loss of funds.
Classification
- Domain
- Web3 & smart contracts
- ROOTVIEL support
- Partial — Core is built; parts are not yet wired end-to-end.
- Analysis family
- RootRaven
- CWE
- CWE-400
- OWASP
- SC10
Definition
This class covers defects that make a necessary contract operation impossible or unreasonably expensive to execute, denying service to legitimate users. The three canonical shapes: an unbounded loop whose gas cost scales with attacker-influenceable data until it exceeds the block gas limit; a batch operation that reverts entirely when one recipient reverts, letting a single party block everyone; and gas griefing, where an attacker arranges for a required call to consume excessive gas so the surrounding operation cannot finish.
The precise harm is availability: funds may be frozen (unwithdrawable), an auction may be impossible to finalize, a queue may be un-processable. It is important to state the harm honestly as unavailability; a DoS does not, on its own, transfer value to an attacker, and claiming a fund loss where only a liveness failure was shown would be a fabrication.
Context
DoS appears wherever a contract iterates over data that can grow, pays out to multiple parties in one transaction, or depends on an external call succeeding to make progress. Blocks have a gas limit; any single transaction must fit under it, so any cost that grows without bound is a latent denial of service.
- ▸Loops over arrays/mappings whose length users can grow (participant lists, claimant arrays, pending-order books).
- ▸Batch payouts/refunds that revert the whole transaction if any one recipient’s transfer reverts.
- ▸Operations gated on an external call that an adversary can make revert or consume all forwarded gas.
- ▸State-machine steps that only advance if a costly cleanup over unbounded data completes.
Solidity & EVM fundamentals
Every operation costs gas, paid by the transaction sender, and every transaction must execute within the block gas limit. If the gas required to run a function exceeds what a block allows, the function can never be mined — permanently, until the contract state that drives the cost changes (which may itself be impossible).
- Block gas limit
- An upper bound on total gas per block. A single transaction cannot exceed it, so an operation whose cost grows past it becomes uncallable.
- Unbounded iteration
- A loop whose iteration count depends on data that can grow without a cap. Cost scales linearly (or worse); past a threshold, the transaction no longer fits in a block.
- Revert bubbling in batches
- If a loop makes a sub-call that reverts and the caller does not isolate it, the revert unwinds the entire transaction — so one failing element aborts the whole batch.
- 63/64 gas forwarding (EIP-150)
- A call forwards at most 63/64 of remaining gas and retains 1/64. A griefing callee can burn nearly all forwarded gas; combined with a required success, this can starve the caller’s remaining work.
- Push vs pull
- Pushing funds to many recipients couples the operation’s success to every recipient; pulling (each recipient withdraws their own) decouples them and removes the single-point-of-failure.
The correct mental model: any per-user or per-item work done inside one transaction is a shared resource that one participant can inflate. Design so no single actor can make a required operation too expensive or cause it to revert for everyone.
Root cause
The root cause is coupling the liveness of a required operation to data or external behavior that an adversary (or merely scale) can control. Unbounded loops couple liveness to a growable count; all-or-nothing batches couple every participant’s outcome to the worst-behaving one; griefing couples a required path to an attacker’s gas consumption.
Underneath is a missing separation of concerns: progress of the whole is made to depend on completion of each part in a single atomic transaction, when the parts should be independent (pull-based, bounded, or isolated).
How the attack works, step by step
- 1The attacker identifies a required operation whose cost or success depends on data they can influence — e.g. a refund loop over an array of participants, or a finalize step that pays the current top bidder before accepting a new one.
- 2Unbounded-loop variant: the attacker (or many colluding/ Sybil accounts) inflates the array until iterating it exceeds the block gas limit; the operation can no longer be executed by anyone.
- 3Reverting-recipient variant: the attacker becomes one recipient in a batch and deploys a contract whose receive() always reverts; when the batch tries to pay them, the whole transaction reverts, blocking all other recipients.
- 4Griefing variant: the attacker’s callee consumes nearly all forwarded gas (or forces repeated expensive work), so the surrounding required call runs out of gas before completing.
- 5The result in every variant is the same: a necessary operation cannot complete, freezing funds or halting the protocol’s progress until (if ever) the offending state is removed.
Vulnerable example
A synthetic refund function that loops over all participants and pushes native value to each. Two independent DoS vectors live here: the loop is unbounded in participants, and one reverting recipient aborts every refund.
synthetic — do not use; illustrates the flaw// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; // UNSAFE: unbounded push-payment loop; one reverting recipient blocks all. contract VulnerableSale { address[] public participants; // grows without a cap mapping(address => uint256) public contributed; function contribute() external payable { if (contributed[msg.sender] == 0) { participants.push(msg.sender); // attacker can inflate length } contributed[msg.sender] += msg.value; } // Required operation: refund everyone if the sale is cancelled. function refundAll() external { for (uint256 i = 0; i < participants.length; i++) { address p = participants[i]; uint256 amount = contributed[p]; contributed[p] = 0; // PUSH payment: reverts the WHOLE loop if any p rejects value, // and the loop itself can exceed the block gas limit. (bool ok, ) = p.call{value: amount}(""); require(ok, "refund failed"); // one bad recipient blocks all } } }
An attacker can (a) register many addresses so participants.length makes refundAll exceed the block gas limit, and/or (b) contribute from a contract whose receive() reverts, so require(ok) aborts the entire refund for everyone. Either way, a required operation becomes uncallable — an availability failure.
Impact
- ▸Availability: a required operation (refund, finalize, withdraw-all, process-queue) becomes impossible or impractically expensive to execute.
- ▸Funds frozen: value can become stuck in the contract because the only path to release it is the blocked operation — frozen, not stolen.
- ▸Protocol halt: state machines that cannot advance stall indefinitely until the offending state is removed (which may be impossible by design).
- ▸Low attacker cost: Sybil registration or a tiny reverting contract can be cheap relative to the disruption caused.
Honesty (critical): the demonstrable harm is unavailability. Do NOT claim a DoS caused a fund loss unless a separate, proven mechanism transferred value. A frozen balance is frozen, not stolen. An unbounded loop near the limit is a SIGNAL; a controlled run showing the required operation cannot complete is a FINDING of unavailability.
Detection: static, dynamic, symbolic, fuzzing, formal
- Static
- Flag loops bounded by storage arrays/mappings that external callers can grow; flag push-payment batches with a require on each sub-call success; flag required paths whose success depends on an external call that can revert or griefed gas.
- Dynamic
- In a controlled environment, grow the relevant data and measure gas against a realistic block gas limit; introduce a reverting recipient and confirm the batch aborts.
- Symbolic
- Reason about whether iteration count or gas cost is bounded by attacker-influenceable inputs; identify paths whose completion is not guaranteed under a fixed gas budget.
- Fuzzing / invariant
- Assert a liveness-style property — "refundAll remains executable under the gas limit for any reachable participant set" or "no single recipient can abort the batch" — and search for a counterexample sequence.
- Formal
- Prove an explicit per-call work bound (e.g. a hard cap on iterations, or a pull-based design with O(1) per user). A proof of bounded cost is strong; note that liveness under gas is harder to fully formalize than a safety property.
Keep statuses distinct: an unbounded-loop or push-batch match is a heuristic SIGNAL; a gas measurement crossing the limit in a lab, or a reverting-recipient aborting a batch, is EVIDENCE of unavailability; a proven work bound is the strongest, within its model. A run that completes under the limit is UNKNOWN beyond the tested data sizes, never "safe."
How autonomous agents investigate it
An autonomous investigator frames DoS as a liveness question: "is there a reachable state in which a required operation cannot complete within a block?" It seeks a concrete demonstration of unavailability and is careful not to inflate it into a value-loss claim.
- 1Observation: identify required operations (those that must succeed for funds to move or the protocol to progress), the data their cost depends on, and whether any party can grow that data or revert a sub-call.
- 2Hypothesis: propose "operation X becomes uncallable when data Y grows" and "recipient R can abort batch B" as leads.
- 3Execution paths: on a disposable local chain or local fork, grow Y to realistic adversarial sizes and measure gas against the block gas limit; deploy a reverting recipient and attempt the batch.
- 4Fuzzing / invariant checks: encode a liveness property (operation stays executable under the limit; no single recipient aborts it) and search for a violating sequence.
- 5Adversarial review: refute — is the data actually capped elsewhere? is there a permissionless alternative path (pull) that still works? If a cap or alternative exists, the lead weakens or is refuted. Crucially, verify only unavailability; do not assert any fund movement that was not separately demonstrated.
A near-limit loop is a hypothesis; a gas measurement crossing the limit is evidence of unavailability; a proven value loss would require a separate, demonstrated transfer mechanism and must never be assumed from a DoS alone. LLM plausibility decides nothing.
How RootRaven analyzes it
RootRaven is evidence-first and, for this class, deliberately narrow about what it will claim. It treats an unbounded loop or all-or-nothing batch as a lead, then proves unavailability — and only unavailability — in a controlled environment: a disposable LOCAL blockchain or a LOCAL fork of real on-chain state, where it grows the data or introduces a reverting recipient and shows, via a deterministic AI-free verifier, that the required operation cannot complete under a realistic block gas limit. There is no mode to attack a live public network.
- Epistemic ladder
- Observation → Hypothesis → Experiment → Evidence → Verification → Finding. Evidence is a reproducible run where the required operation exceeds the gas limit or reverts for all, versus a baseline where it completes.
- AI composes, never decides
- The model proposes the growth strategy or the reverting recipient; a deterministic verifier measures gas against the limit and checks whether the operation completed, deciding unavailability without the model.
- Proves unavailability only
- A DoS Finding asserts that a required operation could not complete. RootRaven does NOT convert this into a fund-loss claim; a frozen balance is reported as frozen. Fabricating an economic loss would break the evidence-first contract.
Support is partial here: gas and liveness thresholds are environment-dependent (block gas limit, pricing changes), so a lab result is conditional on the tested parameters. If RootRaven cannot cross the limit within feasible adversarial data, it reports UNKNOWN, never "secure." The caveat stays visible.
Mitigation
Decouple the liveness of required operations from attacker-influenceable data and from any single participant, in deterministic design.
- ▸Use the pull-payment pattern: let each user withdraw their own funds (O(1) per call) instead of pushing to everyone in one transaction; a reverting recipient then only blocks themselves.
- ▸Bound iteration: cap list sizes, or process large sets in pagination/batches with explicit cursors so no single call must iterate unbounded data.
- ▸Isolate failures in any unavoidable batch: use low-level calls that record per-recipient failure and continue, rather than reverting the whole loop.
- ▸Avoid making required progress depend on an external call succeeding; where unavoidable, cap forwarded gas and handle failure gracefully. Add timeouts/escape hatches so funds are never permanently frozen behind a single blocked path.
Corrected example
The corrected sale converts the push loop into a pull: each participant withdraws their own refund in an O(1) call. Why it works: there is no unbounded loop in a required path, and a recipient that reverts on receive only blocks their own withdrawal, not anyone else’s. Limits follow.
synthetic — illustrates the fix and its limits// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract SaferSale { mapping(address => uint256) public contributed; bool public cancelled; address public immutable admin; constructor() { admin = msg.sender; } function contribute() external payable { contributed[msg.sender] += msg.value; // no growable array in a required path } function cancel() external { require(msg.sender == admin, "not admin"); cancelled = true; } // PULL: each user withdraws their own refund, O(1), isolated. function claimRefund() external { require(cancelled, "not cancelled"); uint256 amount = contributed[msg.sender]; require(amount > 0, "nothing to refund"); // Effect FIRST (CEI), then pay only this caller. contributed[msg.sender] = 0; (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "refund failed"); // only blocks THIS caller if they revert } }
Limits of the fix: a user whose receive() always reverts can still fail to claim their own refund — their funds are stuck for them specifically (an escape hatch such as assigning refunds to an alternate address, or a withdraw-to pattern, mitigates this). Pull design removes the global DoS but shifts a self-inflicted liveness problem onto individual misbehaving recipients.
Verifying the fix
Verify by attempting the DoS against the fixed contract in a controlled environment and confirming the required operation stays available, with reproducible tests (conceptually Foundry and Echidna).
- 1Scale test (must now stay available): register a large, adversarial number of participants and confirm each claimRefund stays well under the block gas limit regardless of total participants.
- 2Reverting-recipient test: have one participant contribute from a reverting contract; assert that their failed claim does not affect any other participant’s ability to claim.
- 3Liveness property (Echidna-style): assert "for any reachable participant set, each honest participant can complete claimRefund under the gas limit"; search for a counterexample within the budget.
- 4Baseline check: confirm honest refunds succeed and amounts are correct.
Report honestly and conditionally: "the required operation remained available under the tested participant sizes and gas limit" is a reproduced-defense result tied to those parameters — not immunity, and explicitly not a statement about funds, which a DoS test does not address. A clean run is UNKNOWN beyond the tested sizes.
Limitations
- ▸Results are parameter-dependent: block gas limits and gas pricing change, so a lab threshold is conditional, not permanent.
- ▸Pull patterns can leave an individual’s own funds stuck if they cannot accept them; an escape hatch is a separate design choice.
- ▸Liveness under gas is harder to formalize than safety; proofs typically assert a bounded work-per-call rather than global liveness.
- ▸RootRaven proves unavailability only; any claim of economic loss requires a separate, independently demonstrated mechanism and must not be inferred from a DoS.
References
- [1]SWC-113 — DoS with Failed Call (SWC Registry)
- [2]SWC-128 — DoS With Block Gas Limit (SWC Registry)
- [3]Solidity docs — Security Considerations (gas limit and loops, pull over push for external calls)
- [4]OWASP Smart Contract Top 10 — SC10 Denial of Service Attacks
- [5]MITRE CWE-400 — Uncontrolled Resource Consumption
- [6]EIP-150 — Gas cost changes (the 63/64 call-gas rule)
References point to public, well-known technical sources for further study.
Related articles
