What Is Gas Griefing?
A griefing attack means the attacker is trying to “cause grief” for other people, even if they don’t gain economically from doing so. In the context of smart contracts, gas griefing is a category of attack where an adversary manipulates how gas flows between contracts — either starving a critical sub-call of gas, forcing the caller to absorb enormous unexpected costs, or rendering a shared function permanently unusable by bloating state or computation.
A gas griefing attack could happen in a scenario where a smart contract receives data from external sources and utilises that data to make a further subcall to another contract. The external source sends sufficient gas to invoke the initial smart contract, but not enough to support the further subcall, causing failure of the subcall and the reverting of the whole transaction.
The economic asymmetry is what makes griefing dangerous. The attacker often spends very little — sometimes only a slightly-crafted transaction — while the victim contract, protocol, or honest relayer bears the full cost of failure.
1. The 63/64 Rule and Stipend Attacks
Background: EIP-150
The 63/64 rule was introduced with Ethereum Improvement Proposal (EIP) 150. This rule states that when one contract calls another, a part of the available gas (1/64) will remain in the calling contract, while the rest (63/64) will be forwarded.
The original motivation was preventing the “call depth attack,” where a malicious actor would push the call stack to its limit of 1024 frames and selectively revert the last call. This attack is no longer possible after EIP-150, because the forwarded gas (63/64) in each call is reduced exponentially the deeper the stack gets.
However, the rule introduced a new class of griefing surface. Even when explicitly forwarding all the gas left in a call, a fraction will still be reserved for the calling contract. This subtle reservation is the root of the 63/64 stipend attack.
How the Attack Works
Insufficient gas griefing attacks represent a subset of griefing attacks that primarily affect smart contracts performing external calls without checking the success return value. In such an attack, an adversary may supply just enough gas to ensure the top-level function’s success while ensuring the external call’s failure due to gas exhaustion. Owing to the 63/64 rule, the top-level contract can complete its function call, resulting in an incomplete state change.
The attack is particularly dangerous in relayer and meta-transaction architectures. Suppose a forwarder calls forward with minimal gas, sufficient only to allow the Relayer contract to succeed but causing the external call to revert due to an out-of-gas error. In that case, the user’s transaction is not executed, and their signature is invalidated. Consequently, the anticipated state change on the target contract will not occur. Malicious forwarders can exploit this technique to permanently censor user transactions within the Relayer contract.
Vulnerable Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// ❌ VULNERABLE: No gas check before forwarding
contract VulnerableRelayer {
mapping(bytes => bool) public executed;
function relay(address target, bytes memory data) external {
require(!executed[data], "Already executed");
executed[data] = true; // marked executed BEFORE the call
// Attacker supplies just enough gas for this line,
// but the external call silently runs out of gas.
(bool success, ) = target.call(data);
// success is ignored — griefing complete
}
}
In this case, the attack is not particularly beneficial to the attacker, but rather causes grief for the contract owner, as there will be a bunch of “executed” bytes that were not actually executed.
Mitigation: Require a Sufficient Gas Budget
There are two options to prevent insufficient gas griefing: only allow trusted users to relay transactions, or require that the forwarder provides enough gas.
The canonical formula accounts for the 63/64 retention. Before forwarding, compute whether gasleft() — after the base call overhead — multiplied by 63/64 is at least as large as the required sub-call budget.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// ✅ MITIGATED: Enforce a minimum gas budget before sub-call
contract SafeRelayer {
uint256 public constant REQUIRED_GAS = 100_000;
uint256 public constant GAS_OVERHEAD = 5_000; // bookkeeping cost
mapping(bytes32 => bool) public executed;
function relay(address target, bytes memory data) external {
bytes32 id = keccak256(data);
require(!executed[id], "Already executed");
// Verify the caller supplied enough gas for the sub-call
// accounting for the 1/64 retained by this frame
uint256 gasAvailable = gasleft() - GAS_OVERHEAD;
require(
gasAvailable - gasAvailable / 64 >= REQUIRED_GAS,
"Insufficient gas for sub-call"
);
executed[id] = true;
(bool success, bytes memory returnData) = target.call{gas: REQUIRED_GAS}(data);
require(success, "Sub-call failed");
}
}
It is essential to be aware of this rule if your protocol logic depends on gas calculations, especially if you are using the built-in Solidity gasleft() function.
2. Return Bomb Attacks via Large returndata
The Mechanism
When Solidity executes a low-level .call(), it automatically copies the return data from the callee’s output buffer into the caller’s memory. The bytes memory returnData that was returned from the target will be copied to memory. Memory allocation becomes very costly if the payload is big, so this means that if a target implements a fallback function that returns a huge payload, then the msg.sender of the transaction will have to pay a huge amount of gas for copying this payload to memory.
The critical insight is that this memory expansion cost is paid by the caller, not the callee. Even though the Victim contract has not explicitly requested bytes memory data to be returned, and has furthermore given the external call a gas stipend, Solidity will still invoke RETURNDATACOPY during the top-level call-frame. This means the Attacker contract, through revert or return, can force the Victim contract to consume unbounded gas during their own call-frame and not that of the Attacker.
Under Solidity’s implementation, up until at least 0.8.26, the entirety of this return data is automatically copied from the buffer into memory. This is true even when using a Solidity low-level call with the omission of the bytes memory data syntax.
Vulnerable Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// ❌ VULNERABLE: returndata is blindly copied to memory
contract VulnerableExecutor {
function execute(address target, bytes memory callData)
external
returns (bool success)
{
bytes memory returnData;
// If `target` returns megabytes of data, this call pays
// for all of that memory expansion — a return bomb.
(success, returnData) = target.call(callData);
}
}
// Attacker contract: returns a massive payload on any call
contract ReturnBomb {
fallback() external {
// Allocate and return 500 KB of zeros to exhaust
// the caller's gas budget during RETURNDATACOPY
assembly {
let size := 0x80000 // ~500 KB
return(0, size)
}
}
}
Mitigation: ExcessivelySafeCall / Assembly Cap
To address the issue and prevent potential griefing attacks, it is recommended to implement protection against return data bombs. Consider using ExcessivelySafeCall when interacting with untrusted contracts. This solution aims to safeguard against excessive gas costs incurred during memory allocation for large return data payloads.
To mitigate this griefing risk entirely, EigenLayer used a Yul call, where they limit the return data size to 1 word. If the external manager contract tries to return any more data than this, the excess of 32 bytes simply won’t be copied to memory.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// ✅ MITIGATED: Cap return data copy using inline assembly
library SafeCall {
/// @notice Calls `target` with `data`, but caps the returndata
/// copy at `maxReturnBytes` to prevent return bombs.
function safeCall(
address target,
uint256 gasLimit,
bytes memory data,
uint256 maxReturnBytes
) internal returns (bool success, bytes memory returnData) {
returnData = new bytes(maxReturnBytes);
assembly {
success := call(
gasLimit,
target,
0,
add(data, 0x20),
mload(data),
add(returnData, 0x20), // write into our pre-sized buffer
maxReturnBytes // hard cap on copy size
)
// Shrink returnData to actual size returned
let actualSize := returndatasize()
if lt(actualSize, maxReturnBytes) {
mstore(returnData, actualSize)
}
}
}
}
contract SafeExecutor {
using SafeCall for address;
uint256 private constant MAX_RETURN_BYTES = 256;
uint256 private constant SUB_CALL_GAS = 100_000;
function execute(address target, bytes memory callData)
external
returns (bool success)
{
(success, ) = target.safeCall(SUB_CALL_GAS, callData, MAX_RETURN_BYTES);
}
}
3. Griefing via Expensive Fallback Functions
The Mechanism
When a contract sends ETH to another address using .call{value: v}(""), the recipient’s fallback() or receive() function is triggered. A smart contract can maliciously use up all the gas forwarded to it by going into an infinite loop.
The old transfer() and send() primitives were considered “safe” because they only forwarded a 2,300-gas stipend. Generally, the fallback function has a maximum gas limit of 2300 units; if this function requires more expensive computational steps, an out-of-gas exception could be thrown. However, low-level .call() forwards nearly all available gas by default (63/64), making the recipient’s fallback far more dangerous.
Vulnerable Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// ❌ VULNERABLE: Iterates all addresses, each can burn forwarded gas
contract VulnerableDistributor {
address[] public recipients;
function addRecipient(address r) external {
recipients.push(r);
}
function distribute() external payable {
uint256 share = msg.value / recipients.length;
for (uint256 i = 0; i < recipients.length; i++) {
// `.call` forwards ~63/64 of remaining gas.
// A malicious fallback loops forever, draining it all.
(bool ok, ) = recipients[i].call{value: share}("");
require(ok, "Transfer failed");
}
}
}
// Attacker: expensive fallback that burns all forwarded gas
contract GriefingFallback {
uint256 private counter;
fallback() external payable {
// Tight loop consumes every forwarded gas unit
while (true) {
counter++;
}
}
}
Mitigation: Gas-Capped Calls + Pull Payments
The canonical mitigation is the pull payment pattern: do not push ETH, let recipients withdraw it. For architectures that must push payments, cap the gas forwarded to each call and tolerate failures gracefully.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// ✅ MITIGATED: Pull payment pattern + per-call gas cap
contract SafeDistributor {
mapping(address => uint256) public pendingWithdrawals;
// Instead of pushing, record entitlements
function allocate(address[] calldata recipients, uint256[] calldata amounts)
external
payable
{
require(recipients.length == amounts.length, "Length mismatch");
for (uint256 i = 0; i < recipients.length; i++) {
pendingWithdrawals[recipients[i]] += amounts[i];
}
}
// Each recipient pulls their own funds — they pay their own gas
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, gas: 2300}("");
require(ok, "Transfer failed");
}
}
4. Unbounded Array Iteration
The Mechanism
Unbounded loops in Solidity smart contracts can create significant vulnerabilities, one of the most notable being the ‘Out of Gas’ vulnerability. This issue arises when a loop runs for an indeterminate or excessively high number of iterations, consuming more gas than a block’s gas limit, and causing transactions to fail.
An attacker can deliberately grow an on-chain array — by repeatedly calling a permissionless function — until iterating it exceeds the block gas limit. An attacker submits thousands of small entries, inflating the array length. When the loop function is called, the loop’s gas cost exceeds the 30 million gas limit, causing the transaction to revert.
Unlike a single reverted transaction, a permanently unbounded array makes the function permanently unusable, a denial of service with no recovery path.
Vulnerable Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// ❌ VULNERABLE: Public append + unbounded loop
contract VulnerableVoting {
address[] public voters;
mapping(address => bool) public hasVoted;
// Anyone can register — array grows without bound
function register() external {
voters.push(msg.sender);
}
// Iterates the entire voter list — DoS when list is large
function countVoters() external view returns (uint256 count) {
for (uint256 i = 0; i < voters.length; i++) {
if (hasVoted[voters[i]]) count++;
}
}
// Even worse: state-writing loop with external calls
function refundAll(uint256 refundAmount) external {
for (uint256 i = 0; i < voters.length; i++) {
(bool ok, ) = voters[i].call{value: refundAmount}("");
require(ok, "Refund failed");
}
}
}
Mitigation: Pagination, Caps, and Off-Chain Aggregation
A MAX_ITEMS constant limits the number of items per user, preventing unbounded arrays. Batch processing functions process a fixed number of items per call, ensuring gas costs stay within the block limit.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// ✅ MITIGATED: Cap registration + paginated processing
contract SafeVoting {
address[] public voters;
mapping(address => bool) public registered;
uint256 public constant MAX_VOTERS = 10_000;
function register() external {
require(voters.length < MAX_VOTERS, "Registration full");
require(!registered[msg.sender], "Already registered");
registered[msg.sender] = true;
voters.push(msg.sender);
}
// Paginated: caller controls the window, gas stays bounded
function countVotersInRange(uint256 start, uint256 end)
external
view
returns (uint256 count)
{
require(end <= voters.length, "Out of range");
for (uint256 i = start; i < end; i++) {
if (registered[voters[i]]) count++;
}
}
// Paginated refund — process in safe-sized batches
function refundBatch(
uint256 start,
uint256 batchSize,
uint256 refundAmount
) external {
uint256 end = start + batchSize;
if (end > voters.length) end = voters.length;
for (uint256 i = start; i < end; i++) {
(bool ok, ) = voters[i].call{value: refundAmount, gas: 2300}("");
// Tolerate individual failures; do not revert the whole batch
if (!ok) emit RefundFailed(voters[i]);
}
}
event RefundFailed(address indexed voter);
}
A more extreme approach to mitigate state bloat is to store just a 32-byte Merkle root on the blockchain. A transaction’s caller is responsible for providing appropriate values and proofs for any data that the transaction needs during execution. Smart contracts can verify that the proof is correct but do not need to store any of that information persistently on-chain — only one 32-byte root is required to be kept and updated.
5. The Cost of Calling Non-Existent Contracts
The Mechanism
A subtle gas griefing vector arises when a contract makes a low-level .call() to an address that holds no bytecode (i.e., an EOA or a not-yet-deployed contract). The Solidity compiler, prior to version 0.8.10, inserted an EXTCODESIZE check before every high-level external call to verify that the target is a contract — and that check is not free.
EIP-150 increased the gas cost of EXTCODESIZE to 700 (from 20). After EIP-2929 (Berlin hard fork), cold account access costs 2,600 gas. This means:
- The check itself is expensive — 2,600 gas for a cold address.
- The call succeeds silently —
.call()to a zero-code address returns(true, ""). If the contract checkssuccess == trueand trusts it, the logic assumes a callback or state change happened that never did. - Griefing by contract self-destruct — if a callee
selfdestructs between when an address was validated and when a subsequent call is made, the next call hits an empty account.
Using a Solidity version of at least 0.8.10 allows external calls to skip contract existence checks if the external call has a return value. Below that version, high-level calls always pay for the existence check.
Vulnerable Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface ICallback {
function onComplete(uint256 id) external;
}
// ❌ VULNERABLE: Trusts that the target is a live contract
contract VulnerableAuction {
mapping(uint256 => address) public winners;
function claimPrize(uint256 auctionId) external {
address winner = winners[auctionId];
require(winner != address(0), "No winner");
// If `winner` is an EOA or a self-destructed contract,
// `.call()` returns (true, ""), and the prize is "sent"
// into the void — funds are permanently lost.
(bool ok, ) = winner.call{value: 1 ether}("");
require(ok, "Transfer failed");
// Callback to a potentially non-existent contract:
// no revert, no callback executed, state is corrupted.
ICallback(winner).onComplete(auctionId);
}
}
Mitigation: Check Code Size and Validate Return Data
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// ✅ MITIGATED: Validate target has code before calling
library ContractGuard {
error NotAContract(address target);
function requireContract(address target) internal view {
uint256 size;
assembly { size := extcodesize(target) }
if (size == 0) revert NotAContract(target);
}
}
contract SafeAuction {
using ContractGuard for address;
mapping(uint256 => address) public winners;
function claimPrize(uint256 auctionId) external {
address winner = winners[auctionId];
require(winner != address(0), "No winner");
// Verify target is a live contract
winner.requireContract();
(bool ok, ) = winner.call{value: 1 ether}("");
require(ok, "ETH transfer failed");
}
}
6. Storage Bloat as a Griefing Vector
The Mechanism
Storage is the most expensive persistent resource in the EVM. Writing to a previously-zero slot costs 20,000 gas (cold SSTORE). Functions that allow arbitrary parties to append to a shared on-chain data structure — mappings of arrays, linked lists, logs — can be weaponized to bloat storage until critical functions that must iterate those structures hit the block gas limit.
The userItems mapping allows unbounded array growth, and processItems iterates over the entire array in a single transaction. Attackers can exploit this to make the contract unusable, preventing item processing and locking users out of critical functions.
Storage bloat is permanent — unlike a reverted transaction, once storage is written, its gas cost is sunk. An attacker can spread the write cost across thousands of cheap transactions, each contributing slightly to the bloat, while the victim’s aggregation function eventually becomes permanently bricked.
Vulnerable Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// ❌ VULNERABLE: Unbounded per-user storage + mandatory full scan
contract VulnerableMarket {
mapping(address => uint256[]) public listings;
// Anyone can append — attacker inflates their own array cheaply
function list(uint256 itemId) external {
listings[msg.sender].push(itemId);
}
// Protocol must iterate the seller's full array to process orders
// — griefed seller's listings can never be processed
function processAllListings(address seller) external {
uint256[] storage items = listings[seller];
for (uint256 i = 0; i < items.length; i++) {
_processItem(seller, items[i]); // expensive per-item logic
}
}
function _processItem(address seller, uint256 itemId) internal { /* ... */ }
}
Mitigation: Caps, Pagination, and Epoch-Based Pruning
To address these vulnerabilities, the Solodit checklist recommends capping state growth, processing data in batches, and minimizing external calls.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// ✅ MITIGATED: Per-user caps + explicit pruning
contract SafeMarket {
uint256 public constant MAX_LISTINGS_PER_USER = 50;
mapping(address => uint256[]) private _listings;
mapping(address => uint256) public listingCount;
error ListingCapExceeded();
error NothingToProcess();
function list(uint256 itemId) external {
if (listingCount[msg.sender] >= MAX_LISTINGS_PER_USER)
revert ListingCapExceeded();
_listings[msg.sender].push(itemId);
listingCount[msg.sender]++;
}
// Paginated processing — caller decides batch size
function processListingsBatch(
address seller,
uint256 startIndex,
uint256 batchSize
) external {
uint256[] storage items = _listings[seller];
uint256 end = startIndex + batchSize;
if (end > items.length) end = items.length;
if (startIndex >= items.length) revert NothingToProcess();
for (uint256 i = startIndex; i < end; i++) {
_processItem(seller, items[i]);
}
}
// Explicit pruning to reclaim storage and gas refunds
function clearProcessed(address seller) external {
delete _listings[seller];
listingCount[seller] = 0;
}
function _processItem(address seller, uint256 itemId) internal { /* ... */ }
}
A state clearing function allows admins to clear processed items in batches, reducing storage costs and preventing bloat. Additionally, consider using a Merkle-tree-based approach for large datasets: only one 32-byte root is required to be kept and updated, while callers provide proofs off-chain.
7. Gas Griefing in Cross-Chain Message Passing
Why Cross-Chain Messaging Amplifies Gas Griefing
Cross-chain protocols are inherently asynchronous — a message is initiated on a source chain, relayed by off-chain infrastructure, and executed on a destination chain. Cross-chain protocols are complex systems composed of numerous components spread across different networks and execution environments. They involve various security considerations, assumptions, and trade-offs, as well as the coordination of entities with different incentive and trust models.
This asynchrony creates a critical gas griefing surface:
- Gas is priced on the source chain in the source chain’s native token.
- Execution happens on the destination chain where gas prices, opcodes, and block limits differ.
- The message payload content is known by the sender, who can craft it to maximize destination gas consumption.
- Failed destination execution can strand funds — assets locked on the source with no minted equivalent on the destination.
The Receiver-Side Gas Estimation Problem
The gasLimit is set based on the cost of calling handle on the destination chain for a given message. This can vary depending on message content and the logic of the handler. The default gasLimit for metering the handle call is a static default of 50,000 gas. This is sufficient for simple operations but may not be enough for more complex handle functions. If your handle function performs complex operations or requires more gas, you must override the default gasLimit in metadata to avoid transaction reverts.
A griefing attacker who can influence the message payload — for example, by crafting a large calldata blob or embedding data that causes the destination handler to iterate storage — can force the relayer’s execution to consume more gas than was quoted and paid for, causing the delivery to fail.
If the handle call consumes more gas than quoted, the relayer will not submit a process transaction. This issue often occurs due to insufficient gas payment during the initial dispatch.
Vulnerable Cross-Chain Receiver
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// Simulates a LayerZero-style OApp receiver
// ❌ VULNERABLE: Handler logic gas scales with payload size
contract VulnerableReceiver {
mapping(bytes32 => bool) public processed;
uint256[] public processedItems;
// Called by the bridge/relayer on the destination chain
function lzReceive(
uint16 /* srcChainId */,
bytes memory /* srcAddress */,
uint64 /* nonce */,
bytes memory payload
) external {
// Decode an arbitrary-length array from the payload
uint256[] memory items = abi.decode(payload, (uint256[]));
// ❌ Iterates the attacker-controlled array
// Attacker sends a 10,000-element array — handler OOG
for (uint256 i = 0; i < items.length; i++) {
processedItems.push(items[i]);
}
}
}
In this pattern, an attacker sends a message on the source chain (a cheap transaction) with a payload encoding a massive array. The gas estimation done at dispatch time uses a small test payload. When the real message arrives on the destination chain, the handler runs out of gas, the message is marked failed, and depending on the protocol, source-chain funds may be stuck.
Mitigation: Payload Size Caps + Gas-Aware Handlers
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// ✅ MITIGATED: Bounded payload + gas-checked handler
contract SafeReceiver {
uint256 public constant MAX_ITEMS_PER_MESSAGE = 20;
uint256 public constant MIN_GAS_FOR_HANDLE = 80_000;
mapping(bytes32 => bool) public processed;
error PayloadTooLarge(uint256 count, uint256 max);
error InsufficientGasForHandle(uint256 gasLeft, uint256 required);
function lzReceive(
uint16 srcChainId,
bytes memory srcAddress,
uint64 nonce,
bytes memory payload
) external {
// Enforce minimum gas remaining so we can finish safely
if (gasleft() < MIN_GAS_FOR_HANDLE)
revert InsufficientGasForHandle(gasleft(), MIN_GAS_FOR_HANDLE);
uint256[] memory items = abi.decode(payload, (uint256[]));
// Enforce payload size cap — revert early, before any state change
if (items.length > MAX_ITEMS_PER