KHAGAN AYDIN

Gas Limiting Vulnerability in Web3: Understanding and Mitigating the Risks

On a normal server, slow code gives you a slow page and a bigger bill. On a blockchain, slow code can kill the transaction, and sometimes freeze the money behind it for good.

· 3 min read

Every operation on a chain like Ethereum costs “gas,” a fee unit for computation. Each block has a hard ceiling on how much gas it can hold. A transaction that needs more gas than a block allows cannot complete: it reverts, it does not run, and the caller still pays for the gas it burned trying. That one rule turns a performance mistake into a security vulnerability, because the amount of work your code does can often be decided by the person attacking it.

The vulnerable pattern: unbounded loops

The danger is any loop whose length is controlled by data an attacker can grow. The classic shape is a contract that pays out or updates every entry in a list:

// Vulnerable: the loop grows with the number of participants
function payEveryone() public {
    for (uint256 i = 0; i < participants.length; i++) {
        participants[i].transfer(amounts[i]);
    }
}

It looks reasonable. Here is the trap. Anyone can add themselves to participants. An attacker signs up ten thousand addresses, or just a few addresses built to reject payment so each step costs more. Now payEveryone needs more gas than a block can hold. It reverts every time it is called. Not slowed down. Frozen. The funds are stuck, for everyone, because the one function that moves them no longer fits in a block. The attacker spent a few dollars to brick the contract.

This is documented as “denial of service with block gas limit” in the Ethereum smart contract security guidance, and it has locked real contracts. The cost to attack is tiny. The damage is total, and on an immutable chain it is often unfixable.

The fix: never do work someone else controls

Replace the push with a pull. Instead of the contract paying everyone in one call, let each person withdraw their own share:

// Fixed: each caller does a fixed, small amount of work
mapping(address => uint256) public owed;

function withdraw() public {
    uint256 amount = owed[msg.sender];
    require(amount > 0, "Nothing to withdraw");
    owed[msg.sender] = 0;            // set state before sending
    payable(msg.sender).transfer(amount);
}

Now each transaction does the same tiny amount of work no matter how many people are in the system. Ten users or ten million, withdraw costs the same and always fits in a block. The attacker can add a million addresses and nothing changes, because no single call ever loops over them.

Note the ordering: set owed to zero before sending, not after. Sending first is a different and more famous bug, reentrancy, the flaw behind the 2016 DAO hack that drained about 60 million dollars worth of ether. Same lesson in both: update your own books before you hand money out.

In threat-modeling terms

Some write-ups run this bug through all six STRIDE categories. That obscures the point. This is Denial of Service, plain and simple. The asset at risk is availability, the attacker’s lever is resource exhaustion, and the resource is the block’s gas budget, which every user of the contract shares. Forcing spoofing or information disclosure onto it adds words, not understanding.

The question before you deploy

This is the oldest idea in security wearing a new coat: never let untrusted input decide how much work your system does. On an ordinary server you learn that lesson from a bill. On a blockchain you learn it once, because the contract is immutable and the frozen funds do not come back.

So before you deploy, read every loop and ask one question: can anyone else make this run longer? If the answer is yes, the gas limit is not your safety rail. It is the attacker’s weapon, and you handed it to them.