Low-Level Calls: call, delegatecall, staticcall
Low-level calls are the raw mechanism through which contracts communicate. Every high-level Solidity function call ultimately compiles down to one of these primitives. Understanding them is mandatory for security research because the most dangerous Solidity vulnerabilities — reentrancy, storage collisions, arbitrary code execution — all flow through low-level call mechanics. This lesson covers all three call types in depth, with the real-world exploit patterns that have drained hundreds of millions of dollars.
Low-level calls bypass Solidity's type system entirely. When you write target.call(data), the compiler doesn't check whether target implements the function, whether the data is correctly encoded, or whether the return value is valid. Everything is your responsibility. Getting any one of these wrong can result in silent failures, fund loss, or complete contract takeover.
High-Level vs Low-Level Calls
| Feature | High-Level (interface call) | Low-Level (.call) |
|---|---|---|
| ABI encoding | Automatic | Manual (abi.encode...) |
| Return value decoding | Automatic | Manual (abi.decode) |
| Revert propagation | Automatic — reverts bubble up | Returns (bool, bytes) — must check manually |
| Type checking | Compile-time | None — runtime only |
| Gas forwarding | 63/64 of remaining gas | Configurable via {gas: N} |
| ETH forwarding | {value: N} syntax | {value: N} syntax |
The .call() Function
// .call() returns a tuple: (bool success, bytes memory returnData)
(bool success, bytes memory data) = target.call(
abi.encodeWithSignature("transfer(address,uint256)", recipient, amount)
);
// CRITICAL: .call() does NOT revert on failure!
// success == false if the target reverted
// You MUST check the return value:
require(success, "call failed");
// Decode return data from a successful call:
uint256 result = abi.decode(data, (uint256));
// Sending ETH with .call (modern best practice):
(bool sent, ) = payable(recipient).call{value: amount}("");
require(sent, "ETH transfer failed");
// Forward specific gas amount (prevent gas griefing):
(bool ok, ) = target.call{gas: 50000, value: 0}(calldata);
// .call() to address(0) always returns success=true!
// This is a common gotcha — validate target != address(0) first.transfer() and .send() — Why They Are Deprecated
// .transfer(): 2300 gas stipend, reverts on failure
payable(recipient).transfer(amount);
// PROBLEM: 2300 gas is sometimes not enough
// EIP-1884 raised SLOAD cost — contracts with SLOAD in receive() OOG
// Recipient contract's receive() can't do much with 2300 gas
// .send(): 2300 gas stipend, returns bool — does NOT revert
bool sent = payable(recipient).send(amount);
// Worst of both worlds: too little gas AND silent failure
// .call{value:}("") — RECOMMENDED: forwards gas, explicit check
(bool ok, ) = payable(recipient).call{value: amount}("");
require(ok, "ETH send failed");
// Forwards all remaining gas (63/64 rule applies)
// Use nonReentrant modifier when forwarding significant gas
// Summary: use .call{value:} everywhere. Protect with nonReentrant.delegatecall: Running Code in Your Own Context
delegatecall is the most powerful and most dangerous of the three call types. It runs the callee's bytecode but in the caller's storage, msg.sender, and msg.value context. This is the foundation of every upgradeable proxy pattern:
// CALL: runs in target's own storage
// Proxy.delegatecall(impl) → runs impl's code in Proxy's storage
┌─────────────────┐ ┌─────────────────┐
│ PROXY │ │ IMPLEMENTATION │
│ Storage slot 0 │◄────────│ Storage slot 0 │ (proxy slot 0 is written)
│ Storage slot 1 │◄────────│ Storage slot 1 │ (proxy slot 1 is written)
│ │ delegate│ │
│ msg.sender=User │ call │ msg.sender=User │ (sender preserved!)
│ address(this) │ │ address(this)= │ (this = proxy addr)
│ = Proxy │ │ Proxy │
└─────────────────┘ └─────────────────┘
// The critical requirement: proxy and impl must have IDENTICAL storage layouts
// If impl has: uint256 value at slot 0
// But proxy has: address owner at slot 0
// → writing to impl's 'value' corrupts proxy's 'owner'!// Simplified Parity Wallet pattern (2017, $150M lost)
contract VulnerableWallet {
address public owner;
address public library; // set by owner during deployment
// FATAL BUG: delegates to user-controlled library address
function() payable public {
library.delegatecall(msg.data);
// Attacker sets library = their malicious contract
// Then calls initWallet(attackerAddress) — which runs in THIS storage
// This overwrites `owner` slot with attacker's address
// Now attacker can drain all ETH
}
}
// Fix: NEVER delegatecall to a user-controlled address
// Fix: Validate the target is a specific, immutable contract
// Fix: Use OZ's UUPS or Transparent proxy — hardened patternsstaticcall: Read-Only External Calls
// staticcall: like call, but reverts if callee tries to write state
// Used automatically by the compiler for 'view' and 'pure' external calls
(bool ok, bytes memory data) = oracle.staticcall(
abi.encodeWithSignature("latestPrice()")
);
require(ok, "oracle read failed");
uint256 price = abi.decode(data, (uint256));
// staticcall reverts if the target tries to:
// - SSTORE (write storage)
// - LOG (emit events)
// - SELFDESTRUCT
// - CREATE / CREATE2
// - CALL with value
// Security use: use staticcall when you need to call an untrusted
// contract for read data — it prevents the callee from modifying your state
// even if the callee is malicious
// Solidity automatically uses staticcall for interface.function() when
// the function is marked 'view' or 'pure'Common Vulnerabilities
The most common low-level call vulnerability is simply not checking the bool success return value. A failed .call() returns false — it doesn't revert the caller. If your contract credits a user's balance after a .call() that silently failed, the user received credit for a transfer that never happened. The Slither detector unchecked-lowlevel specifically catches this pattern.
// VULNERABLE: state updated AFTER external call
contract VulnerableBank {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0);
// CALL HAPPENS BEFORE STATE UPDATE — reentrant call works!
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
balances[msg.sender] = 0; // too late — already reentered
}
}
// SAFE: state updated BEFORE external call (CEI pattern)
contract SafeBank {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0);
balances[msg.sender] = 0; // EFFECT first
(bool ok, ) = msg.sender.call{value: amount}(""); // INTERACT last
require(ok, "send failed");
}
}Passing user-controlled bytes directly to target.call(userData) is a backdoor. The user can encode any function selector they want, calling arbitrary functions on the target — including admin functions, upgrade functions, or self-destruct. If you must forward user-supplied data, validate the function selector against an allowlist before making the call.
Calling the zero address with .call() always returns (true, "") because there is no code at address(0) — the EVM treats it as a successful call to a non-existent account. If your contract sends ETH to an address that might be zero (e.g., before it's initialized), the ETH is lost and the call is reported as successful. Always validate target != address(0) before any .call().
The Address.functionCall() Helper from OpenZeppelin
import "@openzeppelin/contracts/utils/Address.sol";
contract SafeCaller {
using Address for address;
// Address.functionCall adds:
// 1. Checks target has code (not EOA or address(0))
// 2. Bubbles up revert reason from failed call
// 3. Reverts if success == false
function safeCall(address target, bytes memory data)
external returns (bytes memory) {
return target.functionCall(data, "call failed");
}
function safeCallWithValue(address target, bytes memory data, uint256 value)
external payable returns (bytes memory) {
return target.functionCallWithValue(data, value, "call failed");
}
// For delegatecall:
// target.functionDelegateCall(data, "error") — same safety wrappers
}Sending ETH to Contracts Without receive()
// A contract can only receive ETH via .call() if it has:
// receive() external payable { ... } ← for plain ETH (no calldata)
// fallback() external payable { ... } ← for ETH with calldata
// If neither exists and you send ETH, the call returns false
// (or reverts if you use .transfer())
// Contracts that should NOT receive ETH:
contract NoETH {
// No receive() or fallback() — ETH sends revert automatically
// Good for contracts that only work with ERC20 tokens
}
// SELFDESTRUCT bypass: force-send ETH even to contracts without receive()
// selfdestruct(payable(targetContract)) skips receive() entirely
// This is an attack vector: if a protocol assumes its ETH balance
// can only change via its own logic, selfdestruct breaks that assumption
// Never use address(this).balance as a strict accounting value!Key Takeaways
.call()returns(bool success, bytes data)— it never reverts on callee failure. Always check the bool and revert manually if needed.- Use
.call{value: amount}("")for ETH transfers — not.transfer()or.send()which have a restrictive 2300 gas stipend that breaks with smart contract recipients. delegatecallruns the callee's bytecode in the caller's storage context — the proxy and implementation must have identical storage layouts or slots will collide and corrupt state.- Never pass user-controlled calldata to
.call()or user-controlled addresses todelegatecall— this enables arbitrary code execution in your contract's context. staticcallenforces read-only execution — the EVM reverts if the callee attempts any state write. Use it when calling untrusted external contracts for price data or other read operations.- Calling
address(0).call()always returnstrue— validate all target addresses are non-zero before calling. - ETH can be force-sent via
selfdestructbypassingreceive()— never useaddress(this).balanceas a strict invariant.