Denial of Service Remediation
How to eliminate denial-of-service vulnerabilities by replacing push-based ETH distribution with pull withdrawal patterns and adding maximum batch sizes and pagination to prevent unbounded loop gas exhaustion.
Denial of Service Remediation
Overview
Denial-of-service vulnerabilities in smart contracts arise from two primary patterns. The first is push-based ETH distribution: when a contract iterates over an array of recipients and sends ETH to each one in a loop, a single recipient whose receive() function reverts causes the entire distribution to fail permanently. The second is unbounded loop execution: when an array can be inflated without limit by user actions, the gas cost of iterating over it can exceed the block gas limit, making the function permanently unusable and locking any funds deposited for distribution.
GovernMental was permanently locked due to an unbounded loop. The King of the Ether contract blocked legitimate refunds when recipients refused ETH. Both vulnerabilities remain common in production contracts today.
The remediation is to replace push-over-pull patterns with pull withdrawals, cap loop iterations with a bounded batch size, and paginate large operations across multiple transactions.
Related Detector: DoS Detector
Recommended Fix
Before (Vulnerable)
contract VulnerableAirdrop {
address[] public recipients;
mapping(address => uint256) public amounts;
function addRecipient(address recipient, uint256 amount) external onlyOwner {
recipients.push(recipient); // No limit — attacker inflates array
amounts[recipient] = amount;
}
// VULNERABLE: Iterates over unbounded array — runs out of gas when length > ~1000
// Also: one reverting recipient blocks the entire distribution
function distributeTokens() external {
for (uint256 i = 0; i < recipients.length; i++) {
(bool success,) = payable(recipients[i]).call{value: amounts[recipients[i]]}("");
require(success, "Transfer failed"); // One failure blocks all others
}
}
}
contract VulnerableRefund {
mapping(address => uint256) public balances;
// VULNERABLE: Attacker deploys contract with reverting receive() to block refunds
function refund(address payable user) external {
uint256 amount = balances[user];
balances[user] = 0;
user.transfer(amount); // Reverts if user is a contract that rejects ETH
}
}
After (Fixed)
// FIX 1: Pull pattern — each user withdraws their own funds
// A single user's reverting receive() cannot affect other users
contract SafeRefund {
mapping(address => uint256) public withdrawable;
function recordRefund(address user, uint256 amount) internal {
withdrawable[user] += amount;
}
// User calls withdraw() themselves — no push, no shared state risk
function withdraw() external {
uint256 amount = withdrawable[msg.sender];
require(amount > 0, "No balance to withdraw");
withdrawable[msg.sender] = 0; // Zero first to prevent reentrancy
(bool success,) = msg.sender.call{value: amount}("");
if (!success) {
// If the call fails, restore balance so user can try again
withdrawable[msg.sender] = amount;
revert("Transfer failed");
}
emit Withdrawn(msg.sender, amount);
}
}
// FIX 2: Bounded batch size with pagination
contract SafeAirdrop {
address[] public recipients;
mapping(address => uint256) public amounts;
uint256 public lastProcessedIndex;
uint256 public constant MAX_BATCH_SIZE = 50;
uint256 public constant MAX_RECIPIENTS = 10_000;
function addRecipient(address recipient, uint256 amount) external onlyOwner {
require(recipients.length < MAX_RECIPIENTS, "Recipient limit reached");
recipients.push(recipient);
amounts[recipient] = amount;
}
// Paginated distribution — caller specifies batch size within the cap
function distributeTokens(uint256 batchSize) external {
require(batchSize > 0 && batchSize <= MAX_BATCH_SIZE, "Invalid batch size");
require(lastProcessedIndex < recipients.length, "Distribution complete");
uint256 endIndex = lastProcessedIndex + batchSize;
if (endIndex > recipients.length) {
endIndex = recipients.length;
}
for (uint256 i = lastProcessedIndex; i < endIndex; i++) {
address recipient = recipients[i];
uint256 amount = amounts[recipient];
if (amount > 0) {
amounts[recipient] = 0; // Prevent double-distribution
(bool success,) = payable(recipient).call{value: amount}("");
// Do NOT revert on individual failure — continue the batch
if (!success) {
// Log the failure and allow the recipient to pull later
pendingRefunds[recipient] += amount;
emit TransferFailed(recipient, amount);
}
}
}
lastProcessedIndex = endIndex;
emit BatchProcessed(lastProcessedIndex, endIndex);
}
// Fallback pull mechanism for failed push transfers
mapping(address => uint256) public pendingRefunds;
function claimFailedTransfer() external {
uint256 amount = pendingRefunds[msg.sender];
require(amount > 0, "Nothing to claim");
pendingRefunds[msg.sender] = 0;
(bool success,) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Alternative Mitigations
Gas limit per external call — when a push pattern cannot be avoided, limit the gas forwarded to each recipient to prevent runaway execution in the recipient’s receive() function:
// Forward limited gas to prevent recipient from consuming all available gas
(bool success,) = payable(recipient).call{value: amount, gas: 2300}("");
// 2300 gas allows only basic ETH receipt — no storage writes in recipient
// Use this only for simple ETH transfers; not for ERC-20 or complex callbacks
Merkle-based claim pattern — for large airdrops, distribute a Merkle root on-chain and require each recipient to submit their own Merkle proof. This eliminates the loop entirely: each recipient claims independently:
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
contract MerkleAirdrop {
bytes32 public immutable merkleRoot;
mapping(address => bool) public claimed;
constructor(bytes32 _merkleRoot) {
merkleRoot = _merkleRoot;
}
function claim(uint256 amount, bytes32[] calldata proof) external {
require(!claimed[msg.sender], "Already claimed");
bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount));
require(MerkleProof.verify(proof, merkleRoot, leaf), "Invalid proof");
claimed[msg.sender] = true;
payable(msg.sender).transfer(amount);
emit Claimed(msg.sender, amount);
}
}
Avoid unbounded array growth — cap the maximum size of all contract-owned arrays. For user-supplied collections (NFT holders, staker lists), use off-chain indexing and Merkle proofs rather than on-chain arrays:
uint256 public constant MAX_PARTICIPANTS = 500;
function join() external {
require(participants.length < MAX_PARTICIPANTS, "Participant cap reached");
participants.push(msg.sender);
}
Separate failed transfers into a recovery queue — instead of reverting the entire batch on a single failure, record failures and allow recipients to pull their own allocation:
mapping(address => uint256) public undelivered;
function processBatch(...) external {
for (uint256 i = start; i < end; i++) {
(bool ok,) = payable(recipients[i]).call{value: amounts[i]}("");
if (!ok) {
undelivered[recipients[i]] += amounts[i];
}
}
}
Common Mistakes
Using .transfer() instead of .call() — .transfer() forwards exactly 2300 gas and reverts if the recipient uses more. Since EIP-1884 (Istanbul upgrade), many contracts use more than 2300 gas in their receive() function. Use low-level .call() instead, which forwards all available gas, combined with an explicit check on the return value.
Relying on try/catch for external call failures in a loop — try/catch in Solidity only works with external function calls at the call level. A low-level .call() that reverts returns success = false without bubbling up; using try on it is not possible. Use the low-level call pattern with the return value check instead.
Not accounting for griefing in unbounded loops — even without an attacker, a system that allows users to join an unbounded list will eventually hit the block gas limit through normal growth. Apply caps early, before the limit is approached.
Re-entering through the push payment — a push transfer to a recipient contract that has a receive() function calling back into the distributing contract can re-enter the distribution logic. Zero the balance before any external call (checks-effects-interactions pattern) and use the pull pattern to eliminate the attack surface entirely.
References
- SWC-113: DoS with Failed Call
- SWC-128: DoS with Block Gas Limit
- ConsenSys: DoS with Block Gas Limit Best Practices
- OpenZeppelin: MerkleProof
- King of the Ether Throne Post-Mortem (2016)
- GovernMental Locked Funds Post-Mortem (2016)