DoS Griefing — Unbounded Loop
A reward distributor iterates over a user-controlled depositors array and pushes ETH to each depositor. Two attack vectors: grow the array until the loop costs more gas than the block limit, or register a reverting contract that permanently blocks all distributions.
~7 min read
Denial-of-Service (DoS) in smart contracts means a function that should always be callable becomes permanently or temporarily blocked. In this lab the distributeRewards()() function iterates over a depositors array that any user can grow. The push-payment model — sending ETH in a loop — creates two attack surfaces: gas exhaustion and reverting receiver.
1. Gas limits and unbounded loops
Every Ethereum block has a gas limit (currently ~30M gas). A transaction that exceeds this limit is invalid and never included. If a loop's gas cost scales with a user-controlled array length, an attacker who inflates the array can make the loop exceed the block gas limit — permanently bricking the function.
The gas cost of an Ethereum loop is approximately: (setup overhead) + n * (per-iteration cost). For a loop over address[]: each iteration does a storage read (~2100 gas cold, ~100 hot), an arithmetic operation (~5 gas), and an ETH send (~2300–21000 gas depending on recipient type). At 10,000 entries with cold storage reads plus ETH sends, this easily hits 100M gas — over 3x the block limit.
An attacker can call deposit()() thousands of times from different addresses (or the same address, since the contract pushes to the array on every deposit without deduplication). Each deposit adds one entry. After enough deposits, distributeRewards()() is permanently out of gas.
2. Reverting receiver — push payment griefing
Push payment means the distributing contract calls recipient.call{value: share}("") for each recipient. If any recipient is a contract whose receive() function reverts, the entire loop transaction reverts — no recipient gets paid.
The reverting receiver is a griefing attack: an attacker deploys a contract with `receive() external payable { revert('grief'); }` and calls deposit() once. Now every distribution attempt reverts at the attacker's entry, permanently blocking all recipients.
3. The attack — gas bomb or griefing
Two attacks on VulnerableRewardDistributor. Either one alone permanently blocks distributeRewards()():
// Attack 1: Gas bomb
// Attacker calls deposit() thousands of times
// depositors array length = 50,000
// distributeRewards() loop gas: ~50,000 * 5,000 = 250M > block limit
// Function is permanently out-of-gas
// Attack 2: Reverting receiver
// Attacker deploys GriefContract { receive() external payable { revert(); } }
// GriefContract.deposit() — attacker is in depositors array
// distributeRewards() reaches GriefContract, call reverts
// Entire transaction reverts — all legitimate depositors blockedThese two vectors can be combined: gas bomb makes the function expensive, reverting receiver makes it permanently fail at a specific entry. The attacker pays minimal gas (one deposit) for a permanent DoS on all legitimate users.
Attack timeline
- 1
- 2
- 3
- 4
- 5
4. The fix — pull-payment pattern
Replace the push loop with per-recipient balance tracking. Each depositor calls withdraw() themselves. A single failed withdrawal does not affect others:
If an iterable distribution is required (e.g., an on-chain snapshot), use pagination: distribute in batches with a startIndex and endIndex parameter. No single transaction covers the full array.
When ETH transfers must happen in a loop, skip failed transfers instead of reverting: `(bool ok, ) = recipient.call{value: share}(""); if (!ok) { pendingRewards[recipient] += share; }`. The failed share accumulates in a mapping the recipient can withdraw later.
5. What to look for as an auditor
- Any loop over an array where the array length is user-controllable (users can push entries via deposit, register, or any public function). Flag as potential gas bomb.
- Push-payment in a loop: (bool ok,) = recipient.call{value:...}. If require(ok) is inside the loop, a single reverting recipient blocks everyone. This is an automatic finding.
- Check if the loop array has a size cap. Is there a require(depositors.length < MAX)? If not, the array can grow without bound.
- Check for duplicate entries in user-controlled arrays. If a single address can be added multiple times, the attacker's DoS cost decreases.
- Off-chain workarounds do not help: even if the owner can pause the contract, the rewards are still stuck once the DoS is active. The fix must be architectural (pull-payment).
- Batch patterns with pagination are acceptable if the batch size is bounded by a hardcoded constant, not by user input.
With the two DoS vectors in mind, open the Hunt tab. Identify the array and the push-payment pattern, then describe which vector an attacker would use and why.