Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0xF1 |
| Mnemonic | CALL |
| Gas | Complex: base_cost + address_access_cost + value_transfer_cost + new_account_cost + memory_expansion_cost. Base: 0. Cold address access: 2600 (warm: 100). Value transfer (val > 0): 9000 + 2300 stipend to callee. New account creation (val > 0 to empty account): 25000. Memory expansion for input and output regions: quadratic. |
| Stack Input | gas, addr, val, argOst, argLen, retOst, retLen |
| Stack Output | success (1 if the call succeeded, 0 if it reverted or failed) |
| Behavior | Creates a new call frame and executes the code at address addr with the specified gas, sending val wei. Input data is read from the caller’s memory at [argOst, argOst+argLen). Return data is written to the caller’s memory at [retOst, retOst+retLen) (truncated or zero-padded). The full return data is available via RETURNDATASIZE/RETURNDATACOPY regardless of retLen. The caller’s msg.sender becomes address(this) in the callee’s context. Gas forwarding is capped at 63/64 of remaining gas (EIP-150). Pushes 1 on success, 0 on failure — does not revert the caller on callee failure. |
Threat Surface
CALL is the most security-critical opcode in the EVM. It is the sole mechanism for inter-contract communication, value transfer, and external code execution. Every DeFi protocol, every token transfer, every governance vote, and every oracle query ultimately bottoms out at a CALL instruction. The history of Ethereum smart contract exploits is, in large measure, the history of CALL being misused.
The threat surface centers on five properties:
-
CALL enables reentrancy. When contract A calls contract B, B receives execution control and can call back into A (or any other contract) before A’s call frame resumes. If A has not finalized its state updates before the CALL, the re-entrant call observes inconsistent state. This single property is responsible for The DAO hack (70M+, 2023), the Fei/Rari Capital drain (25M, 2020), and hundreds of smaller incidents. Reentrancy remains the most exploited vulnerability class in smart contract history.
-
CALL’s return value is silent. Unlike high-level Solidity function calls that auto-revert on failure, the raw CALL opcode pushes 0 on failure and continues execution. If the caller does not explicitly check this value, it proceeds under the false assumption that the call succeeded. This “unchecked return value” pattern caused the King of the Ether bug (2016), affects an estimated 30% of audited contracts, and is a top-10 OWASP smart contract vulnerability.
-
CALL’s gas semantics are adversarial. The 63/64 forwarding rule (EIP-150) means the caller always retains 1/64 of its gas, but the callee controls how the forwarded 63/64 is spent. A malicious callee can consume nearly all forwarded gas, then return massive data that triggers quadratic memory expansion in the caller’s remaining 1/64 gas (the return bomb attack). Separately, the cold/warm access pricing (EIP-2929) means a CALL to a previously untouched address costs 2600 gas instead of 100, creating gas griefing opportunities in loops over unbounded address lists.
-
CALL transfers value and can create accounts. When
val > 0, CALL sends wei to the target and grants a 2300-gas stipend. If the target is a contract with areceive()orfallback()function, that code executes with the stipend — the original reentrancy vector. Whenval > 0and the target address has no code and no balance, CALL creates a new account at a cost of 25000 gas, enabling state-growth griefing. -
CALL is the gateway to arbitrary code execution. The callee address and calldata are fully controlled by the caller (or by inputs to the caller). If a contract constructs a CALL with user-supplied
addror calldata without validation, an attacker can redirect execution to any contract with any function signature, turning the vulnerable contract into a universal proxy for the attacker’s intentions.
Smart Contract Threats
T1: Reentrancy — Cross-Function, Cross-Contract, and Read-Only (Critical)
CALL transfers execution control to the callee before the caller’s code resumes. If the caller has not committed its state updates before the CALL, the callee (or any contract it transitively calls) can re-enter the caller while its invariants are broken. Three reentrancy variants exist:
-
Single-function reentrancy. The classic pattern: a
withdraw()function sends ETH viamsg.sender.call{value: amount}("")before zeroing the sender’s balance. The callee’sreceive()function callswithdraw()again; the balance is still non-zero, so funds are sent again. This was The DAO’s fatal flaw in 2016. -
Cross-function reentrancy. The attacker re-enters through a different function that reads the stale state. For example,
withdraw()sends ETH before updating balances, and the re-entrant call goes totransfer(), which reads the pre-update balance and allows transferring tokens that should have been debited. The Fei/Rari Capital exploit ($80M, April 2022) used this:borrow()sent ETH before recording the debt, and the re-entrant call went toexitMarket()to withdraw collateral without the system seeing the outstanding loan. -
Cross-contract reentrancy. Contract A calls contract B, which calls contract C, which re-enters contract A. Contract A’s state is mid-update, but C’s call passes through a different entry point. The Curve Finance exploit ($70M+, July 2023) used this pattern with a broken Vyper compiler reentrancy guard.
-
Read-only reentrancy. During a re-entrant callback, an attacker calls a
viewfunction on the vulnerable contract. The view function returns stale state (e.g., an incorrect LP token price), and external protocols relying on that price make incorrect decisions. The CALL itself doesn’t modify state, but it enables reading inconsistent state during the callback window.
Why it matters: Reentrancy has caused over $200M in cumulative losses. It is the single most consequential vulnerability class in Ethereum’s history.
T2: Unchecked Return Value (High)
The CALL opcode pushes 0 on failure and 1 on success but does not revert the caller. Low-level address.call() in Solidity returns (bool success, bytes memory data), and the developer must explicitly check success. If they don’t:
-
Silent failure on ETH transfer. A contract calls
payable(recipient).call{value: amount}("")without checking the return. The recipient’s fallback reverts (or the call runs out of gas), but the caller proceeds as if payment succeeded, updating state (e.g., marking a withdrawal as complete) despite the funds remaining in the contract. -
Token transfer that didn’t happen. Some ERC-20 tokens (notably USDT) don’t return a
boolfromtransfer(). A low-level call to such a token will succeed (return 1) but the return data is empty. If the caller decodes the return data expecting a bool and doesn’t use SafeERC20, it may misinterpret the result. -
Failed governance execution. A timelock or multisig executes a proposal via low-level CALL. The target reverts, but the governance contract doesn’t check the return value and marks the proposal as executed. The action never happened, but it can never be retried.
Why it matters: Unchecked return values appear in approximately 30% of audited contracts. The King of the Ether Throne (2016) was one of the earliest public examples; OWASP ranks this in the Smart Contract Top 10.
T3: Gas Griefing via Cold Access and 1/64 Retention (High)
CALL’s gas costs are highly variable and partially attacker-controlled:
-
Cold access griefing. Post-EIP-2929, the first CALL to an address in a transaction costs 2600 gas (cold); subsequent calls cost 100 (warm). If a contract iterates over a user-supplied list of addresses (e.g., batch airdrop, multi-send), an attacker can provide a list of never-before-accessed addresses, each costing 2600 gas. A list of 10,000 cold addresses costs 26M gas in access costs alone, likely exceeding the block gas limit and causing the transaction to revert.
-
1/64 gas retention exploitation. The caller retains
floor(gas / 64)after a CALL. If the caller needs significant gas after the CALL (for state updates, further calls, event emission), an attacker can craft a callee that consumes exactly enough gas to leave the caller with insufficient gas for its post-call logic. This is subtle: the caller’s remaining gas depends on how much the callee consumed, and the callee can tune its consumption. -
Gas stipend limitations. When transferring value, the callee receives only a 2300-gas stipend (unless extra gas is explicitly forwarded). This is intentionally restrictive but breaks legitimate use cases where the recipient is a contract that needs more than 2300 gas in its
receive()function (e.g., proxy contracts that need DELEGATECALL).
Why it matters: Gas griefing doesn’t steal funds directly but can DoS critical operations: liquidations, unstaking, governance execution, bridge withdrawals.
T4: Return Data Bomb (High)
When Solidity captures return data via (bool success, bytes memory data) = target.call(...), the compiler generates RETURNDATACOPY to copy the callee’s entire return data into memory. A malicious callee can exploit this:
- The caller forwards 63/64 of its gas to the callee.
- The callee executes
revert(0, 0x100000)(1 MB), spending forwarded gas on its own memory expansion. - Control returns to the caller with only 1/64 of original gas.
- Solidity’s generated RETURNDATACOPY copies the 1 MB return data into the caller’s memory.
- The quadratic memory expansion cost (
words^2 / 512 + 3 * words) far exceeds the remaining 1/64 gas. - The entire transaction reverts with out-of-gas.
This attack can permanently block critical protocol operations if the callee address is attacker-controlled (e.g., callback hooks, saviour contracts, delegation targets).
Why it matters: Return bombs were found in RAI’s LiquidationEngine (September 2023, protocol insolvency risk) and EigenLayer’s DelegationManager (delegator fund lockup). Any contract that captures return data from an untrusted CALL is vulnerable.
T5: Value Transfer to Contracts with Fallback Code (Medium)
When CALL transfers value (val > 0) to a contract, the recipient’s receive() or fallback() function executes. This creates multiple risk vectors:
-
Unexpected code execution. The caller may assume the recipient is an EOA, but if it’s a contract, the fallback code runs. This code can call back into the caller (reentrancy), revert (causing the transfer to fail), consume excessive gas, or emit misleading events.
-
Account creation cost surprise. Sending value to an address with no code and no balance creates a new account (25000 gas). An attacker can force the caller to pay this cost by providing addresses that don’t exist yet, potentially causing out-of-gas in loops.
-
Forced ETH reception. Some contracts assume they can reject ETH (no
receive()/fallback()), but ETH can be forced into any contract viaSELFDESTRUCT(pre-Dencun behavior) or coinbase rewards, breaking invariants likeaddress(this).balance == totalDeposits.
Why it matters: The 2300-gas stipend was designed to prevent reentrancy from value transfers, but EIP-1884 and EIP-2929’s repricing of SLOAD has made 2300 gas insufficient for some legitimate fallback patterns, creating a tension between security and functionality.
Protocol-Level Threats
P1: 2016 DoS Attacks and the EIP-150 63/64 Rule (Historical — Critical Impact)
Before EIP-150 (Tangerine Whistle, October 2016), the CALL opcode had a hard call depth limit of 1024 and only cost 40 base gas. Attackers exploited this in September-October 2016 with “Shanghai Attacks” that:
- Created transactions with deep call chains consuming minimal gas but forcing O(n) processing time per CALL due to state access.
- Sent CALLs to massive numbers of accounts, each requiring a cold state read at only 40 gas, producing transactions that cost thousands of gas but took 20-80 seconds to process.
- Brought Ethereum nodes to a near-halt, with block processing times exceeding 1 minute.
EIP-150 responded by: (a) increasing CALL base cost from 40 to 700, (b) introducing the 63/64 gas forwarding rule, replacing the hard depth limit with exponential gas decay. With the 63/64 rule, the de-facto maximum call depth is ~340 frames, and deep calls become prohibitively expensive.
P2: EIP-2929 Cold/Warm Access Costs (Medium)
EIP-2929 (Berlin, April 2021) introduced the accessed_addresses set and split CALL costs into cold (2600) and warm (100). This was a direct response to remaining DoS vectors from the 2016 attacks. The impact on CALL:
- First CALL to any address in a transaction: 2600 gas for the address access alone.
- Subsequent CALLs to the same address: 100 gas.
- Access lists (EIP-2930): Transactions can pre-declare addresses for 2400 gas each (discount vs. 2600 cold).
- Contract breakage: Contracts that hardcoded gas amounts for CALLs (e.g.,
call{gas: 2300}) may fail post-Berlin because the callee now needs to pay 2600 for its own cold state accesses.
P3: CALL Gas Accounting Complexity and Client Divergence Risk (Medium)
CALL has the most complex gas calculation of any EVM opcode, combining six independent cost components: base cost, address access cost, value transfer cost, new account cost, memory expansion for input, and memory expansion for output. The 63/64 rule adds a further calculation where gas_available_to_callee = min(gas_arg, gas_remaining - gas_remaining/64). This complexity creates implementation divergence risk across EVM clients (geth, Nethermind, Besu, Erigon, reth) and is a primary source of consensus bugs in new client implementations and L2 EVM-equivalents.
P4: EIP-7069 — Proposed CALL Instruction Revamp (Informational)
EIP-7069 proposes new CALL instructions (EXTCALL, EXTDELEGATECALL, EXTSTATICCALL) that separate value transfer from code execution, remove the gas stipend, and return more granular failure codes. If adopted, these would address several CALL threat vectors by design, particularly the gas stipend confusion and the conflation of value transfer with code execution. The existing CALL opcode would remain for backward compatibility.
Edge Cases
| Edge Case | Behavior | Security Implication |
|---|---|---|
| CALL to an EOA (no code) | Succeeds immediately, returns 1, no code executed, return data buffer is empty | Safe for value transfer; callers that expect return data will get nothing (RETURNDATASIZE = 0) |
| CALL to a precompile (0x01-0x0A) | Executes the precompile’s built-in logic; gas costs are precompile-specific | Precompiles don’t have Solidity fallbacks; they follow their own gas and return data rules. Some precompiles (e.g., ecrecover at 0x01) return 0 bytes on invalid input instead of reverting. |
CALL with val > balance | Fails immediately (pushes 0), no gas forwarded to callee, no state changes | Caller must check return value; failure is silent. Balance check happens before any gas deduction for the subcall. |
CALL with gas = 0 and val > 0 | Callee receives the 2300-gas stipend only (added automatically for value transfers) | Sufficient for basic fallback logic but not for SLOAD or CALL within the callee |
CALL with gas = 0 and val = 0 | Callee receives 0 gas; execution immediately fails | Pushes 0 (failure); useful pattern only if you want to check address existence without executing code (though EXTCODESIZE is better) |
CALL where retLen < RETURNDATASIZE | Only retLen bytes copied to [retOst, retOst+retLen); full return data available via RETURNDATACOPY | Does not truncate the return data buffer. Callers using assembly must still use RETURNDATASIZE to get the full data. |
CALL where retLen > RETURNDATASIZE | Return data is zero-padded to fill [retOst, retOst+retLen) | Memory expansion cost is paid for the full retLen even if return data is shorter |
CALL to address(this) (self-call) | Creates a new call frame with msg.sender = address(this); valid and used in some patterns | Self-calls pass msg.sender == address(this) checks; combined with re-entrancy, can bypass access control |
| Return data overwrite by subsequent CALL | Any CALL/STATICCALL/DELEGATECALL/CALLCODE overwrites the return data buffer | Callers must read return data immediately; an intervening call (even a balance check via CALL to a precompile) destroys previous return data |
| CALL with overlapping memory regions for input and output | Input is read first, then output is written; overlapping ranges work but output overwrites input bytes | No security issue per se, but can cause confusion in hand-written assembly |
CALL to address with code but no receive/fallback and val > 0 | Callee reverts (Solidity contracts revert if no payable function matches empty calldata) | Pushes 0; caller must handle the failure |
Real-World Exploits
Exploit 1: The DAO Hack — $60M via Single-Function Reentrancy (June 2016)
Root cause: The DAO’s splitDAO() function used CALL to send ETH to msg.sender before updating the sender’s balance to zero, enabling recursive re-entrant withdrawals.
Details: The DAO was a decentralized investment fund holding ~$150M in ETH. Its splitDAO() function allowed members to withdraw their proportional share. The function called withdrawRewardFor(msg.sender), which executed _recipient.call.value(_amount)() — a CALL that sent ETH and forwarded all available gas to the recipient. Critically, the sender’s token balance was only zeroed after this external call returned.
The attacker deployed a malicious contract whose fallback() function immediately called splitDAO() again. Since the balance hadn’t been zeroed, the DAO computed the same withdrawal amount and sent ETH again. This cycle repeated ~20 times per transaction (limited by gas), and the attacker executed approximately 250 such transactions from two addresses, draining 3.6 million ETH.
The call.value() pattern was particularly dangerous because, unlike transfer() or send(), it forwarded all remaining gas to the callee, giving the attacker’s fallback function enough gas to execute the recursive call.
CALL’s role: CALL was the literal mechanism of the exploit. The opcode transferred execution control to the attacker’s contract, and the unlimited gas forwarding (pre-EIP-150) gave the attacker ample resources for deep recursion. EIP-150’s 63/64 rule was later introduced partly in response to this class of attacks.
Impact: ~$60M stolen (3.6M ETH at 2016 prices). Led to the Ethereum/Ethereum Classic hard fork — the most consequential event in Ethereum’s history.
References:
- Hacking Distributed: Analysis of the DAO Exploit
- Ethereum Foundation: Critical Update Re: DAO Vulnerability
Exploit 2: Fei/Rari Capital — $80M via Cross-Function Reentrancy (April 2022)
Root cause: Rari Fuse pool contracts (forks of Compound) used CALL to send ETH in borrow() before recording the debt, enabling a cross-function re-entrant call to exitMarket() that withdrew collateral while the debt was invisible.
Details: The attacker flash-loaned 150M USDC and 50K WETH, deposited USDC as collateral in Rari Fuse pools, and called borrow() to borrow ETH. The borrow() function used msg.sender.call{value: borrowAmount}("") to transfer ETH before updating the borrower’s debt record. During the CALL’s execution, the attacker’s fallback function called exitMarket() to remove their collateral. Because the debt hadn’t been recorded yet, the system saw no outstanding loan and allowed collateral withdrawal. The attacker then repaid the flash loan with the stolen assets.
Seven Fuse pools were drained (pools 8, 18, 27, 127, 144, 146, 156) for a total of approximately $80M.
CALL’s role: The call{value: borrowAmount}("") was the CALL instruction that transferred execution control to the attacker’s contract, enabling the cross-function re-entrant call to exitMarket() while borrow()’s state update was pending.
Impact: ~$80M stolen across seven Fuse pools. Fei Protocol later shut down.
References:
Exploit 3: Lendf.Me — $25M via ERC-777 Callback Reentrancy (April 2020)
Root cause: Lendf.Me’s supply() function called transferFrom() on an ERC-777 token (imBTC), which triggered a tokensToSend callback via CALL. The attacker re-entered withdraw() during this callback, exploiting the fact that the supply() function stored the user’s balance in a local variable before the external call and updated storage afterward.
Details: ERC-777 tokens define hook interfaces (tokensToSend, tokensReceived) that execute external CALLs during transferFrom(). Lendf.Me’s supply() function: (1) read the user’s existing balance, (2) called transferFrom() to pull imBTC tokens, (3) updated the user’s balance in storage. During step 2, the ERC-777 tokensToSend hook gave the attacker execution control. The attacker called withdraw() to extract their previously deposited imBTC. When supply() resumed at step 3, it used the stale pre-withdrawal balance to compute the new balance, effectively crediting the attacker twice.
By repeating this cycle, the attacker inflated their collateral balance to over $25M and borrowed all available liquidity across Lendf.Me’s markets.
CALL’s role: The ERC-777 tokensToSend hook is executed via a CALL instruction within the token’s transferFrom(). This CALL transferred control to the attacker’s registered hook contract, enabling the re-entrant withdraw() call while supply()’s state update was incomplete.
Impact: ~$25M drained. Most funds were later returned after the attacker was identified.
References:
- Tokenlon: About Recent Uniswap and Lendf.Me Reentrancy Attacks
- Quantstamp: How the dForce Hacker Used Reentrancy to Steal 25 Million
Exploit 4: King of the Ether Throne — Unchecked CALL Return Value (February 2016)
Root cause: The King of the Ether contract used send() (a limited CALL with 2300-gas stipend) to pay the previous king, but did not check the return value. When the recipient was a Mist wallet contract that required more than 2300 gas, the send silently failed, and the contract’s state updated as though payment had succeeded.
Details: The game’s logic was simple: each new “king” pays more than the last, and the previous king receives a payout. The contract executed previousKing.send(compensation) without checking whether send() returned true. When the previous king’s address was a contract wallet (created by the Mist Ethereum Wallet), the 2300-gas stipend was insufficient for the wallet’s internal accounting logic. The send() returned false, but the contract ignored it, updated its state, and the compensation ETH remained stuck in the contract.
CALL’s role: Solidity’s send() compiles to a CALL with a 2300-gas limit and val > 0. The CALL returned 0 (failure) because the callee needed more than 2300 gas, but the calling contract never checked this return value.
Impact: ETH stuck in the contract during the “Turbulent Age” (February 6-8, 2016). Relatively small financial loss but became the canonical example of the unchecked-return-value vulnerability, prompting the creation of the withdrawal pattern and the transfer() function.
References:
- KotET Post-Mortem Investigation
- Hacking Distributed: Scanning Live Ethereum Contracts for the “Unchecked-Send” Bug
Exploit 5: Curve Finance — $70M+ via Reentrancy with Broken Compiler Guard (July 2023)
Root cause: Vyper compiler versions 0.2.15-0.3.0 had a bug where the @nonreentrant decorator did not correctly protect against reentrancy. Attackers exploited this to re-enter Curve pool functions during ETH transfers executed via CALL.
Details: Several Curve stableswap pools (alETH-ETH, msETH-ETH, pETH-ETH, CRV-ETH) were compiled with vulnerable Vyper versions. The pools’ remove_liquidity() and add_liquidity() functions were decorated with @nonreentrant but the compiler-generated lock/unlock sequence was incorrect — it stored the lock flag in a storage slot that was overwritten by other variables, or the unlock happened before the function completed.
Attackers called remove_liquidity(), which sent ETH via CALL to the attacker’s contract. During the CALL, the attacker re-entered add_liquidity() (or another pool function). The reentrancy guard should have blocked this second call but didn’t due to the compiler bug. The pool’s reserves were in an inconsistent state during the re-entrant call, allowing the attacker to extract more value than their fair share.
CALL’s role: The ETH transfer in remove_liquidity() was the CALL that gave the attacker execution control. The reentrancy guard was supposed to prevent re-entry, but the compiler bug neutralized it, making the CALL a direct exploit vector.
Impact: $70M+ drained across multiple Curve pools. Multiple MEV bots and white-hat rescuers competed to drain vulnerable pools, creating chaotic on-chain races.
References:
Attack Scenarios
Scenario A: Classic Single-Function Reentrancy (The DAO Pattern)
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");
// VULNERABLE: CALL sends ETH before state update.
// Callee gets execution control with stale state.
(bool success,) = msg.sender.call{value: amount}("");
require(success, "transfer failed");
// State update happens AFTER the external CALL
balances[msg.sender] = 0;
}
}
contract ReentrancyAttacker {
VulnerableVault immutable vault;
uint256 constant DRAIN_ROUNDS = 10;
uint256 round;
constructor(VulnerableVault _vault) { vault = _vault; }
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw();
}
receive() external payable {
if (round < DRAIN_ROUNDS && address(vault).balance >= vault.balances(address(this))) {
round++;
vault.withdraw();
}
}
}Scenario B: Unchecked Return Value Leading to Stuck Funds
contract VulnerablePayment {
mapping(address => uint256) public pendingPayments;
function schedulePayment(address payee) external payable {
pendingPayments[payee] += msg.value;
}
function executePayment(address payable payee) external {
uint256 amount = pendingPayments[payee];
require(amount > 0, "nothing pending");
// VULNERABLE: return value from CALL is ignored.
// If payee is a contract whose fallback reverts, the send
// fails silently and the state update below clears the debt.
payee.call{value: amount}("");
// Debt is cleared regardless of whether the CALL succeeded
pendingPayments[payee] = 0;
}
}Scenario C: Return Data Bomb Blocking Liquidation
contract MaliciousCallback {
fallback() external {
assembly {
// Consume 63/64 of forwarded gas on memory expansion,
// then revert with 1 MB of data. The caller's RETURNDATACOPY
// for this data will exceed the remaining 1/64 gas.
revert(0, 0x100000)
}
}
}
contract LendingProtocol {
mapping(address => address) public callbacks;
function liquidate(address user) external {
// ... calculate liquidation amount ...
address hook = callbacks[user];
if (hook != address(0)) {
// VULNERABLE: captures full return data via Solidity default.
// If hook is MaliciousCallback, RETURNDATACOPY for the 1 MB
// revert payload triggers OOG in the remaining 1/64 gas.
try ICallback(hook).onLiquidation(user) {} catch {}
}
// This line is never reached if the return bomb consumed all gas
_executeLiquidation(user);
}
}Scenario D: Cold Address Gas Griefing in Batch Operations
contract BatchSender {
function batchSend(address[] calldata recipients, uint256[] calldata amounts) external payable {
require(recipients.length == amounts.length);
for (uint256 i = 0; i < recipients.length; i++) {
// Each CALL to a cold address costs 2600 gas for address access
// + 9000 for value transfer + 2300 stipend.
// An attacker supplies 2000 never-before-seen addresses:
// 2000 * 2600 = 5.2M gas just for address access.
// Combined with value transfer costs, this easily exceeds
// the block gas limit, reverting the entire batch.
(bool success,) = recipients[i].call{value: amounts[i]}("");
require(success, "send failed");
}
}
}Scenario E: Cross-Function Reentrancy via ERC-777 Callback
contract VulnerableLending {
mapping(address => uint256) public collateral;
mapping(address => uint256) public debt;
function deposit(IERC777 token, uint256 amount) external {
// ERC-777 transferFrom triggers tokensToSend hook via CALL.
// Attacker's hook calls withdraw() before collateral is credited.
uint256 balBefore = token.balanceOf(address(this));
token.transferFrom(msg.sender, address(this), amount);
uint256 received = token.balanceOf(address(this)) - balBefore;
// This update happens AFTER the CALL inside transferFrom
collateral[msg.sender] += received;
}
function withdraw(IERC777 token, uint256 amount) external {
require(collateral[msg.sender] >= amount);
require(debt[msg.sender] == 0, "has debt");
collateral[msg.sender] -= amount;
token.transfer(msg.sender, amount);
}
}Mitigations
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: Single-function reentrancy | Checks-Effects-Interactions pattern | Update all state before making any external CALL; never write state after an external call |
| T1: Cross-function reentrancy | Reentrancy guards | OpenZeppelin ReentrancyGuard (nonReentrant modifier) on all state-mutating external functions |
| T1: Cross-contract reentrancy | Global reentrancy locks | EIP-1153 transient storage locks (post-Dencun) shared across related contracts; or a central lock contract |
| T1: Read-only reentrancy | Reentrancy-aware view functions | Check the reentrancy lock in view functions; or use the “checkpoint” pattern to detect mid-update reads |
| T2: Unchecked return value | Always check CALL success | (bool success,) = target.call{value: amount}(""); require(success, "call failed"); — never ignore the return |
| T2: Non-standard ERC-20 returns | Use SafeERC20 for all token interactions | OpenZeppelin SafeERC20.safeTransfer() handles missing returns and non-standard bool encoding |
| T3: Cold access griefing | Bound loop iterations; use access lists | Limit batch sizes to a known gas budget; use EIP-2930 access lists for predictable gas costs |
| T3: 1/64 gas retention | Verify sufficient gas post-call | require(gasleft() >= MINIMUM_POST_CALL_GAS) after external calls on critical paths |
| T4: Return data bomb | Bound return data copy size | Use assembly: pop(call(gas(), target, val, inPtr, inLen, 0, 0)) then returndatacopy(outPtr, 0, min(returndatasize(), MAX_SIZE)) |
| T4: Return bomb in try/catch | Avoid capturing full revert data from untrusted callees | Use catch { } instead of catch (bytes memory reason), or use Nomad’s ExcessivelySafeCall library |
| T5: Fallback code execution | Pull over push pattern for ETH transfers | Use the withdrawal pattern: let recipients call withdraw() instead of pushing ETH to them |
| T5: Unexpected account creation | Validate recipient addresses | Check address.code.length > 0 or use allowlists for ETH recipients to avoid 25000-gas account creation |
| General | Prefer STATICCALL for read-only operations | STATICCALL prevents the callee from modifying state, eliminating reentrancy for view-only calls |
Compiler/EIP-Based Protections
- EIP-150 (Tangerine Whistle, 2016): Introduced the 63/64 gas forwarding rule, replacing the hard 1024 call depth limit. Eliminates call-depth-based attacks and ensures the caller always retains some gas after a CALL.
- EIP-2929 (Berlin, 2021): Cold/warm access pricing. First CALL to an address costs 2600 gas; subsequent calls cost 100. Mitigates state-access DoS attacks at the cost of increased gas variability.
- EIP-1153 (Dencun, 2024): Transient storage enables gas-efficient reentrancy guards that persist within a transaction but auto-clear afterward. Enables cross-contract reentrancy locks.
- Solidity >= 0.8.0: High-level calls auto-revert if the callee reverts. Low-level
call()still requires manual return value checking. Arithmetic overflow checks prevent some secondary exploitation vectors. - OpenZeppelin ReentrancyGuard: Industry-standard reentrancy protection. Uses a storage-based mutex that reverts on re-entrant calls. Available as
nonReentrantmodifier. - OpenZeppelin SafeERC20: Wraps token calls to handle non-standard return values, missing returns, and bool decoding, preventing integration failures with tokens like USDT.
- ExcessivelySafeCall (Nomad): Library that bounds RETURNDATACOPY size at the call site, preventing return bomb attacks. Essential for contracts calling untrusted addresses.
- EIP-7069 (Proposed): New CALL instructions (
EXTCALL,EXTDELEGATECALL,EXTSTATICCALL) that separate value transfer from code execution and remove the gas stipend. Would eliminate several CALL threat vectors by design.
Severity Summary
| Threat ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | Critical | High | The DAO (70M+, 2023), Fei/Rari (25M, 2020) |
| T2 | Smart Contract | High | High | King of the Ether (2016), OWASP Top 10, ~30% of audited contracts |
| T3 | Smart Contract | High | Medium | 2016 Shanghai DoS attacks; batch operation failures post-EIP-2929 |
| T4 | Smart Contract | High | Medium | RAI LiquidationEngine (2023), EigenLayer DelegationManager (2023-2024) |
| T5 | Smart Contract | Medium | Medium | Gas stipend failures post-EIP-1884/2929; account creation cost surprises |
| P1 | Protocol | Critical (Historical) | N/A | September-October 2016 Ethereum DoS attacks → Tangerine Whistle hard fork |
| P2 | Protocol | Medium | Low | Contract breakage from repriced cold access; access list adoption |
| P3 | Protocol | Medium | Low | Client implementation divergence on complex gas accounting |
| P4 | Protocol | Informational | N/A | EIP-7069 proposed but not yet adopted |
Related Opcodes
| Opcode | Relationship |
|---|---|
| DELEGATECALL (0xF4) | Like CALL but executes callee code in the caller’s storage context with preserved msg.sender and msg.value. Used in proxy patterns. No value transfer capability. Same 63/64 gas rule. |
| STATICCALL (0xFA) | Read-only CALL that reverts if the callee attempts state modification. Eliminates reentrancy risk for view-only calls. No value transfer. Same 63/64 gas rule. |
| CALLCODE (0xF2) | Deprecated precursor to DELEGATECALL. Executes callee code in caller’s context but sets msg.sender to the calling contract (unlike DELEGATECALL which preserves it). Should not be used in new code. |
| CALLER (0x33) | Returns msg.sender in the current call frame. CALL sets the callee’s CALLER to address(this) of the calling contract. |
| CALLVALUE (0x34) | Returns msg.value in the current call frame. CALL’s val parameter becomes the callee’s CALLVALUE. |
| RETURNDATASIZE (0x3D) | Returns the size of the return data from the most recent CALL. Essential for safe return data handling and return bomb mitigation. |
| RETURNDATACOPY (0x3E) | Copies return data from the most recent CALL into memory. Automatically generated by Solidity; the primary gas sink in return bomb attacks. |
| GAS (0x5A) | Returns remaining gas. Used before CALL to compute how much gas to forward; the 63/64 rule caps the forwarded amount. |
| CREATE (0xF0) | Deploys new contract code. Like CALL, creates a new execution frame, but for contract deployment rather than message calls. Same 63/64 gas rule. |
| REVERT (0xFD) | Halts execution and returns data to the caller. The callee’s REVERT populates the return data buffer read by RETURNDATASIZE/RETURNDATACOPY. Return bombs use REVERT to fill the buffer with massive payloads. |