← Back to Blog

Gas Griefing and Return Bombs: How a Callee Can Wreck Your External Calls

2026-06-23 gas griefing return bomb returndata external call try-catch relayer solidity security 2026

Most Solidity developers worry about what happens to their contract when an external call fails. Fewer think about the opposite direction: when you call an untrusted contract, the callee controls the returndata — and that returndata is copied into your memory before you ever get to inspect it. A hostile callee can use this to burn your entire gas budget or force your "safe" try/catch to revert. This is the family of bugs known as return bombs and gas griefing, and they show up constantly in relayers, multicall routers, keeper bots, and cross-chain message handlers.

This post explains the mechanism, shows a working grief, and gives the standard fix.


The mechanism: returndata is copied before you can react

When contract A does target.call(data), the EVM runs target, and on return it places the callee's returndata in a buffer. In Solidity, the high-level call and try/catch then copy that returndata into memory so it can be decoded. Memory expansion is priced quadratically, so copying a very large returndata blob costs a lot of gas — gas that the caller pays, not the callee.

The callee decides how big that blob is. That is the entire attack surface.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

// Malicious callee: returns a massive blob to grief the caller.
contract ReturnBomber {
    fallback() external {
        assembly {
            // expand memory to a huge size and return all of it
            let size := 0x1000000        // ~16 MB of returndata
            return(0, size)
        }
    }
}

Any caller that copies this returndata into memory will spend an enormous amount of gas on memory expansion — potentially the whole transaction's gas — even though the call "succeeded."


Exploit: bricking a try/catch batch

A classic safe-looking relayer iterates over targets and uses try/catch so one bad target does not abort the batch:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IJob { function run() external; }

contract Relayer {
    // intent: tolerate individual job failures, keep going
    function runAll(address[] calldata jobs) external {
        for (uint256 i; i < jobs.length; ++i) {
            try IJob(jobs[i]).run() {} catch {
                // swallow failure and continue
            }
        }
    }
}

The intent is graceful degradation. But try/catch copies the callee's returndata (or revert data) into memory. A ReturnBomber placed in the jobs array returns a 16 MB blob; copying it explodes gas usage. Either the whole runAll transaction runs out of gas and reverts — defeating the "keep going" design — or, if the caller forwarded a bounded gas amount, the catch block itself fails. One malicious job griefs the entire batch. The same pattern breaks keeper networks, airdrop distributors, and "claim for many users" functions.

A second variant is the 63/64 gas grief: when you call with a forwarded gas limit, the EVM keeps 1/64 of the remaining gas (EIP-150). If your code calls an untrusted target in a loop without reserving enough gas, the callee can consume almost everything and leave the caller unable to finish its own post-call logic (e.g., recording state), corrupting accounting.


The fix: bound the returndata you copy

The standard mitigation is to make the low-level call yourself and refuse to copy more than N bytes of returndata. This is exactly what LayerZero's widely-used ExcessivelySafeCall library does. The core idea in assembly:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

library SafeReturnCall {
    /// @notice Calls `target` but copies at most `maxCopy` bytes of returndata.
    function safeCall(
        address target,
        uint256 gasLimit,
        uint16 maxCopy,
        bytes memory payload
    ) internal returns (bool success, bytes memory returnData) {
        returnData = new bytes(maxCopy);
        assembly {
            success := call(
                gasLimit,
                target,
                0,
                add(payload, 0x20),
                mload(payload),
                0,
                0
            )
            // copy only min(returndatasize, maxCopy) bytes
            let toCopy := returndatasize()
            if gt(toCopy, maxCopy) { toCopy := maxCopy }
            mstore(returnData, toCopy)
            returndatacopy(add(returnData, 0x20), 0, toCopy)
        }
    }
}

Because we never returndatacopy more than maxCopy bytes, a 16 MB return bomb costs the same as a tiny one. The relayer becomes:

function runAll(address[] calldata jobs) external {
    bytes memory data = abi.encodeWithSignature("run()");
    for (uint256 i; i < jobs.length; ++i) {
        // forward a bounded gas amount, copy at most 32 bytes back
        SafeReturnCall.safeCall(jobs[i], gasleft() / (jobs.length - i + 1), 32, data);
        // ignore success/returnData: one bad job cannot grief the batch
    }
}

Two defenses combined: bounded returndata (kills the return bomb) and a per-iteration gas budget so a single job cannot drain the loop (mitigates the 63/64 grief).


When this matters most

If your contract calls addresses it does not fully control and then copies the result, assume a return bomb is possible.


Pre-deployment checklist


FAQ

Does try/catch protect me from a malicious callee?
Not from a return bomb. try/catch copies the callee's return/revert data into memory before your catch block runs, so oversized returndata can exhaust gas regardless of the catch.

Is this only an issue for low-level call?
No. High-level calls and try/catch are more exposed because they auto-copy and decode returndata. Bounded low-level calls are the fix, not the cause.

What is a safe maxCopy value?
Copy only what you actually need to decode. For a boolean or a single uint256, 32 bytes is enough. If you do not use the return value, copy 0.

Does this apply to plain ERC-20 transfers in a loop?
Yes — a token address (or a fake token) in a distribution loop can be a contract that return-bombs or grief-burns gas. Bound the call and budget gas per iteration.


Return bombs and gas griefing are easy to miss because the vulnerable code looks defensive — it already uses try/catch or checks the success flag. The flaw is in copying attacker-sized returndata at all. Bound it, budget your gas, and one hostile callee can no longer take down your batch.


Calling untrusted contracts in a loop or relayer? ContractScan flags unbounded returndata copies and gas-griefing patterns automatically — free QuickScan, no signup, with fix guidance.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →