Remediating Loop Gas Exhaustion
How to prevent DoS via block gas limit exhaustion by adding maximum iteration limits, pagination, and pull-over-push patterns.
Remediating Loop Gas Exhaustion
Overview
Related Detector: Loop Gas Exhaustion
Loop gas exhaustion occurs when a loop’s iteration count is unbounded and derived from user input or mutable state. An attacker provides input that forces enough iterations to exceed the block gas limit. The fix is to cap iterations with a constant maximum, implement pagination, or redesign the operation to avoid unbounded loops.
Recommended Fix
Add a Maximum Iteration Limit
// BEFORE: Unbounded loop from calldata
function distribute(address[] calldata recipients, uint256[] calldata amounts) external {
for (uint256 i = 0; i < recipients.length; i++) {
token.transfer(recipients[i], amounts[i]);
}
}
// AFTER: Bounded with maximum
uint256 constant MAX_BATCH = 100;
function distribute(address[] calldata recipients, uint256[] calldata amounts) external {
require(recipients.length <= MAX_BATCH, "Batch exceeds maximum");
require(recipients.length == amounts.length, "Length mismatch");
for (uint256 i = 0; i < recipients.length; i++) {
token.transfer(recipients[i], amounts[i]);
}
}
Alternative Mitigations
Pagination for Growing Datasets
For operations over storage arrays that grow over time, use paginated processing:
uint256 constant PAGE_SIZE = 50;
function processRewards(uint256 fromIndex) external returns (uint256 nextIndex) {
uint256 total = stakers.length;
uint256 end = fromIndex + PAGE_SIZE;
if (end > total) end = total;
for (uint256 i = fromIndex; i < end; i++) {
_distributeReward(stakers[i]);
}
return end;
}
Pull-Over-Push for Withdrawal Operations
Replace push-based distribution with pull-based claims:
// BEFORE: Push pattern with unbounded loop
function distributeRewards() external {
for (uint256 i = 0; i < stakers.length; i++) {
payable(stakers[i]).transfer(rewards[stakers[i]]);
}
}
// AFTER: Pull pattern -- no loop needed
mapping(address => uint256) public pendingRewards;
function claimReward() external {
uint256 amount = pendingRewards[msg.sender];
require(amount > 0, "Nothing to claim");
pendingRewards[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
}
Off-Chain Computation with On-Chain Verification
For complex batch operations, compute the result off-chain and submit a Merkle proof:
function claimBatchReward(bytes32[] calldata proof, uint256 amount) external {
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, amount))));
require(MerkleProof.verify(proof, rewardRoot, leaf), "Invalid proof");
require(!claimed[msg.sender], "Already claimed");
claimed[msg.sender] = true;
token.transfer(msg.sender, amount);
}
Common Mistakes
Mistake: Using Storage Array Length as Loop Bound
address[] public users;
function addUser(address user) external {
users.push(user); // Array grows without bound
}
function processAllUsers() external {
for (uint256 i = 0; i < users.length; i++) { // Unbounded
_process(users[i]);
}
}
Either cap the array size in addUser() or paginate processAllUsers().
Mistake: Setting the Maximum Too High
uint256 constant MAX_BATCH = 10_000; // Still too many if loop body is expensive
Calculate the maximum based on the gas cost per iteration: MAX = block_gas_limit / gas_per_iteration / safety_factor. For a loop body costing 45,000 gas with a 30M gas limit, MAX = 30M / 45K / 2 = 333.
Mistake: Bounding the Loop but Not the Array Growth
function processUsers(uint256 count) external {
require(count <= MAX_BATCH, "Too many");
for (uint256 i = 0; i < count; i++) {
_process(users[i]);
}
}
// users array still grows without bound -- processing future batches
// becomes impossible when users.length >> MAX_BATCH
Cap both the processing batch size and the data structure growth rate.