Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0xFA |
| Mnemonic | STATICCALL |
| Gas | access_cost + mem_expansion (100 warm / 2600 cold target access, plus memory expansion for input and output regions) |
| Stack Input | gas, addr, argOst, argLen, retOst, retLen |
| Stack Output | success (1 if the call succeeded, 0 if it reverted or ran out of gas) |
| Behavior | Executes a message call to addr with the specified gas, input data (from memory at argOst for argLen bytes), and output buffer (at retOst for retLen bytes). The call is read-only: the callee (and all nested calls) cannot execute state-modifying opcodes — SSTORE, CREATE, CREATE2, LOG0-LOG4, SELFDESTRUCT, TSTORE, or any CALL that transfers value. If any such opcode is attempted, the entire subcall reverts. msg.value is implicitly 0. Introduced in the Byzantium hard fork via EIP-214. |
Threat Surface
STATICCALL is the EVM’s enforcement mechanism for read-only external calls. Solidity compiles all view and pure function calls to external contracts as STATICCALL, and the opcode is the backbone of on-chain oracle reads, price queries, and balance checks. The security guarantee is simple: STATICCALL prevents the callee from modifying state. But this guarantee is narrower than most developers assume, and the gap between what STATICCALL prevents and what developers believe it prevents is the primary attack surface.
The threat surface centers on three properties:
-
STATICCALL prevents state writes but not stale state reads. The read-only guarantee means the callee cannot SSTORE, LOG, CREATE, or SELFDESTRUCT. But it says nothing about the consistency of the state being read. If a contract’s storage is mid-update (e.g., during a reentrancy window where reserves have been sent but accounting hasn’t been updated), a STATICCALL into that contract’s view functions reads the inconsistent state faithfully. This is read-only reentrancy: the most dangerous class of STATICCALL-related vulnerabilities. Protocols that price LP tokens, compute collateral values, or check oracle rates via STATICCALL during another contract’s callback window receive manipulated values without any state modification occurring in the STATICCALL itself.
-
STATICCALL to untrusted code is still an external call. The read-only constraint prevents state mutation, but the callee still executes arbitrary bytecode, consumes gas, and controls its return data. A malicious callee can consume all forwarded gas (63/64 of available gas), return megabytes of data to trigger quadratic memory expansion in the caller (return bomb), or simply return false/garbage data. The “static” label creates a false sense of safety that leads developers to skip gas limits, return data bounds, and other defensive measures they would apply to a regular CALL.
-
STATICCALL inherits the reentrancy context of its caller. When contract A calls contract B (via CALL), and B callbacks into A’s view function (via STATICCALL), the STATICCALL executes while A’s state is inconsistent. The STATICCALL itself doesn’t modify state, but the values it returns to B reflect A’s mid-update storage. B then uses these stale values to make state-changing decisions (e.g., pricing collateral, computing liquidation thresholds). The attack doesn’t violate STATICCALL’s guarantee — no state was modified during the static call — but it exploits the timing of when the static call executes.
Smart Contract Threats
T1: Read-Only Reentrancy — Stale State via STATICCALL During Callbacks (Critical)
Read-only reentrancy is the most impactful vulnerability class associated with STATICCALL. Unlike traditional reentrancy where the attacker re-enters a state-modifying function, read-only reentrancy targets view functions that report stale or inconsistent values during a reentrancy window. Since view functions are compiled to STATICCALL and typically lack reentrancy guards (“it’s read-only, what could go wrong?”), they are universally unprotected.
The attack pattern is consistent across all known exploits:
- An attacker triggers a state-modifying operation on a DeFi pool (e.g.,
remove_liquidity(),exitPool()) that sends ETH or tokens to the attacker via a low-levelcall(). - Before the pool updates its internal accounting (reserves, LP supply, invariant
D), the attacker’sreceive()orfallback()function gains execution. - The attacker uses this callback to invoke a downstream protocol that prices the pool’s LP tokens or reads the pool’s state via STATICCALL (e.g.,
get_virtual_price(),getPoolTokens(),getRate()). - The STATICCALL faithfully reads the pool’s storage, which is in an inconsistent state: tokens have been transferred out but the accounting hasn’t been updated. This produces an inflated or deflated price.
- The attacker uses the manipulated price to borrow against overvalued collateral, trigger unfavorable liquidations, or arbitrage the price difference.
The attack exploits the gap between what STATICCALL guarantees (no state writes) and what developers assume (consistent state reads). The view function returns “correct” values given the current storage — but the storage is temporarily wrong.
Why it matters: Read-only reentrancy has caused over 3.65M, Sentiment 660K) and has been identified as a latent vulnerability in dozens more. It targets the most trusted pattern in DeFi: reading prices from on-chain oracles via STATICCALL.
T2: Gas Griefing via STATICCALL to Untrusted Code (High)
STATICCALL forwards gas to the callee following the 63/64 rule (the caller retains 1/64 of available gas). A malicious callee can consume all forwarded gas without producing a useful result, then return 0 (failure). The caller’s remaining 1/64 gas may be insufficient to handle the failure gracefully (e.g., trying an alternative oracle, reverting with a meaningful error, or continuing execution).
Concrete scenarios:
-
Oracle fallback exhaustion. A protocol calls a primary oracle via STATICCALL. If it fails, a fallback oracle is queried. A malicious primary oracle consumes 63/64 of gas, leaving insufficient gas for the fallback path. The transaction reverts even though a valid fallback exists.
-
Batch operation gas starvation. A contract iterates over a list of addresses, calling a view function on each via STATICCALL. A single malicious address that consumes all gas in the STATICCALL can cause the entire batch to fail, blocking operations like reward distributions or health-factor checks.
-
Governance/voting DoS. Governance contracts that STATICCALL into token contracts for
balanceOf()orgetVotes()can be griefed if a malicious token implementation burns gas during these calls, preventing proposals from being created or votes from being tallied.
Why it matters: STATICCALL’s read-only guarantee leads developers to omit gas limits (using gasleft() or the implicit 63/64 forwarding). The callee can weaponize this to DoS any operation that depends on the STATICCALL completing within a gas budget.
T3: Return Data Bombs via STATICCALL (High)
STATICCALL populates the return data buffer identically to CALL. When Solidity decodes the return value of a STATICCALL, it generates RETURNDATACOPY to copy the entire return buffer into memory. A malicious callee can return megabytes of data, triggering quadratic memory expansion in the caller’s execution context.
The attack works identically to return bombs via CALL (see RETURNDATACOPY threat model), but is more insidious because developers assume STATICCALL is safe:
-
Solidity’s auto-copy is invisible. When a contract calls
IERC20(token).balanceOf(user)wheretokenis attacker-controlled, Solidity generates a STATICCALL followed by RETURNDATACOPY for the return value. If the malicious “token” returns 1 MB of data, the memory expansion cost (~500M gas) exceeds the block gas limit. -
View function wrappers amplify the surface. Protocols that wrap external view calls (e.g.,
oracle.getPrice(),pool.getReserves()) without bounding return data forward the full bomb to the caller. -
Try/catch doesn’t help. Even with
try oracle.getPrice() returns (uint256 price) { ... } catch { ... }, if the oracle returns massive data on success, the RETURNDATACOPY in the try branch consumes all gas before the catch block executes.
Why it matters: Any STATICCALL to an untrusted address is a potential return bomb vector. The “read-only” nature provides no protection against gas-based attacks.
T4: False Sense of Security — STATICCALL Doesn’t Prevent All Attacks (High)
Developers frequently treat STATICCALL as a security boundary: “if I use staticcall, nothing bad can happen.” This assumption leads to missing critical defenses:
-
No protection against stale/manipulated reads. STATICCALL reads state faithfully but provides no guarantee about state consistency (T1). Developers who skip reentrancy guards on view functions because “they’re read-only” enable the entire read-only reentrancy attack class.
-
No protection against gas consumption. The callee can burn all forwarded gas (T2). Developers who omit gas limits on STATICCALL because “it can’t modify state” enable DoS attacks.
-
No protection against return data attacks. The callee controls the return buffer size and contents (T3). Developers who skip return data validation because “it’s just a view call” enable return bombs and data manipulation.
-
No protection against returning false data. A malicious callee can return any data from a STATICCALL. If a protocol calls
IOracle(untrustedOracle).getPrice()via STATICCALL, the oracle can return any uint256 — it doesn’t need to modify state to lie. The “static” guarantee means the oracle didn’t write state during this call, not that the returned value is correct. -
STATICCALL inside DELEGATECALL. When a proxy delegatecalls an implementation that makes a STATICCALL, the STATICCALL runs in the proxy’s storage context. If the proxy’s storage is mid-update, the STATICCALL reads the proxy’s inconsistent storage, not the implementation’s.
Why it matters: The word “static” implies safety, but STATICCALL’s guarantee is narrow: no state writes by the callee. Developers who equate “no state writes” with “safe” miss gas griefing, data manipulation, and stale-read attacks.
T5: STATICCALL to Non-Existent or Destroyed Contracts (Medium)
STATICCALL to an address with no deployed code (EOA, pre-deployment address, or post-SELFDESTRUCT address) succeeds with return data of length 0. This creates subtle failure modes:
-
Silent success on empty addresses.
(bool success, bytes memory data) = addr.staticcall(payload)returnssuccess = trueanddata = ""whenaddrhas no code. If the caller doesn’t checkdata.length, it may decode the empty return as a default value (e.g.,0foruint256,address(0)foraddress), silently accepting wrong data. -
Post-SELFDESTRUCT behavior (pre-Dencun). Before EIP-6780, a contract that self-destructed within the same transaction had its code cleared. A STATICCALL to this address within the same transaction returned success with empty data. Protocols relying on the target’s code for price feeds or balance checks would get empty/zero results.
-
Post-Dencun (EIP-6780) behavior. SELFDESTRUCT no longer deletes code except when called in the same transaction as contract creation. A “self-destructed” contract still has code and responds to STATICCALL normally. The balance is transferred but storage and code remain. This is safer than pre-Dencun but may confuse contracts that check for code existence as a proxy for contract liveness.
-
Precompile edge cases. STATICCALL to precompile addresses (0x01-0x0A) succeeds and returns the precompile’s output. A STATICCALL with insufficient gas for the precompile (e.g., calling ecrecover at 0x01 with less than 3000 gas) fails gracefully with
success = 0rather than consuming all forwarded gas.
Why it matters: The silent success of STATICCALL to empty addresses is a common source of integration bugs, particularly in multi-chain deployments where a contract exists on one chain but not another.
Protocol-Level Threats
P1: STATICCALL Gas Accounting and the 63/64 Rule (Low)
STATICCALL follows the same gas forwarding mechanics as CALL: the caller can specify a gas limit, capped at 63/64 of remaining gas. The cold/warm access pattern applies (2600 gas for first access to a new account per EIP-2929, 100 gas thereafter). No unique protocol-level gas concerns exist beyond those shared with CALL.
P2: Consensus Safety — STATICCALL State-Modification Detection (Low)
STATICCALL’s state-write prohibition is enforced at the EVM level via a static flag propagated through all nested call frames. If any opcode in the static context attempts a state write, the entire subcall reverts. This mechanism has been thoroughly tested across all major clients (geth, Nethermind, Besu, Erigon, Reth) since Byzantium (2017). No consensus bugs have been attributed to STATICCALL’s state-modification detection.
P3: STATICCALL in Cross-Chain / L2 Contexts (Medium)
STATICCALL semantics are consistent across L1 and L2s, but the state-modification detection interacts with L2-specific precompiles and system contracts:
-
L2 precompiles. Some L2s (Arbitrum, Optimism) add custom precompiles (e.g.,
ArbSys,L1Block) that STATICCALL can query. These precompiles may return L1-derived data (block hashes, gas prices) that changes between L1 and L2 blocks. Contracts that cache STATICCALL results from these precompiles may hold stale cross-chain data. -
Sequencer-dependent state. On L2s with centralized sequencers, the state visible to STATICCALL reflects the sequencer’s latest state root, which may lag behind L1. View functions that report cross-chain state (bridge balances, L1 oracle prices) may return outdated values.
P4: EIP-214 Specification Completeness (Low)
EIP-214 defines the set of prohibited opcodes in static context. As new state-modifying opcodes are introduced (e.g., TSTORE from EIP-1153), they must be added to the prohibition list. EIP-1153 correctly specifies that TSTORE reverts in static context while TLOAD is allowed. Future EIPs introducing state-modifying opcodes must follow the same pattern. The risk of an omission is low but non-zero.
Edge Cases
| Edge Case | Behavior | Security Implication |
|---|---|---|
| STATICCALL callee attempts SSTORE | Entire subcall reverts; caller receives success = 0 | Core guarantee of STATICCALL. No state modification occurs. |
| STATICCALL callee attempts LOG0-LOG4 | Entire subcall reverts | Logs are state modifications (append to tx receipt). Prevents stealth event emission. |
| STATICCALL callee attempts CREATE/CREATE2 | Entire subcall reverts | Contract creation is a state modification. |
| STATICCALL callee attempts SELFDESTRUCT | Entire subcall reverts | SELFDESTRUCT modifies state (balance transfer + code deletion). |
| STATICCALL callee attempts TSTORE | Entire subcall reverts | Transient storage writes are state modifications (EIP-1153). |
| STATICCALL callee executes TLOAD | Succeeds; returns transient storage value | TLOAD is a read operation. Transient state set earlier in the transaction is visible. |
| STATICCALL callee executes SLOAD | Succeeds; returns storage value | SLOAD is a read. STATICCALL freely reads persistent storage. |
STATICCALL callee attempts CALL with value > 0 | Entire subcall reverts | Value-transferring CALL modifies state (balance changes). |
STATICCALL callee executes CALL with value = 0 | Succeeds (the nested call also inherits static mode) | Zero-value calls are read-safe; the static flag propagates to all nested calls. |
| Nested STATICCALL (STATICCALL within STATICCALL) | Succeeds; inner call inherits static mode | Redundant but harmless. The static flag is already set. |
| STATICCALL within a regular CALL | Succeeds; the STATICCALL’s callee cannot modify state | Used legitimately for view function calls within state-modifying transactions. |
| STATICCALL to a precompile (0x01-0x0A) | Succeeds; precompiles are read-only by nature | ecrecover (0x01), SHA-256 (0x02), etc. all work under STATICCALL. Gas cost follows precompile-specific pricing. |
| STATICCALL to an EOA (no code) | Returns success = 1 with empty return data | Silent success. Caller must check returndatasize > 0 or validate code existence before the call. |
| STATICCALL to address(0) | Returns success = 1 with empty return data (no precompile at 0x00) | Same as calling any codeless address. |
STATICCALL with gas = 0 | Subcall gets 0 gas, immediately fails (success = 0) | Usable as a code-existence check (cheap fail), but EXTCODESIZE is more appropriate. |
msg.value inside STATICCALL | Always 0 | STATICCALL does not accept a value parameter; callvalue opcode returns 0 in the callee. |
STATICCALL return data exceeds retLen | Only retLen bytes are copied to memory; full return data is available via RETURNDATACOPY | Memory at retOst gets truncated data. The full return data buffer is still accessible. |
Real-World Exploits
Exploit 1: dForce Protocol — $3.65M Read-Only Reentrancy via Curve’s get_virtual_price() (February 2023)
Root cause: dForce’s lending protocol used Curve’s get_virtual_price() (called via STATICCALL) to price LP token collateral. During a Curve pool withdrawal, the attacker’s callback re-entered dForce’s oracle before Curve updated its internal accounting, reading an inflated virtual price.
Details: On February 10, 2023, the attacker flash-loaned 68,429 ETH and deposited into the wstETH/ETH Curve pool. They then called remove_liquidity(), which sends ETH back to the caller via a low-level call() before updating the pool’s internal state variable self.D (the invariant) and adjusting the LP token supply.
During the ETH callback, the attacker’s contract called dForce’s lending market. dForce priced the Curve LP token collateral by calling get_virtual_price() via STATICCALL. This function computes D / totalSupply. At this moment, LP tokens had been burned (reducing totalSupply) but D had not yet been recalculated to reflect the withdrawn liquidity. The ratio D / totalSupply was therefore inflated, making the LP token appear more valuable than it actually was.
The attacker used this inflated collateral value to borrow assets far exceeding the real collateral value, then repaid the flash loan and pocketed the difference.
STATICCALL’s role: The get_virtual_price() call was a legitimate STATICCALL — no state was modified during the call. The function faithfully read Curve’s storage. The problem was that Curve’s storage was in an inconsistent state because the STATICCALL executed during the reentrancy window between the ETH transfer and the accounting update. STATICCALL’s read-only guarantee was satisfied; the consistency guarantee that developers assumed was not.
Impact: 1.91M) and Optimism ($1.73M). The attacker later returned all funds.
References:
- Halborn: Explained the dForce Hack (February 2023)
- QuillAudits: Decoding dForce Protocol Read-Only Reentrancy Exploit
- BlockApex: dForce Network Hack Analysis
Exploit 2: Sentiment Protocol — $1M Read-Only Reentrancy via Balancer’s getPoolTokens() (April 2023)
Root cause: Sentiment’s oracle priced Balancer BPT (Balancer Pool Token) collateral using getPoolTokens() via STATICCALL. During a Balancer exitPool() operation, the attacker’s callback re-entered Sentiment before Balancer updated pool balances, reading stale balances that inflated the BPT price.
Details: On April 4, 2023, the attacker exploited Sentiment Protocol on Arbitrum. Balancer’s exitPool() function transfers ETH to the exiting user via a low-level call() before updating the pool’s token balances in the Vault. The attacker’s fallback function received the ETH callback and re-entered Sentiment’s lending market.
Sentiment’s WeightedBalancerLPOracle.getPrice() called the Balancer Vault’s getPoolTokens() via STATICCALL to fetch pool reserves. At this point in the exitPool() execution, the BPT supply had already been reduced (burned) but the pool’s token balances in the Vault had not yet been decremented. The oracle therefore computed a price based on the full pre-exit reserves divided by the reduced BPT supply, producing a highly inflated BPT price.
The attacker deposited the overvalued BPT as collateral and borrowed against it at the inflated valuation, extracting approximately $1M.
STATICCALL’s role: Both getPoolTokens() and getPrice() were pure STATICCALL paths. No state modification occurred during the oracle reads. The Balancer Vault’s getPoolTokens() function was a view function — it read storage slots that hadn’t been updated yet because the exitPool() function performs the external ETH transfer before updating balances (following a transfer-then-update pattern, not checks-effects-interactions).
Impact: ~2M in Sherlock insurance coverage.
References:
- Halborn: Explained the Sentiment Hack (March 2023)
- SolidityScan: Sentiment Hack Analysis — Reentrancy Attack
- Sentiment Post-Mortem (HackMD)
Exploit 3: Midas Capital — $660K Read-Only Reentrancy via Curve LP Token Pricing (January 2023)
Root cause: Midas Capital’s lending protocol on Polygon used Curve’s get_virtual_price() (via STATICCALL) to price WMATIC-stMATIC Curve LP tokens used as collateral. The same read-only reentrancy pattern as the dForce exploit allowed the attacker to borrow against inflated collateral.
Details: In January 2023, the attacker flash-loaned assets from Balancer V2, AAVE V3, and AAVE V2. They deposited into the WMATIC-stMATIC Curve pool and called remove_liquidity(). During the MATIC transfer callback (before the pool’s self.D invariant was updated), the attacker re-entered Midas Capital’s lending market.
Midas priced the Curve LP token by calling get_virtual_price() via STATICCALL. The inconsistent state (outdated D, reduced LP supply) inflated the virtual price. The attacker deposited LP tokens as collateral at the inflated price, borrowed MATIC, and repaid the flash loans.
STATICCALL’s role: Identical to the dForce exploit. The STATICCALL to get_virtual_price() was a read-only call that faithfully returned the computed virtual price from Curve’s inconsistent storage. The vulnerability was not in STATICCALL’s execution but in the timing of when it was invoked relative to Curve’s state update.
Impact: ~$660K stolen. This was one of the earliest publicized read-only reentrancy exploits against Curve LP pricing and served as a warning that was subsequently repeated in the dForce and Sentiment hacks.
References:
- Neptune Mutual: How Was Midas Capital Exploited
- Rekt News: Midas Capital — REKT
- ChainSecurity: Curve LP Oracle Manipulation Post Mortem
Exploit 4: Curve/Balancer LP Token Oracle Manipulation — Systemic Risk Across DeFi (2023, Ongoing)
Root cause: Curve’s get_virtual_price() and Balancer’s getRate() / getPoolTokens() are widely used as on-chain price oracles for LP tokens. Both functions are view functions called via STATICCALL, and both are susceptible to read-only reentrancy during pool exit operations.
Details: ChainSecurity published the first comprehensive analysis of this vulnerability class in 2022, identifying that Curve’s get_virtual_price() could be manipulated during remove_liquidity() calls on pools containing ETH (which use a low-level call() to transfer ETH, enabling callbacks). The vulnerability affected every protocol using get_virtual_price() as a price feed, including MakerDAO, Enzyme, Abracadabra, TribeDAO, and Opyn.
Balancer’s composable stable pools had an analogous vulnerability where getRate() could be manipulated during exitPool(). Balancer disclosed a rate provider manipulation vulnerability in September 2023, paying a $130K bug bounty through Immunefi.
The systemic risk is that dozens of lending protocols, yield aggregators, and DEXes use these LP token price feeds via STATICCALL, and each represents an independent exploitation opportunity whenever the underlying pool’s state is temporarily inconsistent during exit operations.
STATICCALL’s role: Every downstream protocol reads these price feeds via STATICCALL. The vulnerability exists not because STATICCALL is broken, but because STATICCALL’s read-only guarantee creates false confidence that view function results are always trustworthy.
Impact: Over $5M in direct exploits (dForce, Sentiment, Midas Capital). Systemic risk to protocols with billions in TVL that rely on these price feeds.
References:
- ChainSecurity: Curve LP Oracle Manipulation Post Mortem
- Balancer: Rate Provider Manipulation in Boosted Pools
- Nomos Labs: Read-Only Reentrancy — The Hidden DeFi Risk
Attack Scenarios
Scenario A: Read-Only Reentrancy — LP Token Price Manipulation
// Curve-style pool with ETH: vulnerable to read-only reentrancy
contract CurvePool {
uint256 public D; // Pool invariant
uint256 public totalSupply; // LP token supply
function remove_liquidity(uint256 lpAmount) external {
uint256 ethShare = (address(this).balance * lpAmount) / totalSupply;
totalSupply -= lpAmount; // LP supply updated FIRST
// ETH sent BEFORE D is recalculated -- reentrancy window opens
(bool ok,) = msg.sender.call{value: ethShare}("");
require(ok);
// D recalculated AFTER the external call
D = _computeD(); // Reentrancy window closes here
}
function get_virtual_price() external view returns (uint256) {
// During reentrancy: totalSupply is reduced but D is stale
// Result: inflated virtual price
return (D * 1e18) / totalSupply;
}
}
// Lending protocol that prices Curve LP tokens via STATICCALL
contract VulnerableLender {
CurvePool public pool;
function getCollateralValue(uint256 lpAmount) public view returns (uint256) {
// STATICCALL to get_virtual_price() -- reads stale D / reduced supply
uint256 vprice = pool.get_virtual_price();
return (lpAmount * vprice) / 1e18;
}
function borrow(uint256 lpCollateral, uint256 borrowAmount) external {
uint256 collateralValue = getCollateralValue(lpCollateral);
require(borrowAmount <= collateralValue * 80 / 100, "undercollateralized");
// Attacker borrows against inflated collateral value
_transferBorrow(msg.sender, borrowAmount);
}
}
// Attacker contract
contract ReadOnlyReentrancyAttacker {
CurvePool public pool;
VulnerableLender public lender;
function attack() external {
// 1. Flash loan ETH, deposit into Curve pool, get LP tokens
// 2. Call remove_liquidity() to trigger ETH callback
pool.remove_liquidity(lpBalance);
}
receive() external payable {
// 3. Callback during remove_liquidity, before D is updated
// 4. Deposit LP tokens as collateral at inflated price
// 5. Borrow maximum against overvalued collateral
lender.borrow(remainingLP, maxBorrowAmount);
}
}Scenario B: Gas Griefing via STATICCALL to Malicious Oracle
contract PriceAggregator {
address public primaryOracle;
address public fallbackOracle;
function getPrice(address token) external view returns (uint256) {
// STATICCALL to primary oracle -- forwards 63/64 of gas
(bool ok, bytes memory data) = primaryOracle.staticcall(
abi.encodeWithSignature("getPrice(address)", token)
);
if (ok && data.length == 32) {
return abi.decode(data, (uint256));
}
// Fallback oracle: only 1/64 gas remains if primary consumed everything
// This STATICCALL will likely fail with out-of-gas
(ok, data) = fallbackOracle.staticcall(
abi.encodeWithSignature("getPrice(address)", token)
);
require(ok, "both oracles failed");
return abi.decode(data, (uint256));
}
}
// Malicious oracle that burns all forwarded gas
contract GasGriefingOracle {
fallback() external {
// Consume all gas in an infinite loop, then revert
while (true) {}
}
}Scenario C: Return Bomb via STATICCALL
contract TokenVault {
function checkBalance(address token, address user) external view returns (uint256) {
// STATICCALL to token.balanceOf(user)
// If 'token' is malicious, it can return megabytes of data
// Solidity auto-copies ALL return data via RETURNDATACOPY
return IERC20(token).balanceOf(user);
// Memory expansion for large return data costs quadratic gas
}
}
// Malicious token that returns a return bomb on STATICCALL
contract ReturnBombToken {
function balanceOf(address) external pure returns (uint256) {
assembly {
// Return 1 MB of data: first 32 bytes = valid balance,
// rest is padding that triggers memory expansion in caller
mstore(0x00, 1000000000000000000) // 1 token
return(0x00, 0x100000) // 1 MB return
}
}
}Scenario D: False Sense of Security — Trusting STATICCALL Return Values
contract VulnerableSwapRouter {
function swap(
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 minOut
) external {
// Developer reasoning: "STATICCALL is safe -- the oracle can't
// modify state, so the price must be trustworthy"
uint256 price = IOracle(tokenOut).getPrice(); // STATICCALL
// The oracle returned a manipulated price (no state write needed)
uint256 amountOut = (amountIn * price) / 1e18;
require(amountOut >= minOut, "slippage");
IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn);
IERC20(tokenOut).transfer(msg.sender, amountOut);
}
}
// Malicious oracle: no state modification needed to lie
contract MaliciousOracle {
function getPrice() external pure returns (uint256) {
// Returns an inflated price -- purely a return value, no state writes
// STATICCALL's guarantee is satisfied (no state modification)
// but the value is completely fabricated
return 1000000e18; // 1000000x the real price
}
}Mitigations
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: Read-only reentrancy via stale state | Apply reentrancy guards to view functions that are used as oracles | Use OpenZeppelin ReentrancyGuard; ensure view functions called by external protocols check the reentrancy lock (revert if locked). Curve added a lock check to get_virtual_price(). |
| T1: Cross-protocol read-only reentrancy | Use TWAP or time-delayed price feeds instead of spot reads | Chainlink price feeds, Uniswap V3 TWAP oracle, or read prices from the previous block rather than the current block |
| T1: LP token price manipulation | Validate oracle prices against independent sources | Cross-reference on-chain prices with Chainlink feeds; use circuit breakers that revert if price deviates beyond a threshold |
| T2: Gas griefing via STATICCALL | Specify explicit gas limits on STATICCALL to untrusted addresses | Use addr.staticcall{gas: MAX_ORACLE_GAS}(data) to cap forwarded gas; ensure fallback paths have enough gas to execute |
| T3: Return data bombs | Bound return data copy size using assembly | Use ExcessivelySafeCall or Yul: let ok := staticcall(gas(), addr, inPtr, inLen, outPtr, 32) to limit copied return data to 32 bytes |
| T3: Solidity auto-copy | Avoid (bool, bytes memory) pattern with untrusted callees | Use low-level assembly STATICCALL with fixed-size output buffers instead of Solidity’s high-level call syntax |
| T4: False trust in STATICCALL results | Never trust return values from untrusted addresses | Validate STATICCALL return values against known bounds, TWAP oracles, or multiple independent sources |
| T4: Untrusted oracle manipulation | Use oracle aggregation with outlier detection | Median of 3+ oracle sources; revert if any source deviates >5% from the median |
| T5: STATICCALL to empty addresses | Check code existence before STATICCALL | require(addr.code.length > 0, "no code") or use Solidity >= 0.8.0 which reverts on calls to codeless addresses (high-level only) |
| T5: Destroyed contract reads | Validate return data length after STATICCALL | require(data.length >= 32, "invalid response") after low-level staticcall |
| General: Reentrancy protection | Use transient storage reentrancy locks (EIP-1153) | TSTORE(LOCK_SLOT, 1) at function entry, TSTORE(LOCK_SLOT, 0) at exit; TLOAD in view functions to check lock state. Gas-efficient and works cross-contract. |
Compiler/EIP-Based Protections
- EIP-214 (Byzantium, 2017): Introduced STATICCALL with the state-modification prohibition. The
staticflag propagates to all nested calls, ensuring callee code cannot bypass the restriction through intermediate calls. - EIP-1153 (Transient Storage, Dencun 2024): Enables gas-efficient cross-contract reentrancy locks using TSTORE/TLOAD. View functions can read the transient lock slot via TLOAD (allowed in STATICCALL context) and revert if the lock is set, preventing read-only reentrancy.
- EIP-7069 (EXTCALL/EXTSTATICCALL, proposed): Proposes new call instructions that don’t return data into memory automatically, eliminating the return bomb vector. EXTSTATICCALL would be the STATICCALL equivalent with safer return data handling via RETURNDATALOAD.
- Solidity >= 0.8.0: High-level external calls to addresses with no code revert automatically, reducing the T5 silent-success risk. Low-level
staticcallstill succeeds on empty addresses. - Curve/Balancer reentrancy lock patches: After the 2023 exploits, Curve added a
lockcheck toget_virtual_price()and other view functions. Balancer implemented similar protections forgetRate()andgetPoolTokens()rate calculations.
Severity Summary
| Threat ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | Critical | High | dForce (1M), Midas Capital ($660K), systemic risk across LP-token-based lending |
| T2 | Smart Contract | High | Medium | Oracle fallback exhaustion patterns; gas griefing in batch operations |
| T3 | Smart Contract | High | Medium | Return bomb class (shared with CALL/RETURNDATACOPY); RAI liquidation engine, EigenLayer DelegationManager |
| T4 | Smart Contract | High | High | Every protocol that trusts untrusted STATICCALL return values; broader design misconception |
| T5 | Smart Contract | Medium | Medium | Multi-chain deployment bugs where contracts exist on one chain but not another |
| P1 | Protocol | Low | N/A | — |
| P2 | Protocol | Low | N/A | — |
| P3 | Protocol | Medium | Low | L2 precompile data freshness; sequencer state lag |
| P4 | Protocol | Low | Low | EIP-1153 correctly added TSTORE to prohibition list |
Related Opcodes
| Opcode | Relationship |
|---|---|
| CALL (0xF1) | General-purpose external call that allows state modification and value transfer. STATICCALL is CALL minus the ability to modify state or send value. Read-only reentrancy typically chains a CALL (which creates the reentrancy window) with a STATICCALL (which reads stale state). |
| DELEGATECALL (0xF4) | Executes callee code in the caller’s storage context. A STATICCALL within a DELEGATECALL reads the delegating contract’s storage. Proxy patterns that mix DELEGATECALL and STATICCALL can read inconsistent proxy storage during reentrancy. |
| SLOAD (0x54) | Reads a storage slot. STATICCALL freely permits SLOAD — it is the mechanism by which view functions access state. Read-only reentrancy exploits the values returned by SLOAD during state inconsistency windows. |
| TLOAD (0x5C) | Reads transient storage (EIP-1153). Allowed within STATICCALL context. Critical for implementing gas-efficient reentrancy locks that view functions can check: if TLOAD(LOCK_SLOT) == 1, the function is being re-entered and should revert. |
| SSTORE (0x55) | Writes to storage. Prohibited within STATICCALL context — any attempt reverts the subcall. This is the core of STATICCALL’s read-only guarantee. |
| RETURNDATACOPY (0x3E) | Copies return data from the most recent external call into memory. STATICCALL populates the return data buffer identically to CALL, making it equally susceptible to return bomb attacks via RETURNDATACOPY. |
| CALLCODE (0xF2) | Deprecated precursor to DELEGATECALL. Like CALL but executes in caller’s context. No STATICCALL equivalent exists for CALLCODE. |