Gas is Ethereum's metered execution model: every opcode costs gas, and every transaction carries a hard ceiling. This creates an attack surface that differs from traditional software vulnerabilities. An attacker does not need to steal funds. They only need to force a target contract to run out of gas at the wrong moment — causing a revert, a stuck state, or an economically unbearable cost for honest users.
Gas griefing is a class of denial-of-service attacks where a malicious actor manipulates gas consumption to disrupt contract execution. The attacker may inflate loop iterations, return oversized data, poison storage slots, or exploit EVM forwarding rules — all without touching privileged functions. Because Solidity abstracts gas from the developer, these vulnerabilities are easy to introduce and hard to spot in code review. This post covers six distinct gas griefing patterns, with vulnerable code, attack mechanics, and gas-safe fixes for each.
1. Unbounded Loop Over User-Controlled Array
The most common gas griefing vector is iterating over an array whose length is controlled, directly or indirectly, by external actors.
Vulnerable code:
// VULNERABLE: array grows unboundedly; owner loop will eventually OOG
contract RewardDistributor {
address[] public recipients;
function addRecipient(address r) external {
recipients.push(r); // any caller can extend this
}
function distributeRewards() external payable onlyOwner {
uint256 share = msg.value / recipients.length;
for (uint256 i = 0; i < recipients.length; i++) {
payable(recipients[i]).transfer(share);
}
}
}
An attacker registers thousands of addresses. When the owner calls distributeRewards, the loop costs roughly 2,100 gas per iteration for the CALL opcode plus transfer overhead. At 5,000 addresses the function exceeds typical block gas limits and reverts permanently, locking the distribution mechanism.
Fix — paginated pull pattern:
// SAFE: off-chain pagination + pull payments remove the loop entirely
contract RewardDistributor {
mapping(address => uint256) public claimable;
function allocate(address[] calldata accounts, uint256[] calldata amounts)
external onlyOwner
{
require(accounts.length == amounts.length, "length mismatch");
require(accounts.length <= 200, "batch too large"); // explicit cap
for (uint256 i = 0; i < accounts.length; i++) {
claimable[accounts[i]] += amounts[i];
}
}
function claim() external {
uint256 amount = claimable[msg.sender];
require(amount > 0, "nothing to claim");
claimable[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}
}
Pull payments mean each user pays for their own gas. Batching with a hard cap prevents a single transaction from becoming too expensive for any honest caller.
2. Return Bomb Attack on External Calls
When Solidity captures return data from an external call, the EVM charges memory expansion gas proportional to the returned byte count. A malicious callee can exploit this by returning an arbitrarily large payload.
Vulnerable code:
// VULNERABLE: unbounded returndata inflates memory costs
contract Aggregator {
function query(address target, bytes calldata data)
external returns (bytes memory result)
{
(bool ok, bytes memory ret) = target.call(data);
require(ok, "call failed");
return ret; // expands memory for every byte returned
}
}
A carefully crafted target returns 100 KB of zeros. Memory expansion cost follows a quadratic formula: memory_cost = (words^2 / 512) + (3 * words). At 100 KB (3,125 32-byte words) the expansion alone costs over 19,000 extra gas. At 500 KB it can exceed a full block gas limit. The caller's transaction reverts or becomes prohibitively expensive.
Fix — assembly-level returndata cap:
// SAFE: cap returndata read at 256 bytes; ignore the rest
contract Aggregator {
uint256 constant MAX_RETURN = 256;
function query(address target, bytes calldata data)
external returns (bytes memory result)
{
bool ok;
assembly {
ok := call(gas(), target, 0, add(data.offset, 0), data.length, 0, 0)
}
require(ok, "call failed");
uint256 size = returndatasize() < MAX_RETURN
? returndatasize()
: MAX_RETURN;
result = new bytes(size);
assembly {
returndatacopy(add(result, 32), 0, size)
}
}
}
Using call with zero return-size arguments in assembly avoids automatic memory expansion. returndatacopy is then used to copy only a bounded slice. The rest of the returned data is discarded safely.
3. 63/64 Rule Griefing (EIP-150)
EIP-150 introduced the rule that a CALL or DELEGATECALL forwards at most 63/64 of the available gas, retaining 1/64 for the calling context. When the retained fraction is too small, post-call logic in the caller fails even though the sub-call succeeded.
Vulnerable code:
// VULNERABLE: forwards all gas; post-call logic may get only 1/64
contract Executor {
function execute(address target, bytes calldata data) external {
(bool ok,) = target.call{gas: gasleft()}(data); // explicit all-gas forward
require(ok);
_settle(); // may OOG if target consumed nearly all forwarded gas
}
function _settle() internal {
// writes state, emits events — needs non-trivial gas
}
}
An attacker-controlled target deliberately burns the forwarded gas inside its fallback. Executor resumes with 1/64 of original gas — potentially a few hundred units — which is insufficient for _settle(). The transaction reverts after the sub-call has already executed side effects in other contracts, creating inconsistent state.
Fix — explicit minimum gas reservation:
// SAFE: reserve enough gas for post-call work before forwarding
contract Executor {
uint256 constant POST_CALL_GAS = 50_000;
function execute(address target, bytes calldata data) external {
require(gasleft() > POST_CALL_GAS + 10_000, "insufficient gas");
uint256 forwardGas = gasleft() - POST_CALL_GAS;
(bool ok,) = target.call{gas: forwardGas}(data);
require(ok);
_settle(); // guaranteed >= POST_CALL_GAS remaining
}
}
Calculate the gas needed for post-call logic, subtract it before forwarding, and reject the transaction early if total gas is insufficient.
4. Calldata Inflation Griefing
After EIP-2028 (Istanbul), non-zero calldata bytes cost 16 gas each and zero bytes cost 4 gas. In systems where fees are computed from calldata length — such as optimistic rollups or meta-transaction relayers — attackers can inflate calldata to raise costs or push transactions past block gas limits.
Vulnerable code:
// VULNERABLE: fee model trusts caller-supplied length
contract MetaTxRelayer {
function relay(
address target,
bytes calldata payload,
bytes calldata extraData // no restriction on size
) external {
uint256 fee = extraData.length * 16; // meant to cover calldata cost
_chargeRelayFee(fee);
(bool ok,) = target.call(payload);
require(ok);
}
}
An attacker appends megabytes of extraData. The fee calculation overflows the intended range, and the inflated transaction can push honest users' transactions out of the block by consuming most available gas.
Fix — enforce calldata size bounds:
// SAFE: hard cap on all variable-length inputs
contract MetaTxRelayer {
uint256 constant MAX_PAYLOAD = 4096;
uint256 constant MAX_EXTRA = 256;
function relay(
address target,
bytes calldata payload,
bytes calldata extraData
) external {
require(payload.length <= MAX_PAYLOAD, "payload too large");
require(extraData.length <= MAX_EXTRA, "extra too large");
uint256 fee = gasleft() / 10; // gas-proportional, not byte-count-based
_chargeRelayFee(fee);
(bool ok,) = target.call(payload);
require(ok);
}
}
Size limits on calldata inputs prevent inflation attacks. For fee models, prefer gas-proportional charges over byte-count charges, as gas accounting already reflects calldata costs at the protocol level.
5. Storage Write Griefing (Dirty Slot Attack)
Ethereum charges 20,000 gas (SSTORE) to write to a storage slot that holds zero (a cold zero slot) but only 5,000 gas to update a slot that already holds a non-zero value (a dirty slot). This asymmetry enables a subtle griefing attack in auction and refund patterns.
Vulnerable code:
// VULNERABLE: attacker pre-dirtying a slot makes victim's write expensive
contract Auction {
mapping(address => uint256) public bids;
function bid() external payable {
bids[msg.sender] = msg.value; // 20,000 gas on fresh slot
}
function refund(address bidder) external onlyOwner {
uint256 amount = bids[bidder];
bids[bidder] = 0; // victim pays 20,000 gas if attacker pre-set a value
payable(bidder).transfer(amount);
}
}
The attack is more subtle in patterns where the victim pays to clear a slot the attacker previously set. In contexts where gas costs affect economic incentives — liquidation bots, keeper networks — an attacker can increase the cost of a competitor's operation by pre-writing to shared storage slots.
Fix — use sentinel values and understand slot lifecycle:
// SAFE: use 1/2 sentinel instead of 0/value to avoid fresh-slot penalty
contract Auction {
uint256 constant EMPTY = 1;
mapping(address => uint256) public bids; // initialized to 0 by EVM
function bid() external payable {
require(msg.value > 0);
// Store value+1 as sentinel; slot stays non-zero after refund
bids[msg.sender] = msg.value + EMPTY;
}
function refund(address bidder) external onlyOwner {
uint256 stored = bids[bidder];
require(stored > EMPTY, "no bid");
bids[bidder] = EMPTY; // dirty-slot write: 5,000 gas, not 20,000
payable(bidder).transfer(stored - EMPTY);
}
}
Setting slots to 1 instead of 0 keeps them in the dirty state permanently, reducing subsequent SSTORE costs from 20,000 to 5,000 gas. This pattern is used in OpenZeppelin's ReentrancyGuard for the same reason.
6. Nested External Calls Exhausting Gas
Contracts that make multiple external calls to user-supplied addresses are vulnerable when one of those addresses registers a receive() or fallback that intentionally burns gas.
Vulnerable code:
// VULNERABLE: one gas-burning receiver reverts the entire settlement
contract Settlement {
address[] public parties;
function settleAll() external {
for (uint256 i = 0; i < parties.length; i++) {
// attacker's receive() burns gas via loop; subsequent calls OOG
(bool ok,) = parties[i].call{value: shares[i]}("");
require(ok, "transfer failed"); // revert propagates
}
}
}
An attacker registers a contract whose receive() runs an expensive loop consuming the forwarded gas. The entire settleAll transaction reverts, preventing any other party from receiving funds. If the attacker is party index 0, they block everyone else indefinitely.
Fix — track failures, forward bounded gas, do not revert on failure:
// SAFE: bounded gas forward + failure tracking prevents total DoS
contract Settlement {
address[] public parties;
mapping(address => uint256) public failedPayments;
uint256 constant TRANSFER_GAS = 2300; // enough for simple receives
function settleAll() external {
for (uint256 i = 0; i < parties.length; i++) {
address party = parties[i];
uint256 amount = shares[party];
if (amount == 0) continue;
shares[party] = 0;
(bool ok,) = party.call{value: amount, gas: TRANSFER_GAS}("");
if (!ok) {
// Record failure; party can retrieve via separate pull
failedPayments[party] += amount;
}
}
}
function retrieveFailed() external {
uint256 amount = failedPayments[msg.sender];
require(amount > 0, "nothing to retrieve");
failedPayments[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}
}
Forwarding a fixed gas stipend (2,300 gas — enough for a simple ETH receive but not for an expensive loop) neutralizes gas-burning receivers. Recording failures instead of reverting ensures that one bad actor cannot block the rest of settlement.
What ContractScan Detects
ContractScan performs static and semantic analysis of Solidity source to flag gas griefing patterns before deployment.
| Vulnerability | Detection Method | Severity |
|---|---|---|
| Unbounded loop over user-controlled array | Taint analysis traces external input to loop bounds | High |
| Return bomb on external calls | Flags (bool, bytes memory) captures from untrusted callees without size guard |
High |
| 63/64 rule griefing | Detects post-call state writes with no gas reservation before forwarding | Medium |
| Calldata inflation | Flags unrestricted bytes calldata inputs in fee-bearing or relay functions |
Medium |
| Dirty slot attack | Identifies SSTORE patterns that reset to zero in auction or refund contexts | Low |
| Nested external call gas exhaustion | Detects multi-call loops to user-supplied addresses with require(ok) propagation |
High |
Related Posts
- Reentrancy Attack Prevention in Solidity
- Denial of Service Attacks in Solidity: Gas Griefing and More
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.