- name:
- gas-optimization-patterns
- description:
- Dynamic array push without apparent length bound - unbounded array growth enables DoS when the array is later iterated in a single transaction
- category:
- vulnerability-pattern
- pattern_category:
- gas-optimization
- - regex:
- \.push\([^)]*\)
- severity:
- Medium
- confidence:
- Low
- swc:
- SWC-128
Gas Optimization Risk Patterns
Overview
Gas inefficiency becomes a security issue when costs are attacker-influenced. A function that works for small collections may become permanently uncallable after state growth, effectively creating a denial-of-service condition without any explicit revert logic. In production protocols, these patterns usually emerge in reward distribution, migration scripts, liquidation queues, and accounting loops.
The dangerous class is unbounded iteration over mutable on-chain state, especially when each loop body performs storage writes or external interactions. Gas limits are hard caps at block level, so any architecture that assumes processing "all users" in one transaction eventually fails as adoption grows. Attackers can accelerate that failure by inflating arrays or forcing worst-case paths.
Auditing for gas-related vulnerabilities should treat scalability assumptions as invariants. The key question is whether protocol-critical workflows can always make forward progress under realistic chain conditions. If progress requires a loop that can grow without bound, the protocol has a latent liveness fault.
Key Attack Vectors
- Unbounded loops over dynamic arrays (
users.length, positions.length) that grow with normal usage.
- Storage writes inside loops where each iteration incurs expensive
SSTORE costs.
- External value transfers in loops that revert on one recipient and block all recipients.
- Unchecked
.push() growth on arrays later consumed in single-transaction maintenance jobs.
- Admin "sweep" functions that try to settle all pending records at once.
- Reward distributor contracts that compute and write per-user state in one pass.
- Batch operations without explicit
startIndex and batchSize limits.
- Emergency recovery functions with O(n) writes to critical storage.
Common Failure Mode Timeline
- Protocol ships with loop-based accounting that is cheap at low scale.
- State arrays grow over weeks or months through routine activity.
- Core maintenance function approaches block gas limit.
- Adversary triggers additional growth or worst-case recipients.
- Function reverts consistently and becomes unusable.
- Treasury, rewards, or risk controls stall until contract migration.
High-Risk Contract Areas
- Reward claim and distribution modules.
- Liquidation backlogs and debt settlement queues.
- Airdrop and vesting payout scripts.
- Keeper-executed rebalance loops.
- Governance payout executors.
Detection Heuristics
Structural Heuristics
- Flag any
for or while loop using .length from storage arrays.
- Classify loops as unbounded unless there is a hard cap enforced on array growth.
- Identify loops that modify storage each iteration rather than accumulating in memory.
- Mark loops with
.call, .transfer, or .send as liveness-critical due to external failure coupling.
Data-Flow Heuristics
- Track whether the loop bound is attacker-influenced (user registration, deposits, mints).
- Determine whether the function is required for protocol progress (not just optional convenience).
- Check if failed transfers cause full revert instead of skipping failed recipients.
- Evaluate if array growth is prunable or monotonic.
Severity Escalation Signals
- Function is permissionless and called frequently.
- Loop touches protocol-wide accounting or insolvency controls.
- There is no pagination path for operators.
- Keeper bots depend on the function to maintain invariants.
Concrete Code Smells
function distributeRewards() external {
for (uint256 i = 0; i < recipients.length; i++) {
// storage write each iteration
claimed[recipients[i]] += rewards[recipients[i]];
payable(recipients[i]).transfer(rewards[recipients[i]]);
}
}
function register(address user) external {
participants.push(user); // no max length
}
Auditing Checklist
- Can any loop be split into deterministic chunks?
- Is each chunk externally callable without privileged trust?
- Are loop bounds independent from attacker-controlled growth?
- Are failed external payouts isolated per recipient?
- Can the system recover if a single recipient is non-payable?
- Is there an emergency path that remains O(1) or bounded O(k)?
Prevention
Architecture Patterns
- Replace push-payment loops with pull-payment claims per user.
- Use pagination (
start, end or batchSize) for all list processing.
- Bound collection sizes with explicit maximums when feasible.
- Prefer mappings + counters over ever-growing arrays for membership tracking.
Loop Hardening
- Cache array length in memory if static during function scope.
- Accumulate calculations in memory and perform one storage write post-loop where possible.
- Use unchecked increments only when overflow is impossible and validated.
- Emit events for skipped recipients instead of reverting entire batch.
External Call Isolation
- Move external transfers to user-driven
claim() functions.
- If batch payout is required, wrap each call and continue on failure.
- Record failed recipients for retry queues.
- Avoid coupling core accounting success to transfer success.
Example Safer Pattern
function processBatch(uint256 start, uint256 batchSize) external {
uint256 end = start + batchSize;
if (end > recipients.length) end = recipients.length;
for (uint256 i = start; i < end; i++) {
address user = recipients[i];
uint256 amount = pending[user];
if (amount == 0) continue;
pending[user] = 0;
(bool ok,) = user.call{value: amount}("");
if (!ok) {
pending[user] = amount;
emit PayoutFailed(user, amount);
}
}
}
Operational Mitigations
- Monitor gas usage trends per critical function over time.
- Add canary tests that simulate large-state stress scenarios.
- Define upgrade plans for liveness failure before launch.
- Maintain runbooks for phased processing if backlog spikes.
Real-World Examples
Reflection and Dividend Token Incidents
- Recurrent pattern: token contracts iterate over all holders to distribute rewards.
- Impact: transfers and claims become unusable as holder count grows.
- Lesson: holder-wide loops are not sustainable in public deployments.
Airdrop Distribution Failures
- Recurrent pattern: one-shot distribution transactions over large recipient sets.
- Impact: distributions revert due to gas limits or one reverting recipient.
- Lesson: merkle claims and pull-based redemption are safer than monolithic payouts.
DeFi Keeper Stalls
- Recurrent pattern: keeper actions require iterating all stale positions.
- Impact: risk controls fail to execute under stressed market conditions.
- Lesson: bounded batches and priority queues are mandatory for keeper reliability.
Pattern-to-Impact Mapping
unbounded-loop -> protocol function eventually exceeds block gas limit.
storage-write-in-loop -> gas griefing and escalating execution costs.
external-call-in-loop -> single recipient can block full batch execution.
unchecked-array-growth -> latent DoS when arrays are consumed later.
References
- SWC-128 (DoS with block gas limit): https://swcregistry.io/docs/SWC-128
- Solidity gas optimization guide: https://docs.soliditylang.org/en/latest/internals/optimizer.html
- OpenZeppelin PullPayment pattern: https://docs.openzeppelin.com/contracts/4.x/api/security#PullPayment
- ConsenSys smart contract best practices: https://consensys.github.io/smart-contract-best-practices/
- Solidity by Example - iterable mappings caveats: https://solidity-by-example.org/
- Ethereum yellow paper gas model references: https://ethereum.github.io/yellowpaper/paper.pdf