Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0xF3 |
| Mnemonic | RETURN |
| Gas | 0 + memory expansion cost |
| Stack Input | offset, length |
| Stack Output | (none — halts execution) |
| Behavior | Halts execution of the current call frame and returns length bytes of data from memory starting at offset to the caller. In a normal call context, the returned data populates the caller’s return data buffer (accessible via RETURNDATASIZE and RETURNDATACOPY). In a CREATE/CREATE2 context, the returned data becomes the deployed runtime bytecode stored at the new contract’s address. Memory expansion gas is charged if offset + length exceeds the current memory size. Unlike STOP (which returns no data) and REVERT (which reverts state changes), RETURN preserves all state changes and provides data to the caller. |
Threat Surface
RETURN is the EVM’s primary mechanism for both providing call results and defining deployed contract code. This dual role — data delivery channel in call context, code deployment gate in creation context — makes it one of the most security-critical opcodes in the EVM. Every contract deployment on Ethereum flows through RETURN, and every external call’s result is shaped by it.
The threat surface centers on four properties:
-
RETURN in CREATE context is the code deployment gate. When initcode executes RETURN, the returned memory region becomes the permanent runtime bytecode stored on-chain at the new contract’s address. The deployer’s initcode has total freedom over what bytes are returned — there is no semantic validation of the bytecode, no opcode legality checks, and (prior to EIP-3541) no byte-level filtering. This means initcode can deploy arbitrary bytecode, including sequences that look benign but contain hidden functionality, bytecode that doesn’t correspond to any known Solidity source, or code that exploits callers who assume deployed contracts match verified source. The only post-London restriction is EIP-3541’s rejection of bytecode starting with
0xEF(reserved for the future EOF format). -
Return data can be arbitrarily large (return bomb vector). RETURN accepts any
lengthvalue, and the callee chooses how much data to return. When the caller receives this data, Solidity automatically copies all of it into the caller’s memory via RETURNDATACOPY. Memory expansion cost is quadratic (words^2 / 512 + 3 * words), so a malicious callee returning megabytes of data can force the caller to spend enormous gas on memory expansion — potentially exceeding the caller’s remaining gas and reverting the entire transaction. Combined with the 63/64 gas forwarding rule, this creates the “return bomb” attack where the callee spends the forwarded 63/64 gas producing massive return data, and the caller’s reserved 1/64 gas cannot pay for the memory expansion triggered by the automatic copy. -
RETURN vs. STOP creates semantic divergence. STOP halts execution with no return data (the return data buffer is empty,
RETURNDATASIZE = 0). RETURN halts execution with explicit return data. Contracts that branch on whether a subcall returned data (checkingRETURNDATASIZE > 0) can behave differently depending on whether the callee used RETURN or STOP to terminate. This is the root cause of the non-standard ERC-20 class of bugs: tokens like USDT use STOP (returning nothing) instead of RETURN with abool, causing Solidity’s ABI decoder to revert when it expects 32 bytes of return data. -
Memory expansion gas is caller-visible but callee-determined. The gas cost of RETURN itself is zero, but the memory expansion needed to stage the return data is paid by the executing call frame. In CREATE context, large return data means large deployed bytecode, with the additional per-byte deployment cost (200 gas per byte via EIP-170’s successor cost model). In call context, the caller pays memory expansion for receiving the data (via RETURNDATACOPY), not the callee. This asymmetry means the callee controls a cost borne by the caller.
Smart Contract Threats
T1: Code Injection via RETURN in CREATE Context (Critical)
When a contract is deployed via CREATE or CREATE2, the initcode runs in a temporary execution context and calls RETURN to specify the runtime bytecode. Whoever controls the initcode controls the deployed code. This creates a powerful code injection surface:
-
Metamorphic contract attacks. An attacker deploys initcode via CREATE2 that RETURNs benign bytecode. After the contract passes audits and receives approvals/trust, the attacker calls SELFDESTRUCT (pre-Cancun) to wipe the contract, then redeploys different initcode at the same address (same CREATE2 salt, same init code hash if using a proxy pattern) that RETURNs malicious bytecode. The contract address is unchanged, but the deployed code is entirely different. The Tornado Cash governance attack (May 2023, ~$2.17M stolen) used exactly this pattern: a proposal contract was deployed, voted on, then self-destructed and redeployed with malicious code that minted 1.2 million governance tokens.
-
Initcode that computes bytecode dynamically. Initcode is not limited to returning a static bytecode blob. It can read from storage, call other contracts, compute bytecode at runtime, and RETURN any arbitrary sequence. This means the deployed bytecode is not necessarily inspectable from the creation transaction’s calldata alone. A factory contract that runs
CREATE(0, ptr, len)whereptrpoints to dynamically generated initcode can deploy code that has never appeared on-chain before. -
Hidden functionality in deployed bytecode. Since RETURN writes raw bytes as contract code, the deployed bytecode doesn’t need to match any known Solidity compilation output. An attacker can deploy hand-crafted bytecode that includes hidden functions, backdoor selectors, or opcode sequences that bypass common static analysis patterns. Etherscan verification can be gamed by exploiting metadata region handling (as demonstrated by samczsun’s “Hiding in Plain Sight” research).
-
Post-Cancun mitigation. EIP-6780 (Cancun, March 2024) restricts SELFDESTRUCT to only delete contract code when called in the same transaction as the contract’s creation. This effectively kills the metamorphic contract attack vector for contracts that survive past their creation transaction. However, contracts created and self-destructed within a single transaction can still be redeployed with different code.
Why it matters: The RETURN opcode is the final gate between initcode execution and permanent on-chain code storage. Any vulnerability in how initcode is constructed, verified, or trusted propagates directly into the deployed contract’s behavior.
T2: Return Data Bomb — Caller Gas Exhaustion (Critical)
A malicious callee can RETURN an extremely large data payload, forcing the caller to exhaust gas on memory expansion when the return data is copied:
-
The 63/64 gas asymmetry. When contract A calls contract B, A forwards 63/64 of available gas to B. B uses most of this gas to write a massive memory region, then executes
RETURN(0, HUGE_LENGTH). When execution returns to A, Solidity’s generated code calls RETURNDATACOPY to copy the entire return payload into A’s memory. The memory expansion in A’s context costs quadratic gas, but A only has 1/64 of the original gas remaining — far insufficient for megabytes of memory expansion. -
Blocking critical protocol operations. If the callee address is configurable (callback hooks, oracle addresses, delegation targets, Safe Saviour contracts), an attacker who controls it can permanently DoS protocol operations like liquidations, unstaking, or governance execution. Every transaction that touches the malicious callee reverts with out-of-gas.
-
Catch blocks are equally vulnerable. Solidity’s
try/catchwithcatch (bytes memory reason)triggers RETURNDATACOPY for the full revert payload. An attacker can use REVERT instead of RETURN with the same massive payload, and the catch block’s memory expansion still exhausts the caller’s gas.
Why it matters: Return bombs can make positions unliquidatable (protocol insolvency), lock staked funds (permanent DoS), or block governance execution. The RAI LiquidationEngine vulnerability and EigenLayer DelegationManager griefing both exploited this exact pattern.
T3: Memory Expansion Gas Griefing via Large Returns (High)
Even without a full return bomb (where the caller reverts OOG), large return data imposes significant gas costs on the caller:
-
Quadratic memory cost scaling. Memory expansion follows
words^2 / 512 + 3 * words. At 1 KB of return data (32 words), cost is ~98 gas. At 32 KB (1024 words), cost is ~5,120 gas. At 1 MB (32,768 words), cost is ~2.1M gas. A callee returning 1 MB of data costs the caller over 2 million gas in memory expansion alone. -
Gas estimation inaccuracy. Gas estimators (eth_estimateGas) simulate transactions to predict gas usage. If a callee’s return data size varies based on state (e.g., returning more data under certain conditions), gas estimates can be too low for actual execution, causing transactions to fail unexpectedly.
-
Relayer and meta-transaction exploitation. In gasless transaction systems where a relayer pays gas on behalf of users, a malicious callee can inflate gas costs via large return data. The relayer bears the cost, and repeated exploitation can drain the relayer’s gas budget.
Why it matters: Any contract that calls untrusted addresses and processes return data is exposed to gas inflation. The cost asymmetry — callee chooses return size, caller pays memory expansion — makes this a systemic risk in DeFi composability.
T4: STOP vs. RETURN Semantic Inconsistency (High)
STOP and RETURN both halt execution successfully, but differ in return data behavior, creating integration vulnerabilities:
-
Non-standard ERC-20 tokens. The ERC-20 specification requires
transfer()to returnbool. Tokens compiled with older Solidity versions or hand-crafted bytecode (USDT, BNB, OMG) use STOP instead of RETURN at the end oftransfer(), producing zero return data. Solidity’s ABI decoder expects 32 bytes and reverts when RETURNDATACOPY reads past the empty buffer. This is the “missing return value” bug class that necessitated OpenZeppelin’s SafeERC20. -
Assembly-level assumptions. Low-level assembly code that checks
returndatasize()after a call to determine success/failure may misinterpret a STOP-terminated successful call (0 bytes returned) as a failed or empty-result call. The distinction between “returned nothing” (STOP) and “returned zero” (RETURN with 32 bytes encodingfalse) is critical for correctness. -
Proxy pattern confusion. In DELEGATECALL-based proxy patterns, if the implementation function terminates with STOP instead of RETURN, the proxy receives no return data to forward to the original caller, potentially breaking return value expectations.
Why it matters: The STOP/RETURN divergence has caused integration failures worth hundreds of millions of dollars in aggregate across DeFi protocols, particularly when interacting with high-value non-standard tokens like USDT ($100B+ market cap).
T5: EIP-3541 Bytecode Prefix Validation Bypass Considerations (Medium)
EIP-3541 (London hard fork, August 2021) rejects new contract creation if the bytecode returned by RETURN starts with the 0xEF byte. This is a forward-compatibility measure for the Ethereum Object Format (EOF):
-
Validation occurs after RETURN. The
0xEFcheck happens after initcode execution completes and RETURN provides the bytecode. All gas for initcode execution is consumed even if the deployment fails the0xEFcheck. This means a contract that dynamically computes bytecode might unknowingly produce a0xEF-prefixed result and waste all deployment gas. -
Pre-London contracts are exempt. Contracts deployed before the London fork that happen to start with
0xEFcontinue to execute normally. The restriction only applies to new deployments, creating a two-class system where legacy0xEF-prefixed contracts exist but cannot be replicated. -
No other byte-level validation. EIP-3541 only checks the first byte. There is no validation that the returned bytecode contains valid opcodes, properly terminates, or conforms to any structural requirements. This is by design — the EVM treats deployed code as an opaque byte sequence — but it means RETURN can deploy any arbitrary bytes (except
0xEF-prefixed) as “code.” -
EIP-170 code size limit. The maximum contract code size is 24,576 bytes (EIP-170, Spurious Dragon). If RETURN provides more bytes, the deployment fails. This is the only size constraint on deployed bytecode and serves as a natural cap on code injection payload size.
Why it matters: EIP-3541 is the only bytecode-level validation between RETURN and permanent on-chain storage. Understanding its narrow scope (first byte only, new deployments only) is essential for assessing the actual constraints on deployed code.
Protocol-Level Threats
P1: Quadratic Memory Pricing Creates Asymmetric Gas Costs (Medium)
The EVM’s memory pricing model (words^2 / 512 + 3 * words) makes large RETURN payloads disproportionately expensive for the receiver:
-
Cross-frame cost asymmetry. When a callee executes RETURN with a large memory region, the callee pays the memory expansion to stage the data. But when the caller copies this data (via Solidity’s automatic RETURNDATACOPY), the caller pays a separate memory expansion in its own call frame. Both parties pay quadratic costs, but the caller doesn’t choose the data size.
-
Block gas limit implications. A single RETURN with ~1 MB of data would require roughly 2M gas for memory expansion in the callee’s frame, plus additional gas in the caller’s frame. While this is within the 30M block gas limit, it represents a significant fraction of block capacity consumed by a single operation. In account abstraction bundler contexts, a single malicious UserOperation with a large return could consume disproportionate block space.
-
Proposed memory repricing. Vitalik Buterin has proposed adjusting EVM memory gas costs (notes.ethereum.org) to better reflect actual node resource consumption. Any repricing of memory costs directly affects the economics of RETURN-based attacks.
P2: CREATE-Context RETURN and Code Storage Economics (Low)
When RETURN provides runtime bytecode in a CREATE context, the bytecode is permanently stored on the Ethereum state trie. Each byte costs 200 gas to deploy (per the execution specs), and the data persists indefinitely (post-EIP-6780, SELFDESTRUCT no longer clears code except in the creation transaction):
-
State bloat via deployment. Large contracts (up to the 24,576-byte EIP-170 limit) permanently consume state. There is no rent mechanism to reclaim storage occupied by deployed bytecode, though this is a general protocol concern rather than RETURN-specific.
-
EIP-170 as a security boundary. The 24,576-byte code size limit, enforced at the point where RETURN provides bytecode, prevents unbounded state growth from a single deployment. Without this limit, an attacker could deploy arbitrarily large contracts to bloat state.
Edge Cases
| Edge Case | Behavior | Security Implication |
|---|---|---|
| RETURN in CREATE context | Returned bytes become the deployed runtime bytecode at the new contract address | The deployer’s initcode has full control over what code is stored on-chain. No semantic validation of the bytecode occurs (only the 0xEF prefix check per EIP-3541). |
RETURN with length = 0 | Halts execution and returns empty data to caller. In CREATE context, deploys a contract with empty bytecode. | Empty-bytecode contracts accept ETH (no code to reject it) but all calls to them succeed with empty return data. Can be used to create “ETH black holes” or as placeholders. |
RETURN with length = 0 in CREATE context | Deploys an empty contract (zero-length runtime code) | EXTCODESIZE returns 0, making the address indistinguishable from an EOA for isContract() checks. Calls to the address succeed with no effect. |
| Very large return data (call context) | Caller receives large return data buffer; RETURNDATACOPY triggers quadratic memory expansion | Return bomb attack vector: if caller’s remaining gas is insufficient for memory expansion, the transaction reverts OOG. |
| RETURN vs. STOP | STOP halts with empty return data (RETURNDATASIZE = 0). RETURN halts with explicit return data (can be zero-length). | Callers checking RETURNDATASIZE > 0 to detect return data presence behave differently for STOP vs. RETURN(0, 0). Non-standard ERC-20 tokens that use STOP break Solidity’s ABI decoder. |
| RETURN vs. REVERT | Both provide data to the caller, but REVERT rolls back all state changes in the current call frame | Callers must check the success flag from CALL before interpreting return data; the data format may differ (ABI-encoded result vs. error selector + message). |
offset + length overflows uint256 | Memory expansion to a ludicrous size would cost more gas than exists; transaction reverts OOG | Overflow in memory offset calculation causes an immediate OOG revert; not exploitable. |
Bytecode returned starts with 0xEF (post-London) | CREATE/CREATE2 fails; contract is not deployed; all gas for initcode execution is consumed (EIP-3541) | Initcode that dynamically computes bytecode must ensure the first byte is not 0xEF, or the deployment silently fails after consuming all gas. |
| Bytecode exceeds 24,576 bytes (EIP-170) | CREATE/CREATE2 fails; contract is not deployed | Initcode returning more than 24,576 bytes via RETURN wastes all deployment gas. The code size limit caps the maximum payload for code injection attacks. |
| RETURN in STATICCALL context | Permitted; RETURN itself is not state-modifying | Return data is provided to the caller normally. No special restrictions in static context. |
| RETURN in DELEGATECALL context | Returns data to the delegating contract’s caller, not to the delegate’s caller | The delegating contract’s return data buffer is populated with the delegate’s RETURN data. Proxy patterns rely on this behavior. |
Real-World Exploits
Exploit 1: Tornado Cash Governance Attack — Metamorphic Contract Code Injection (~$2.17M, May 2023)
Root cause: RETURN in CREATE2 context was used to deploy benign code, which was then self-destructed and redeployed with malicious code at the same address, exploiting the governance system’s trust in the contract address.
Details: On May 20, 2023, an attacker submitted Proposal #20 to Tornado Cash governance. The proposal contract appeared identical to the previously approved Proposal #16, and community voters approved it. However, the proposal contained hidden SELFDESTRUCT logic. After the proposal passed:
- The attacker called SELFDESTRUCT on the proposal contract, clearing its code and resetting its nonce.
- Using CREATE2 with the same salt and factory, the attacker redeployed new initcode at the same address. The new initcode’s RETURN provided entirely different runtime bytecode — malicious code that called
emergencyExecute()on the Tornado Cash Governance contract. - The malicious code used DELEGATECALL from the governance contract to mint 10,000 TORN tokens to each of 120 attacker-controlled addresses, creating 1.2 million fraudulent votes.
- With only ~70,000 legitimate votes in circulation, the attacker gained total governance control and drained the governance vaults of ~483,000 TORN ($2,173,500).
RETURN’s role: RETURN is the mechanism by which initcode specifies deployed bytecode. The attack hinged on deploying different bytecode via RETURN at the same address across two deployments. The first RETURN provided benign code (to pass voter inspection); the second RETURN provided malicious code (to exploit governance). Without RETURN’s role as the code deployment gate, the metamorphic pattern would be impossible.
Impact: ~$2.17M stolen. Complete governance takeover. Demonstrated that contract address identity provides zero code integrity guarantees when CREATE2 + SELFDESTRUCT is available (pre-Cancun).
References:
- Halborn: Explained — The Tornado Cash Hack (May 2023)
- Composable Security: Understanding the Tornado Cash Governance Attack
- pcaversaccio/tornado-cash-exploit (PoC)
Exploit 2: Return Bomb Blocking RAI Protocol Liquidations — Protocol Insolvency Risk (September 2023)
Root cause: Unbounded return data from a malicious Safe Saviour contract exhausted the LiquidationEngine’s gas via quadratic memory expansion during RETURNDATACOPY of the RETURN/REVERT payload.
Details: Reflexer Finance’s (RAI) LiquidationEngine allows external “Safe Saviour” contracts to rescue underwater positions during liquidation. The engine calls the saviour and wraps the call in a try/catch block. When the saviour reverts, Solidity’s catch clause copies the full revert data into memory via RETURNDATACOPY.
An attacker deploys a malicious saviour contract whose fallback function executes revert(0, 0x100000) (1 MB revert payload). During liquidation:
- The LiquidationEngine forwards 63/64 of gas to the saviour.
- The saviour spends this gas writing to memory and reverting with a massive payload.
- Execution returns to the catch block with only 1/64 of the original gas.
- Solidity’s generated RETURNDATACOPY attempts to copy the 1 MB revert payload into the engine’s memory.
- Memory expansion for 1 MB costs ~2.1M gas. The remaining 1/64 gas is far insufficient.
- The entire liquidation transaction reverts with out-of-gas.
Since the malicious saviour deterministically produces massive return data on every call, the position becomes permanently unliquidatable. Accumulation of unliquidatable positions threatens protocol solvency.
RETURN’s role: While this specific attack used REVERT (not RETURN), the underlying mechanism is identical — the return data channel (shared by both RETURN and REVERT) carries an oversized payload that triggers quadratic memory expansion in the caller. RETURN-based return bombs work the same way using return(0, HUGE_LENGTH) instead of revert(0, HUGE_LENGTH).
Impact: Critical severity — protocol insolvency risk through permanent liquidation DoS. Remained unpatched as of early 2026.
References:
Exploit 3: EigenLayer DelegationManager — Delegator Fund Lockup via Return Bomb (2023-2024)
Root cause: Operator-specified callback contracts in EigenLayer’s delegation system could return unbounded data, exhausting the DelegationManager’s gas during automatic return data copying.
Details: EigenLayer allows operators to register callback contracts invoked during delegation and undelegation flows. The _delegationWithdrawnHook function called these operator-controlled contracts using standard Solidity call semantics, which automatically copy all return data via RETURNDATACOPY.
A malicious operator deploys a callback that returns (or reverts with) an extremely large payload. When any delegator attempts to undelegate, the callback’s massive return data triggers quadratic memory expansion in the DelegationManager’s call frame, consuming all remaining gas and reverting the undelegation transaction. Delegators’ funds become effectively locked.
RETURN’s role: The attack exploits the RETURN data channel — the callback uses RETURN (or REVERT) to produce a massive payload that the caller must process. Solidity’s automatic RETURNDATACOPY of the full RETURN payload is the gas sink.
Impact: High severity — delegator fund lockup. EigenLayer patched this by implementing bounded return data copying using Yul assembly, limiting copied return data to 32 bytes.
References:
Exploit 4: Non-Standard ERC-20 STOP-vs-RETURN Failures — USDT Integration Bug Class (2017-present)
Root cause: Tokens like USDT terminate transfer() with STOP (no return data) instead of RETURN with a bool. Solidity’s ABI decoder expects 32 bytes of return data and reverts when RETURNDATACOPY reads past the empty buffer.
Details: The ERC-20 standard specifies transfer(address, uint256) returns (bool). Solidity’s compiler generates code that, after calling a token’s transfer(), checks RETURNDATASIZE and uses RETURNDATACOPY to decode the expected bool. When USDT (and BNB, OMG, and other early tokens) returns nothing — because they use STOP instead of RETURN — RETURNDATASIZE is 0 and the decode attempt reverts.
This is not a single exploit but a pervasive integration failure affecting every DeFi protocol that interacts with non-standard tokens via Solidity’s high-level call syntax. OpenZeppelin’s SafeERC20.safeTransfer() was created specifically to handle this: it checks RETURNDATASIZE before decoding and treats zero-length return data as success.
RETURN’s role: The bug exists precisely because of the semantic difference between RETURN and STOP. If USDT’s transfer() used RETURN(ptr, 32) with a true value, the integration would work seamlessly. The STOP-vs-RETURN divergence at the opcode level propagates into a protocol-wide integration failure.
Impact: Hundreds of millions of dollars in protocol integrations required workarounds. Every protocol interacting with USDT ($100B+ market cap) must use SafeERC20 or equivalent.
References:
Attack Scenarios
Scenario A: Code Injection via Metamorphic Contract
// Factory that deploys metamorphic contracts via CREATE2
contract MetamorphicFactory {
// Step 1: Deploy benign contract (passes audit/vote)
// Step 2: SELFDESTRUCT the contract (pre-Cancun)
// Step 3: Redeploy with malicious initcode at same address
function deploy(bytes32 salt, bytes memory initcode) external returns (address) {
address deployed;
assembly {
deployed := create2(0, add(initcode, 0x20), mload(initcode), salt)
}
return deployed;
}
}
// Benign initcode (first deployment) -- RETURN provides safe bytecode
// Malicious initcode (second deployment) -- RETURN provides exploit bytecode
// Same address, different code. Callers trusting the address get exploited.
// Post-Cancun mitigation: EIP-6780 prevents SELFDESTRUCT from clearing
// code outside the creation transaction, blocking the redeploy step.
// However, same-transaction create+destroy+redeploy is still possible.Scenario B: Return Bomb Blocking Liquidations
contract MaliciousCallback {
fallback() external {
assembly {
// Spend 63/64 of forwarded gas on memory expansion,
// then RETURN a massive payload. The caller's 1/64 gas
// cannot pay for RETURNDATACOPY memory expansion.
return(0, 0x100000) // 1 MB return payload
}
}
}
contract VulnerableLendingPool {
mapping(address => address) public hooks;
function liquidate(address borrower) external {
address hook = hooks[borrower];
if (hook != address(0)) {
// VULNERABLE: Solidity auto-copies all return data
(bool ok, bytes memory data) = hook.call(
abi.encodeWithSignature("onLiquidation(address)", borrower)
);
// Even if we don't use 'data', Solidity copied it into memory.
// Memory expansion for 1 MB costs ~2.1M gas, exceeding
// the 1/64 reserved gas. Transaction reverts OOG.
}
_performLiquidation(borrower);
}
}
// Attacker registers MaliciousCallback as their hook.
// All liquidation attempts revert -> position is unliquidatable -> protocol insolvency.Scenario C: STOP vs. RETURN Token Integration Failure
// Non-standard token (like USDT) -- uses STOP, not RETURN
contract NonStandardToken {
mapping(address => uint256) balances;
function transfer(address to, uint256 amount) external {
balances[msg.sender] -= amount;
balances[to] += amount;
// Does NOT return bool -- execution ends with STOP (implicit)
// RETURNDATASIZE will be 0 for the caller
}
}
contract VulnerableVault {
function withdraw(IERC20 token, uint256 amount) external {
// Solidity expects 32 bytes of return data (bool).
// USDT returns 0 bytes -> RETURNDATACOPY(ptr, 0, 32) reverts OOB.
token.transfer(msg.sender, amount); // REVERTS for USDT!
}
}
contract SafeVault {
using SafeERC20 for IERC20;
function withdraw(IERC20 token, uint256 amount) external {
// SafeERC20 checks RETURNDATASIZE before decoding:
// 0 bytes -> assumes success; 32 bytes -> decodes bool
token.safeTransfer(msg.sender, amount); // Works for all tokens
}
}Scenario D: Deploying Hidden Backdoor via CREATE
contract MaliciousFactory {
// Deploys a contract that looks innocent to bytecode scanners
// but contains a hidden admin function at a specific selector
function deployBackdoor() external returns (address) {
// Initcode computes bytecode dynamically at deploy time.
// The RETURN opcode provides whatever bytes the initcode
// writes to memory -- no relationship to any Solidity source.
bytes memory initcode = hex"600080..." // hand-crafted bytecode
// ... computes runtime bytecode in memory ...
// ... RETURN(offset, length) stores it as contract code
address deployed;
assembly {
deployed := create(0, add(initcode, 0x20), mload(initcode))
}
return deployed;
// The deployed contract's bytecode contains a backdoor at
// selector 0xdeadbeef that drains all funds to the attacker.
// Static analysis of the Solidity source reveals nothing --
// the bytecode was computed, not compiled.
}
}Mitigations
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: Code injection via CREATE | Verify deployed bytecode on-chain, not just the creation transaction | Use EXTCODEHASH to compare deployed code against a known-good hash. Never trust contract addresses alone — verify code integrity. |
| T1: Metamorphic contracts | Post-Cancun: rely on EIP-6780 limiting SELFDESTRUCT. Pre-Cancun: check EXTCODEHASH at interaction time. | Compare keccak256(extcode) against expected hash before delegating trust. For governance proposals, verify bytecode at execution time, not just proposal time. |
| T2: Return bomb attacks | Bound return data copy size at the call site using assembly | Use let success := call(gas(), target, 0, inPtr, inLen, 0, 0) then returndatacopy(outPtr, 0, min(returndatasize(), maxLen)) — never copy unbounded return data. |
| T2: Return bomb in catch blocks | Avoid catch (bytes memory reason) with untrusted callees | Use catch { } (no data capture) or limit copied data with Yul assembly. Use Nomad’s ExcessivelySafeCall library. |
| T3: Memory expansion griefing | Cap return data processing at call sites | Specify out and outsize in low-level CALL to limit how much return data is written to the caller’s memory. |
| T4: STOP vs. RETURN inconsistency | Use SafeERC20 for all token interactions | OpenZeppelin SafeERC20.safeTransfer() checks RETURNDATASIZE before decoding, handling both STOP (0 bytes) and RETURN (32 bytes). |
| T4: Assembly return data handling | Always check RETURNDATASIZE before RETURNDATACOPY | if gt(returndatasize(), 31) { returndatacopy(ptr, 0, 32) } in assembly |
| T5: EIP-3541 awareness | Ensure dynamically generated bytecode does not start with 0xEF | Validate the first byte of computed bytecode before RETURN in initcode. |
| T5: Code size limit | Respect EIP-170’s 24,576-byte limit | Initcode that RETURNs more than 24,576 bytes wastes all gas. Split large deployments across multiple contracts. |
| General: Critical path protection | Ensure liquidation, unstaking, and governance paths cannot be DoS’d by return bombs | Use bounded return data (EigenLayer pattern: 32 bytes max) for all calls to untrusted addresses in critical code paths. |
Compiler/EIP-Based Protections
- EIP-3541 (London, August 2021): Rejects new contract deployments where the bytecode returned by RETURN starts with
0xEF. Reserves the0xEFprefix for future EOF (EVM Object Format) use. Only post-London deployments are affected; pre-existing0xEFcontracts continue to function. - EIP-170 (Spurious Dragon, November 2016): Limits deployed bytecode to 24,576 bytes. If RETURN provides more bytes in CREATE context, the deployment fails. Prevents unbounded state growth from single deployments and caps code injection payload size.
- EIP-6780 (Cancun, March 2024): Restricts SELFDESTRUCT to only clear code/storage when called in the same transaction as contract creation. Effectively eliminates the metamorphic contract attack vector (CREATE2 + SELFDESTRUCT + redeploy) for contracts that persist beyond their creation transaction.
- ExcessivelySafeCall (Nomad): Library providing bounded RETURNDATACOPY via
excessivelySafeCall(gas, target, value, maxCopy, payload). Prevents return bomb attacks by capping the bytes copied to caller memory. - SafeERC20 (OpenZeppelin): Wrapper that checks
RETURNDATASIZEbefore decoding return values, handling the STOP-vs-RETURN divergence in non-standard tokens. Industry standard for all ERC-20 interactions.
Severity Summary
| Threat ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | Critical | Medium | Tornado Cash governance attack ($2.17M, May 2023); metamorphic contract pattern |
| T2 | Smart Contract | Critical | High | RAI LiquidationEngine return bomb (protocol insolvency risk); EigenLayer DelegationManager fund lockup |
| T3 | Smart Contract | High | Medium | Gas griefing via return data in relayer/meta-tx systems |
| T4 | Smart Contract | High | High | USDT and non-standard ERC-20 integration failures (pervasive, 2017-present) |
| T5 | Smart Contract | Medium | Low | EIP-3541 awareness gap; EIP-170 code size limit |
| P1 | Protocol | Medium | Medium | Quadratic memory cost asymmetry in bundler/relayer contexts |
| P2 | Protocol | Low | Low | State bloat via large contract deployments (general concern) |
Related Opcodes
| Opcode | Relationship |
|---|---|
| REVERT (0xFD) | Like RETURN, provides data to the caller, but rolls back all state changes in the current call frame. Both populate the caller’s return data buffer and share the return bomb attack surface. REVERT is the preferred alternative when a call must fail gracefully. |
| STOP (0x00) | Halts execution successfully but returns no data (RETURNDATASIZE = 0). The STOP-vs-RETURN semantic divergence causes the non-standard ERC-20 integration bug class (USDT, BNB). |
| CREATE (0xF0) | Executes initcode that terminates with RETURN to specify the deployed runtime bytecode. The CREATE address depends on keccak256(sender, nonce). RETURN’s role as the code deployment gate makes it the final step in all CREATE-based deployments. |
| CREATE2 (0xF5) | Like CREATE, but uses keccak256(0xFF, sender, salt, keccak256(initcode)) for deterministic addressing. Combined with SELFDESTRUCT (pre-Cancun), enables the metamorphic contract pattern where RETURN deploys different code at the same address across deployments. |
| RETURNDATASIZE (0x3D) | Returns the size of the return data buffer populated by the most recent RETURN (or REVERT) from a subcall. Used to check how much data was returned before calling RETURNDATACOPY. |
| RETURNDATACOPY (0x3E) | Copies data from the return data buffer (populated by RETURN or REVERT) into memory. The automatic RETURNDATACOPY generated by Solidity after every external call is the mechanism through which return bombs exhaust caller gas. |
| SELFDESTRUCT (0xFF) | Pre-Cancun, cleared contract code and nonce, enabling redeployment at the same address with different RETURN-provided bytecode (metamorphic contracts). Post-EIP-6780, this is only possible within the creation transaction. |