Denial of service via gas limit
A loop over an array that anyone can grow will eventually cost more gas than a block allows. At that point the function can never execute again. The same happens when you push payments to a list of addresses and one of them refuses to accept.
Vulnerable code
address[] public investors;
function invest() external payable {
investors.push(msg.sender); // unbounded growth
}
function distribute() external onlyOwner {
for (uint256 i = 0; i < investors.length; i++) {
payable(investors[i]).transfer(share); // one revert kills all
}
}
Why it breaks
Two independent denial-of-service conditions. First, anyone can call invest cheaply from many addresses until the loop in distribute exceeds the block gas limit, permanently. Second, transfer reverts if a recipient is a contract that rejects ether, and because the whole loop is one transaction, a single hostile recipient blocks payment to everyone else forever.
Watch the attack run
The same bug as a stepped sequence. Play it, or walk through with the arrow keys.
The fix
mapping(address => uint256) public owed;
function allocate(address who, uint256 amt) external onlyOwner {
owed[who] += amt; // record, don't send
}
// Each recipient collects their own funds.
function withdraw() external {
uint256 amt = owed[msg.sender];
require(amt > 0, "nothing owed");
owed[msg.sender] = 0; // effects before interaction
(bool ok, ) = msg.sender.call{value: amt}("");
require(ok, "withdraw failed");
}
The invariant
The fix above is what an audit gives you. This is what monitoring gives you: the thing that should always be true once the contract is live, stated precisely enough to check every block.
Any array iterated in a state-changing function stays below a gas-safe length ceiling.
invariant recipients.length <= 250 watch gas_used / block_gas_limit > 0.5 on distribute()
How to test for it
A finding you cannot reproduce is an opinion. Write the test before you write the fix, watch it fail, then make it pass.
contract Rejector { receive() external payable { revert(); } }
function testOneBadRecipientCannotBlockOthers() public {
pool.allocate(address(new Rejector()), 1 ether);
pool.allocate(alice, 1 ether);
vm.prank(alice);
pool.withdraw(); // unaffected by the rejector
assertEq(alice.balance, 1 ether);
}
Where it has caused real losses
A well-known 2016 game contract locked because its refund loop iterated over every participant and one entry was a contract that rejected payment. Nobody could be refunded, including the many participants who had done nothing wrong. The funds were never recovered.
What we check during review
Related patterns to watch for: unbounded loops over storage arrays anywhere in a state-changing path; array deletion in a loop, which is quadratic in gas; and external calls inside loops generally, since each one is a place where a hostile contract can revert or burn gas. If a loop must exist, bound its length explicitly and provide a paginated version that processes a fixed slice per call.
Want us to check your contract for this?
A free monitoring assessment covers one contract up to 200 lines, manually reviewed, findings back within 72 hours. This class is on the checklist for every review we run.
Related vulnerabilities
Found this useful?
We publish these because the alternative, asking you to trust us, is worth less. Send a contract and we’ll apply the same thinking to your code.
- Reply written by the auditor who read your code
- No sales sequence, no drip campaign, no retargeting
- We’ll tell you if you don’t need a paid audit yet
- Report published only with your written permission