Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0x3D |
| Mnemonic | RETURNDATASIZE |
| Gas | 2 |
| Stack Input | (none) |
| Stack Output | size (length in bytes of the return data from the last external call) |
| Behavior | Pushes the byte-length of the return data buffer produced by the most recent CALL, CALLCODE, DELEGATECALL, STATICCALL, or CREATE/CREATE2. If no external call has been executed in the current call frame, returns 0. Introduced in EIP-211 (Byzantium, October 2017). |
Threat Surface
RETURNDATASIZE is the EVM’s mechanism for inspecting the size of data returned by the last external call. Despite its trivial 2-gas cost and read-only semantics, it sits at the center of three critical security domains:
-
Non-compliant ERC-20 token detection. The ERC-20 standard specifies that
transfer,transferFrom, andapprovemust return abool. In practice, high-value tokens like USDT, BNB, and OMG return nothing. OpenZeppelin’sSafeERC20library relies on RETURNDATASIZE to distinguish between “returnedfalse” (revert) and “returned nothing” (success for non-compliant tokens). Any contract that calls ERC-20 functions without checking RETURNDATASIZE — or that assumes a boolean will always be present — will revert when interacting with these tokens, effectively creating a denial of service against billions of dollars in circulating supply. -
Cheap zero-push optimization (now fragile/obsolete). Because RETURNDATASIZE is guaranteed to return 0 at the start of a call frame (before any external call), the Solidity optimizer historically replaced
PUSH1 0(2 bytes, 3 gas) withRETURNDATASIZE(1 byte, 2 gas) to push zero onto the stack. This optimization saved deployment and runtime gas but introduced subtle bugs: the optimizer incorrectly removedRETURNDATACOPYoperations that should have reverted, and developers who relied on the “always zero” assumption in inline assembly created fragile code that breaks if an external call is later inserted before the usage site. EIP-3855 (PUSH0) largely obsoletes this pattern. -
Return data validation for safe external calls. RETURNDATASIZE is the only way to verify that an external call actually returned the expected data. Without it, code that reads from the return data buffer may consume stale or uninitialized memory. The 0x v2.0 Exchange vulnerability (2019) — where signature validation was bypassed because the code never checked RETURNDATASIZE — demonstrated that missing this check can be a critical vulnerability even in professionally audited code.
Smart Contract Threats
T1: Non-Compliant ERC-20 Tokens Returning No Data (Critical)
The ERC-20 standard mandates that transfer(), transferFrom(), and approve() return a bool. However, major tokens deployed before this convention was enforced — most notably USDT (Tether, ~$80B+ market cap) — return nothing. This creates two failure modes:
-
Direct interface calls revert. When Solidity code declares
IERC20(token).transfer(to, amount), the compiler inserts a check that the return data decodes to abool. If RETURNDATASIZE is 0 (no data returned), theabi.decodefails and the entire transaction reverts. Any DeFi protocol, bridge, or vault that uses the standard IERC20 interface without SafeERC20 silently excludes USDT and similar tokens. -
False success on unchecked low-level calls. Conversely, code that uses
token.call(abi.encodeWithSelector(...))and only checks the booleansuccessreturn fromCALL(ignoring the return data) will treat a failed transfer that returns no data as successful, if the call itself doesn’t revert. This is how tokens get stuck or stolen.
OpenZeppelin’s SafeERC20._callOptionalReturn() solves this by checking RETURNDATASIZE: if return data exists, it must decode to true; if no data is returned, the call is treated as successful. This pattern is now standard, but protocols that don’t use it remain vulnerable.
Why it matters: USDT alone accounts for the largest stablecoin by volume. A protocol that cannot interact with USDT due to missing RETURNDATASIZE handling loses access to a huge portion of DeFi liquidity. The ZKsync L1ERC20Bridge (October 2024) had exactly this bug in its _approveFundsToAssetRouter function, causing DoS for USDT bridging.
T2: RETURNDATASIZE as Zero Before Any External Call — Undefined Assumptions (High)
At the start of any call frame, the return data buffer is empty and RETURNDATASIZE returns 0. This property was exploited as a gas optimization, but it creates hazardous assumptions:
-
Fragile inline assembly. Developers writing Yul or inline assembly sometimes use
returndatasize()as a free zero value (e.g.,call(gas(), target, returndatasize(), ...)to pass 0 as the value parameter). If the code is later refactored and an external call is inserted before this usage,returndatasize()no longer returns 0 — it returns the size of the previous call’s return data, silently corrupting the intended argument. -
Optimizer-introduced bugs. The Solidity Yul optimizer (PR #11093) automatically replaced
PUSH1 0withRETURNDATASIZEbefore external calls. A related optimizer bug (Issue #13039) incorrectly removedRETURNDATACOPYoperations that should have caused out-of-gas reverts, violating the EVM specification. Contracts compiled with affected optimizer versions could silently skip reverts that the developer intended. -
Pure function misuse. Solidity disallowed
returndatasizeandreturndatacopyin inline assembly withinpurefunctions (PR #12861) specifically because these opcodes depend on execution context and produce non-deterministic results in pure contexts.
Why it matters: The optimizer bug affected any contract compiled with certain Solidity versions using the Yul optimizer. While PUSH0 (EIP-3855, Shanghai) provides a clean alternative, legacy contracts and L2 chains that don’t support PUSH0 still rely on the RETURNDATASIZE-as-zero pattern.
T3: Return Data Bombing — Gas Griefing via Unbounded Return Data (High)
Solidity automatically copies all return data from external calls into memory, even when the caller doesn’t use it. An attacker can exploit this by returning or reverting with enormous data payloads:
-
Quadratic memory expansion cost. EVM memory costs grow quadratically. A malicious callee that executes
revert(0, HUGE_SIZE)forces the caller to pay for copying and expanding memory to hold the return data. Since the callee uses 63/64 of the caller’s gas (EIP-150), the caller retains only 1/64 — but the memory expansion in the caller’s frame can consume even that. -
Liquidation prevention. In RAI’s (Reflexer Finance) liquidation engine, a malicious “Safe Saviour” contract could revert with massive return data during the
saveSAFE()callback. TheLiquidationEnginecaught the revert but Solidity’s automaticRETURNDATACOPYconsumed all remaining gas copying the bloated return data, causing the entireliquidateSAFE()to revert. This rendered positions unliquidatable, threatening protocol solvency. -
Unstaking/withdrawal blocking. EigenLayer identified the same pattern in their
DelegationManager: a malicious operator could prevent delegators from undelegating by returning excessive data in callbacks.
Why it matters: Any protocol with external callbacks (flash loans, liquidation hooks, saviours, operator callbacks) is potentially vulnerable. The attack costs the attacker almost nothing but can cause unbounded gas consumption in the victim’s frame.
T4: Missing Return Data Validation — Stale Memory Reads (Critical)
When code performs a low-level CALL/STATICCALL and reads from the return data area without first checking RETURNDATASIZE, it may read whatever was previously in memory at that location:
-
The 0x v2.0 signature bypass. The 0x Exchange’s
isValidWalletSignaturefunction used inline assembly toSTATICCALLa wallet contract’sisValidSignature()method, expecting 32 bytes of return data. When called against an EOA (no code), the STATICCALL succeeded but returned zero bytes. The code never checked RETURNDATASIZE, so it read the pre-existing memory content at the return offset — which happened to contain the function selector0x04, interpreted as a truthy value. This meant any EOA’s signature was treated as valid, enabling arbitrary order forgery on the 0x Exchange. -
Pre-Byzantium contracts. Before EIP-211 introduced RETURNDATASIZE, there was no way to distinguish between “call returned empty data” and “call returned zero.” Post-Byzantium contracts that interact with pre-Byzantium patterns without RETURNDATASIZE checks are at risk.
Why it matters: The 0x vulnerability was discovered by samczsun before exploitation, but it existed in production for approximately one year across a professionally audited codebase. ConsenSys Diligence called it “a bug we missed,” highlighting that even expert auditors overlook RETURNDATASIZE validation.
T5: RETURNDATASIZE After Reverted Calls — Misinterpreting Error Data (Medium)
When an external call reverts, the return data buffer contains the revert reason (if any). RETURNDATASIZE reflects this:
-
Confusing revert data with success data. If code checks
RETURNDATASIZE > 0as a proxy for “the call succeeded and returned data,” it will produce false positives after a reverted call that includes a revert reason. The return data buffer doesn’t distinguish between successful return data and revert data — only the boolean success flag from CALL does. -
Error message parsing attacks. Protocols that parse revert reasons (e.g., to extract custom error codes) must validate RETURNDATASIZE bounds before calling RETURNDATACOPY. If the revert data is shorter than expected, RETURNDATACOPY will revert with an out-of-gas error (per EIP-211), potentially masking the original revert reason.
Why it matters: Complex try/catch patterns and error-forwarding proxies must carefully track both the success flag and RETURNDATASIZE to avoid misinterpreting call outcomes.
Protocol-Level Threats
P1: No Direct DoS Vector (Low)
RETURNDATASIZE costs a fixed 2 gas with no dynamic computation. It reads a single integer from the current execution frame. It cannot itself be used for gas griefing. However, the return data buffer it measures can be weaponized for gas griefing (see T3).
P2: Consensus Safety (Low)
RETURNDATASIZE is deterministic within a call frame — all compliant EVM implementations return the same value for the same execution trace. It was introduced in EIP-211 (Byzantium) and its semantics have not changed. No consensus bugs have been attributed to RETURNDATASIZE itself.
P3: Cross-Chain RETURNDATASIZE Differences (Medium)
Not all EVM-compatible chains activated EIP-211 at the same block or support the same opcode set. L2 chains and EVM-compatible sidechains may have subtly different behavior:
- Some L2s did not initially support
PUSH0(EIP-3855), forcing continued use of the RETURNDATASIZE-as-zero pattern with its associated risks. - Chains with custom precompiles may return unexpected data sizes from precompile calls, causing RETURNDATASIZE-based logic to behave differently than on mainnet.
P4: RETURNDATASIZE Across Hard Forks (Low)
RETURNDATASIZE was introduced in Byzantium (EIP-211, October 2017). Its semantics have not changed in any subsequent fork. The introduction of PUSH0 in Shanghai (EIP-3855, April 2023) reduced reliance on RETURNDATASIZE as a zero-push substitute but did not alter its behavior.
Edge Cases
| Edge Case | Behavior | Security Implication |
|---|---|---|
| Before any external call in the frame | Returns 0 (empty buffer) | Safe as a zero-push optimization only if no external call is later inserted before the usage site; fragile under refactoring |
After CALL to EOA (no code) | Returns 0 (STOP produces no data) | Code must not assume “returned data” exists just because the call succeeded; reading unvalidated memory leads to stale data bugs (0x exploit) |
After CALL that succeeds with return data | Returns the length of the returned data | Standard case; must still validate length matches expected ABI encoding |
After CALL that REVERTs with reason | Returns the length of the revert reason | Return data buffer contains revert data, not success data; checking only RETURNDATASIZE > 0 is ambiguous without the success flag |
After CALL that REVERTs with no reason | Returns 0 | Indistinguishable from a successful call that returns no data without checking the success flag |
After CALL that runs out of gas | Returns 0 (no data produced) | Out-of-gas calls produce no return data; the buffer is cleared |
After CALL to a SELFDESTRUCTing contract | Returns 0 (SELFDESTRUCT produces no data) | Post-Dencun, SELFDESTRUCT only sends ETH; return data is still 0 |
After CREATE/CREATE2 | Returns 0 (creation does not use return buffer) | RETURNDATASIZE is 0 after contract creation even if the constructor ran code |
| After a precompile call | Returns the precompile’s output length | Precompiles may return variable-length data (e.g., ecRecover returns 32 bytes, modexp returns variable); must validate expected size |
| Nested calls overwrite the buffer | Buffer reflects only the most recent call | Checking RETURNDATASIZE after multiple calls without saving intermediate results loses earlier return data |
Real-World Exploits
Exploit 1: 0x v2.0 Exchange — Signature Validation Bypass via Missing RETURNDATASIZE Check (July 2019)
Root cause: The 0x Exchange’s wallet signature validation used inline assembly to STATICCALL a wallet’s isValidSignature() function but never verified RETURNDATASIZE before reading the return data.
Details: The isValidWalletSignature function allocated 32 bytes for the expected return value and performed a STATICCALL to the signer address. If the signer was an EOA (externally owned account with no code), the STATICCALL succeeded (executing STOP, which returns no data) but returned 0 bytes. Because the code never checked RETURNDATASIZE, it read from the pre-existing memory at the return offset, which contained the function selector bytes (0x04) left over from the ABI encoding. This non-zero value was interpreted as true, causing the exchange to accept any order as if the EOA had signed it.
The vulnerability existed in production for approximately one year before security researcher samczsun discovered and reported it. ConsenSys Diligence, who had previously audited the contract, published a post-mortem titled “Return Data Length Validation: A Bug We Missed,” emphasizing that RETURNDATASIZE checks are critical but easy to overlook.
RETURNDATASIZE’s role: A single require(returndatasize() == 32) check after the STATICCALL would have prevented the entire vulnerability. The absence of this check is the direct root cause.
Impact: No funds were lost — the vulnerability was responsibly disclosed before exploitation. 0x immediately shut down the v2 Exchange and deployed a patched version. However, the potential impact was unlimited order forgery across all trading pairs.
References:
- samczsun: The 0x Vulnerability, Explained
- 0x Core Team: Post-mortem
- ConsenSys Diligence: Return Data Length Validation — A Bug We Missed
Exploit 2: Return Data Bombing RAI’s Liquidation Engine — Protocol Solvency Threat (September 2023)
Root cause: Solidity’s automatic RETURNDATACOPY of unbounded return data allowed a malicious callback contract to force out-of-gas reverts in the caller’s frame.
Details: RAI (Reflexer Finance) implements a “Safe Saviour” mechanism that allows designated contracts to rescue underwater positions during liquidation. When the LiquidationEngine calls saveSAFE() on a saviour contract, it wraps the call in a try/catch to handle reverts gracefully. However, a malicious saviour can execute revert(0, VERY_LARGE_NUMBER) — this doesn’t actually write that much data, but when the catch block triggers, Solidity’s compiler-generated code calls RETURNDATACOPY to copy the return data into memory. The EVM’s quadratic memory expansion cost means copying the massive return data consumes all remaining gas (the caller only retains 1/64 of the original gas after EIP-150). The entire liquidateSAFE() transaction reverts, making the position permanently unliquidatable.
An unliquidatable position with declining collateral value threatens protocol solvency — bad debt accumulates with no mechanism to clear it.
RETURNDATASIZE’s role: The attack works because RETURNDATASIZE reports the full size of the revert data, and Solidity’s try/catch machinery uses RETURNDATASIZE to determine how much data to copy via RETURNDATACOPY. If the code had bounded the copy size (e.g., min(returndatasize(), MAX_REASONABLE_SIZE)), the attack would fail.
Impact: No funds were lost (bug bounty report). The vulnerability remains unpatched in RAI’s geb repository as of early 2026. EigenLayer proactively mitigated the same pattern in their DelegationManager by limiting return data copy to 32 bytes using inline assembly.
References:
- Trust Security: Returndata Bombing RAI’s Liquidation Engine
- Smart Contract Vulnerabilities: Unbounded Return Data
Exploit 3: ZKsync L1ERC20Bridge — USDT Bridging DoS via Missing SafeERC20 (October 2024)
Root cause: The bridge’s _approveFundsToAssetRouter function called approve() using the standard IERC20 interface, which expects a boolean return value. USDT’s approve() returns nothing.
Details: The ZKsync L1ERC20Bridge used IERC20(token).approve(assetRouter, amount) to set token allowances during L1-to-L2 bridging. Solidity’s generated code checks that the return data decodes to bool — this requires at least 32 bytes of return data. When the token was USDT, approve() returned no data (RETURNDATASIZE == 0), causing the ABI decode to fail and the entire bridge transaction to revert. Users could not bridge USDT from L1 to L2, creating a denial-of-service for the largest stablecoin by market cap.
The fix was to replace IERC20.approve() with OpenZeppelin’s SafeERC20.forceApprove(), which uses RETURNDATASIZE to handle the missing return value case.
RETURNDATASIZE’s role: The fix depends entirely on RETURNDATASIZE: SafeERC20._callOptionalReturn() checks if (returndata.length > 0) (compiled to RETURNDATASIZE) to decide whether to decode the return value. When RETURNDATASIZE is 0, it treats the call as successful.
Impact: DoS for USDT bridging on ZKsync. No funds were lost, but users were unable to bridge USDT until the fix was deployed.
References:
- Cyfrin CodeHawks: USDT Incompatibility in ZKsync L1ERC20Bridge
- OpenZeppelin SafeERC20 Pull Request #1655
Exploit 4: Solidity Optimizer Bug — Incorrect RETURNDATACOPY Removal (2022)
Root cause: The Solidity Yul optimizer’s RETURNDATASIZE-as-zero optimization led to incorrect removal of RETURNDATACOPY operations that should have caused specification-mandated out-of-gas reverts.
Details: The Yul optimizer replaced PUSH1 0 with RETURNDATASIZE before external calls as a gas optimization (since RETURNDATASIZE is 0 at the start of a frame). A related optimization removed RETURNDATACOPY calls deemed redundant. However, the optimizer failed to account for EIP-211’s specification that RETURNDATACOPY with start + length > RETURNDATASIZE must cause an out-of-gas exception — even when copying 0 bytes from one byte past the buffer end. The optimizer removed these “no-op” copies, allowing execution to continue past points where the EVM specification mandates a revert.
RETURNDATASIZE’s role: The optimizer’s use of RETURNDATASIZE as a zero substitute was the entry point for the bug. The subsequent incorrect reasoning about RETURNDATACOPY’s relationship to RETURNDATASIZE caused the specification violation.
Impact: Contracts compiled with affected Yul optimizer versions could exhibit undefined behavior where reverts were silently skipped. The bug was fixed in Solidity by restricting the optimization to only remove RETURNDATACOPY calls where the length argument is explicitly returndatasize().
References:
- Solidity Issue #13039: Optimizer Does Not Respect RETURNDATACOPY Specification
- Solidity PR #12861: Disallow RETURNDATASIZE in Pure Functions
Attack Scenarios
Scenario A: Non-Compliant Token DoS Against a Lending Protocol
// Vulnerable: Uses raw IERC20 interface -- reverts for USDT
contract VulnerableLendingPool {
function deposit(IERC20 token, uint256 amount) external {
// USDT.transferFrom() returns nothing -- RETURNDATASIZE == 0
// Solidity ABI decoder expects 32 bytes, reverts on decode failure
token.transferFrom(msg.sender, address(this), amount);
// This line is never reached for USDT
balances[msg.sender][address(token)] += amount;
}
function withdraw(IERC20 token, uint256 amount) external {
balances[msg.sender][address(token)] -= amount;
// Same issue: USDT.transfer() returns nothing
token.transfer(msg.sender, amount);
}
}
// Fixed: Uses SafeERC20 which checks RETURNDATASIZE
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract FixedLendingPool {
using SafeERC20 for IERC20;
function deposit(IERC20 token, uint256 amount) external {
// SafeERC20 handles RETURNDATASIZE == 0 gracefully
token.safeTransferFrom(msg.sender, address(this), amount);
balances[msg.sender][address(token)] += amount;
}
}Scenario B: Return Data Bombing to Block Liquidations
// Malicious saviour that prevents liquidation via return data bomb
contract MaliciousSaviour {
// Called by LiquidationEngine inside try/catch
function saveSAFE(
address liquidator,
bytes32 collateralType,
address safe
) external returns (bool ok, uint256 collateralAdded, uint256 debtRepaid) {
// Revert with massive data -- doesn't allocate, just sets returndata size
assembly {
revert(0, 1000000) // 1MB of "return data" (reads zero-memory)
}
// The LiquidationEngine's catch block triggers RETURNDATACOPY,
// which expands memory quadratically, consuming all gas.
// liquidateSAFE() reverts -- position is unliquidatable.
}
}
// Mitigation: Bound return data copy in the caller
contract SafeLiquidationEngine {
function liquidateSAFE(address saviour, bytes32 collat, address safe) external {
bool success;
assembly {
// Call with bounded return data -- ignore anything beyond 96 bytes
success := call(gas(), saviour, 0, /* ... */ 0, 0)
// Only copy up to 96 bytes regardless of RETURNDATASIZE
let size := returndatasize()
if gt(size, 96) { size := 96 }
returndatacopy(0, 0, size)
}
// Process bounded return data safely
}
}Scenario C: Stale Memory Read from Missing RETURNDATASIZE Check
// Vulnerable: Reads return data without checking RETURNDATASIZE
contract VulnerableSignatureValidator {
function isValidSignature(address signer, bytes32 hash, bytes memory sig)
external view returns (bool)
{
bool success;
bytes32 result;
assembly {
let ptr := mload(0x40)
// Encode isValidSignature(bytes32) call
mstore(ptr, 0x1626ba7e00000000000000000000000000000000000000000000000000000000)
mstore(add(ptr, 4), hash)
success := staticcall(gas(), signer, ptr, 36, ptr, 32)
result := mload(ptr) // DANGER: reads ptr even if no data was returned
}
// If signer is an EOA, success=true but RETURNDATASIZE=0.
// `result` contains whatever was at `ptr` before -- the selector 0x1626ba7e,
// which is non-zero, so this returns true for any EOA!
return success && result == 0x1626ba7e00000000000000000000000000000000000000000000000000000000;
}
}
// Fixed: Validate RETURNDATASIZE before reading
contract FixedSignatureValidator {
function isValidSignature(address signer, bytes32 hash, bytes memory sig)
external view returns (bool)
{
bool success;
bytes32 result;
assembly {
let ptr := mload(0x40)
mstore(ptr, 0x1626ba7e00000000000000000000000000000000000000000000000000000000)
mstore(add(ptr, 4), hash)
success := staticcall(gas(), signer, ptr, 36, ptr, 32)
// Check RETURNDATASIZE before trusting the return buffer
if lt(returndatasize(), 32) {
success := 0 // Not enough data returned -- treat as invalid
}
result := mload(ptr)
}
return success && result == 0x1626ba7e00000000000000000000000000000000000000000000000000000000;
}
}Scenario D: Fragile RETURNDATASIZE-as-Zero in Assembly
// Fragile: Uses returndatasize() as zero -- breaks after refactoring
contract FragileAssembly {
function execute(address target, bytes calldata data) external {
assembly {
// returndatasize() is 0 here because no call has been made yet.
// Developer uses it as a cheap way to push 0.
let success := call(
gas(),
target,
returndatasize(), // intended: value = 0
add(data.offset, 0x20),
data.length,
returndatasize(), // intended: retOffset = 0
returndatasize() // intended: retSize = 0
)
// Works... until someone adds a precheck call above this block.
}
}
// After refactoring: a helper call is added before the main call
function executeV2(address target, bytes calldata data) external {
// New: check allowlist (returns bool = 32 bytes)
(bool allowed,) = ALLOWLIST.staticcall(
abi.encodeWithSignature("isAllowed(address)", target)
);
require(allowed);
assembly {
// BUG: returndatasize() is now 32 (from the staticcall above),
// not 0. The call sends 32 wei instead of 0!
let success := call(
gas(),
target,
returndatasize(), // BUG: value = 32, not 0
add(data.offset, 0x20),
data.length,
returndatasize(),
returndatasize()
)
}
}
}Mitigations
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: Non-compliant ERC-20 tokens | Use SafeERC20 for all token interactions | using SafeERC20 for IERC20; then token.safeTransfer(), token.safeTransferFrom(), token.forceApprove() (OpenZeppelin >= 4.9) |
T1: USDT approve() non-compliance | Use forceApprove instead of approve | SafeERC20.forceApprove(token, spender, amount) sets allowance to 0 first, then to the desired amount, handling USDT’s non-standard behavior |
| T2: RETURNDATASIZE-as-zero fragility | Use PUSH0 (EIP-3855) or explicit PUSH1 0 | Target Solidity >= 0.8.20 with EVM version >= Shanghai; avoid returndatasize() as a zero substitute in inline assembly |
| T2: Optimizer bugs | Pin Solidity version; audit optimizer output | Test with and without optimizer; review compiler release notes for optimizer-related CVEs |
| T3: Return data bombing | Bound return data copy size in low-level calls | Use inline assembly: let sz := returndatasize() / if gt(sz, MAX) { sz := MAX } / returndatacopy(0, 0, sz) |
| T3: Callback gas griefing | Limit gas forwarded to untrusted callees | Use call{gas: BOUNDED_GAS}(...) and validate return within the caller’s retained 1/64 gas budget |
| T4: Missing return data validation | Always check RETURNDATASIZE after external calls | require(returndatasize() >= EXPECTED_SIZE) in assembly; or use Solidity’s try/catch which handles this automatically |
| T4: Stale memory reads | Zero the return buffer before external calls | mstore(ptr, 0) before staticcall(..., ptr, 32) so a zero-length return doesn’t read stale data |
| T5: Confusing revert data with return data | Always check the success flag alongside RETURNDATASIZE | if iszero(success) { /* revert data */ } else { /* return data */ } — never use RETURNDATASIZE alone to determine call outcome |
| General | Comprehensive integration testing with non-compliant tokens | Test with USDT, BNB, OMG, and other known non-compliant tokens in forked mainnet environments |
Compiler/EIP-Based Protections
- EIP-211 (Byzantium, 2017): Introduced RETURNDATASIZE and RETURNDATACOPY, enabling return data validation that was impossible before.
- EIP-3855 (Shanghai, 2023): Introduced
PUSH0, providing a clean 1-byte, 2-gas way to push zero without repurposing RETURNDATASIZE. Largely obsoletes the RETURNDATASIZE-as-zero optimization. - Solidity >= 0.8.20: Targets Shanghai by default, using PUSH0 instead of RETURNDATASIZE for zero values.
- OpenZeppelin SafeERC20 (>= 4.x): Industry-standard library that uses RETURNDATASIZE to safely handle non-compliant ERC-20 tokens.
- Solidity >= 0.8.10: Disallows
returndatasizeandreturndatacopyin inline assembly withinpurefunctions.
Severity Summary
| Threat ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | Critical | High | ZKsync L1ERC20Bridge USDT DoS; widespread protocol incompatibility with USDT |
| T2 | Smart Contract | High | Medium | Solidity optimizer bug (Issue #13039); fragile assembly in production contracts |
| T3 | Smart Contract | High | Medium | RAI liquidation engine return data bomb; EigenLayer proactive fix |
| T4 | Smart Contract | Critical | Medium | 0x v2.0 Exchange signature bypass (samczsun, 2019) |
| T5 | Smart Contract | Medium | Low | Error parsing failures in complex proxy/router architectures |
| P1 | Protocol | Low | N/A | — |
| P2 | Protocol | Low | N/A | — |
| P3 | Protocol | Medium | Medium | L2 chains lacking PUSH0 support; precompile return data differences |
| P4 | Protocol | Low | Low | — |
Related Opcodes
| Opcode | Relationship |
|---|---|
| RETURNDATACOPY (0x3E) | Copies data from the return data buffer into memory. RETURNDATASIZE determines the valid range for RETURNDATACOPY; copying beyond RETURNDATASIZE causes an out-of-gas revert. |
| CALL (0xF1) | Executes an external message call. Populates the return data buffer that RETURNDATASIZE measures. The success flag from CALL must be checked alongside RETURNDATASIZE. |
| STATICCALL (0xFA) | Read-only external call. Same return data semantics as CALL. The 0x v2.0 vulnerability involved STATICCALL without RETURNDATASIZE validation. |
| DELEGATECALL (0xF4) | Executes code in the caller’s context. Populates the return data buffer identically to CALL. SafeERC20’s _callOptionalReturn often uses DELEGATECALL internally. |
| CALLCODE (0xF2) | Deprecated predecessor to DELEGATECALL. Also populates the return data buffer. Legacy contracts using CALLCODE may have the same RETURNDATASIZE validation gaps. |
| CREATE (0xF0) | Deploys a new contract. Sets RETURNDATASIZE to 0 after execution (creation does not use the return data buffer in the same way as calls). |
| CREATE2 (0xF5) | Deterministic contract deployment. Same RETURNDATASIZE behavior as CREATE — returns 0 after execution. |