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
- Relayers / meta-transactions — you call user-supplied targets.
- Multicall routers and batch executors — one entry in the batch is attacker-controlled.
- Keeper / automation networks — jobs are external and untrusted.
- Cross-chain message receivers — the payload handler is often arbitrary.
- "Distribute to many" loops — token transfers to addresses that may be contracts.
If your contract calls addresses it does not fully control and then copies the result, assume a return bomb is possible.
Pre-deployment checklist
- [ ] Every call to an untrusted target uses a bounded-returndata pattern (e.g.,
ExcessivelySafeCall) rather than high-level calls or baretry/catch. - [ ] Loops over external calls reserve enough gas per iteration that one callee cannot starve the rest (account for the 63/64 rule).
- [ ] You never
abi.decodeattacker-controlled returndata without first bounding its size. - [ ] Post-call accounting (state writes, event emits) is guaranteed enough gas to complete.
- [ ] Tests include a malicious target that returns oversized returndata and one that consumes nearly all forwarded gas.
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.