Gas Griefing: How Unbounded Loops Brick Your Contract Forever

An unbounded loop is a time bomb with a fuse measured in gas units. Once your array grows past the block gas limit, every function that iterates over it reverts — permanently. No upgrade, no recovery, just a tombstone on-chain. The 2016 GovernMental Ponzi learned this the hard way, locking 1,100…

Gas griefing (SWC-128) is a denial-of-service vulnerability where a Solidity function iterates over an unbounded data structure — usually an array — until its execution cost exceeds the block gas limit. Once that threshold is crossed, every call reverts, freezing funds and bricking contract logic. The fix: replace push-based loops with pull payments, paginate iteration, or cap array growth at write-time.
··7 min read

The Bug: Loops That Outgrow the Block

Ethereum's block gas limit is a hard ceiling — currently around 30 million gas. If a single transaction needs more than that to execute, it cannot be included in any block. Ever. Not next week, not after a fork, not for love or money.

Now consider a contract that loops over a dynamically growing array. Every new entry adds gas cost to that loop. At some point — and it's not an *if*, it's a *when* — the loop's gas cost crosses the block limit, and the function becomes uncallable. Permanently.

This is gas griefing, formalized as **SWC-128**. It's not a fancy reentrancy trick or a math overflow. It's arithmetic: arrays grow, gas is finite, and developers keep writing `for` loops like they're free.

The Vulnerable Pattern

Here's the canonical footgun — a payout function that distributes funds to every participant in one transaction:

```solidity
contract LotteryBad {
address[] public players;
uint256 public prizePerWinner;

function enter() external payable {
require(msg.value == 1 ether, "wrong fee");
players.push(msg.sender);
}

function payoutAll() external {
uint256 share = address(this).balance / players.length;
for (uint256 i = 0; i < players.length; i++) {
payable(players[i]).transfer(share);
}
}
}
```

Looks fine in a Hardhat test with 10 players. Now imagine 5,000 players. Each `transfer` costs ~2,300 gas plus loop overhead — call it 10k gas per iteration. At 3,000 entries, you're past the block limit. The contract balance is now stuck. Forever.

Worse: an attacker can *deliberately* grow the array with cheap entries (or fund many addresses) to brick the contract early. That's the griefing part — they don't need to steal funds, they just need to make sure nobody else gets theirs.

Real Money, Real Tombstones: GovernMental (2016)

The **GovernMental Ponzi** is the textbook case. The contract tracked participants in a dynamic array and required iteration over the full list to pay out the jackpot. By late 2016, the array had grown so large that the payout function exceeded the block gas limit. Roughly **1,100 ETH** — over a million dollars at the time — became permanently inaccessible. Not stolen. Not exploited in any clever sense. Just stuck behind a `for` loop the developer never imagined would grow that big.

The contract is still there. The ETH is still there. Nobody's getting it out.

Fix #1: Pull Payments

The single most important pattern for avoiding gas-DoS is the **pull payment**: instead of the contract pushing funds to N recipients, each recipient pulls their own share in a separate transaction. Gas costs are bounded per-call and paid by the user who benefits.

```solidity
contract LotteryGood {
mapping(address => uint256) public pendingWithdrawals;
address[] public players;

function enter() external payable {
require(msg.value == 1 ether, "wrong fee");
players.push(msg.sender);
}

function distribute() external {
uint256 share = address(this).balance / players.length;
for (uint256 i = 0; i < players.length; i++) {
pendingWithdrawals[players[i]] += share;
}
delete players;
}

function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "nothing to withdraw");
pendingWithdrawals[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
}
```

Note: `distribute()` *still* has a loop. If `players.length` grows unbounded, it has the same problem. Which brings us to fix #2.

Fix #2: Account at Write-Time, Not Read-Time

The cleanest pattern eliminates the loop entirely by maintaining running totals on each state change:

```solidity
contract LotteryBest {
mapping(address => uint256) public shares;
uint256 public totalShares;
uint256 public totalDeposited;
mapping(address => uint256) public claimed;

function enter() external payable {
require(msg.value == 1 ether, "wrong fee");
shares[msg.sender] += 1;
totalShares += 1;
totalDeposited += msg.value;
}

function withdraw() external {
uint256 entitled = (totalDeposited * shares[msg.sender]) / totalShares;
uint256 owed = entitled - claimed[msg.sender];
require(owed > 0, "nothing owed");
claimed[msg.sender] = entitled;
(bool ok, ) = msg.sender.call{value: owed}("");
require(ok, "transfer failed");
}
}
```

No loops. No iteration. Gas cost is O(1) regardless of how many users participate. This is the pattern used by well-designed staking pools and dividend tokens (MasterChef-style accumulator math).

Fix #3: Pagination When You Must Loop

Some use cases genuinely require iteration — snapshot voting, batch processing, off-chain indexed queries. When you can't avoid a loop, paginate it:

```solidity
function processBatch(uint256 start, uint256 end) external {
require(end <= items.length && start < end, "bad range");
require(end - start <= 200, "batch too large");
for (uint256 i = start; i < end; i++) {
_process(items[i]);
}
}
```

Cap the batch size at write-time. Don't trust callers to pick safe ranges — enforce a maximum.

Other Gas-DoS Vectors To Watch

Unbounded arrays aren't the only path to gas griefing. Watch for:

**External call failure**: if your loop does `recipient.transfer(...)` and one recipient is a contract that reverts on receive, the whole batch dies. Use `call` with a return-value check and skip failures, or use pull payments.
- **Storage writes in loops**: each SSTORE is 5k-20k gas. Loops that write to mappings or arrays scale viciously.
- **`delete` on large arrays**: deleting a 10,000-element array can itself exceed the gas limit.
- **Calls to user-supplied contracts**: any unbounded list of external addresses is an attack surface.

Auditing Your Own Code

Grep your codebase for `for (` and `while (`. For every match, ask:

1. What's the upper bound on iterations?
2. Who controls the size of that bound?
3. What happens at 10,000 iterations? 100,000?
4. Is there any path where a hostile actor can grow the bound cheaply?

If you can't answer all four with confidence, you have a potential gas-DoS. Run our [free AI-powered Solidity audit](https://www.cryptohawking.com/audit) — it flags unbounded-loop patterns and missing pull-payment structures automatically. For production contracts where funds are at stake, our [manual audit](https://www.cryptohawking.com/audit/manual) goes deeper: 3 business days, $5,000 paid in ETH/SOL/USDT, with two senior engineers walking the full attack surface.

The Takeaway

Gas griefing isn't exotic. It's the most boring vulnerability in Solidity, and that's exactly why it keeps shipping to mainnet. A `for` loop over an array nobody thought would grow. A push-payment design that worked in tests with 5 users. Iteration over external calls that one griefer can poison.

The defensive posture is simple: assume any data structure controlled by users will grow beyond what you can iterate over. Then design around that assumption — pull payments, accumulator math, paginated batches — before deployment, not after the funds are locked.

FAQ

What's the difference between gas griefing and a regular DoS attack?

Gas griefing is a specific subclass of DoS unique to gas-metered VMs like the EVM. A traditional DoS overwhelms a server with requests; gas griefing exploits the fact that every transaction has a hard gas ceiling enforced by the block limit. The attacker doesn't need bandwidth or compute — they just need to inflate your contract's state (or revert in a callback) until a critical function exceeds the limit and becomes permanently uncallable. The damage is on-chain and often irreversible.

Are pull payments always safer than push payments?

Generally yes, but with one caveat: pull payments shift the gas cost to the user, which can be a UX problem for small amounts where the withdrawal costs more than the payout. They also add a state variable (pending balances) that must be carefully managed against reentrancy. Use checks-effects-interactions, zero out balances before transferring, and prefer `call` over `transfer` for forward compatibility with post-Istanbul gas pricing. For airdrops with thousands of recipients, pull is essentially mandatory.

Can I just increase the block gas limit to fix this?

No. The block gas limit is set by validators across the entire network — you, as a contract deployer, have zero control over it. Even if Ethereum raised it tomorrow, you'd just be kicking the can: a contract designed with unbounded growth will eventually hit any ceiling. The fix has to live in your contract architecture, not in network parameters. Design for O(1) per-user operations, not O(N) where N is user-controlled.

How do I detect unbounded loops in an existing codebase?

Static analyzers like Slither flag loops over storage arrays with `loops` and `costly-loop` detectors. Manually, grep for `for` and `while`, then trace whether the upper bound is constant, admin-controlled, or user-controlled. User-controlled bounds are red flags. Foundry invariant fuzzing helps too — write an invariant that critical functions remain callable, then let the fuzzer grow state until something breaks. Our automated audit at cryptohawking.com/audit checks for this class of bug as part of its standard ruleset.

What about the `try/catch` pattern — can it save a failing loop?

Partially. Wrapping external calls in `try/catch` lets you skip failed recipients instead of reverting the whole batch, which solves the malicious-callback variant of gas-DoS. It does *not* solve the unbounded-array variant — the loop itself still costs gas proportional to the array size, and `try/catch` doesn't reduce that cost. Treat `try/catch` as a complement to pagination and pull payments, not a replacement. And remember it only works on external function calls, not internal logic.

One Solidity tip + 1 case study per month