Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0x33 |
| Mnemonic | CALLER |
| Gas | 2 |
| Stack Input | (none) |
| Stack Output | msg.sender (address, zero-padded to 32 bytes) |
| Behavior | Pushes the 20-byte address of the account that directly invoked the current execution context. In a top-level call this is the EOA or contract that sent the transaction/message. In a DELEGATECALL, this is the caller of the delegating contract, not the delegating contract itself. |
Threat Surface
CALLER is the identity primitive of the EVM. Almost every access control mechanism in smart contracts ultimately relies on msg.sender (compiled to CALLER) to answer “who is calling me?” This makes CALLER one of the most security-critical opcodes despite its trivial gas cost and simple semantics.
The threat surface centers on three properties:
-
CALLER changes meaning in DELEGATECALL. When contract A delegatecalls contract B, B’s code sees
msg.senderas whoever called A, not A itself. This is by design for proxies, but it means any library or implementation contract that checksmsg.senderfor access control must account for the delegatecall context. If the same contract is called both directly and via delegatecall, the CALLER value differs — a common source of privilege escalation in proxy patterns. -
CALLER can be spoofed at the application layer. ERC-2771 meta-transactions append the real sender’s address to calldata and have a trusted forwarder call the target contract. The target overrides
_msgSender()to read the appended address instead of the rawmsg.sender. If the trusted forwarder logic is flawed, or if the contract also usesMulticall(which usesdelegatecallinternally), an attacker can forge arbitrary sender addresses. -
CALLER is necessary but not sufficient for authentication.
msg.senderconfirms which address called, but not with what intent. Phishing attacks via malicious contracts, reentrancy attacks wheremsg.senderis the same contract re-entering, and confused-deputy attacks all exploit the gap between identity and authorization.
Smart Contract Threats
T1: CALLER Preservation in DELEGATECALL — Proxy Access Control Bypass (Critical)
When a proxy uses DELEGATECALL to forward execution to an implementation contract, the implementation sees the proxy’s caller as msg.sender, not the proxy itself. This is the intended behavior that makes proxies transparent. However, it creates critical vulnerabilities when:
-
Implementation contracts are callable directly. If an implementation contract has an
initialize()function protected bymsg.sender == deployer, an attacker can callinitialize()directly on the implementation (not through the proxy), wheremsg.senderis the attacker. After taking ownership, the attacker canSELFDESTRUCTthe implementation, bricking all proxies that delegate to it. -
Libraries assume a specific caller context. A shared library designed to be called via
delegatecallmay perform access control checks againstmsg.sender. If the library is ever called directly (not via delegatecall),msg.senderis the direct external caller, bypassing intended restrictions. -
Storage slot collisions alter access control state. When the proxy’s storage layout disagrees with the implementation’s, the storage slot that the implementation reads as
ownermay contain an entirely different value in the proxy’s storage, granting or denying access incorrectly.
Why it matters: Proxy patterns (UUPS, Transparent, Diamond/EIP-2535) secure billions in TVL. A misunderstood CALLER in the delegatecall context directly leads to contract takeover.
T2: msg.sender Spoofing via ERC-2771 Trusted Forwarder (Critical)
ERC-2771 defines a meta-transaction protocol where a trusted forwarder relays transactions on behalf of users. The target contract overrides _msgSender() to extract the real sender from the last 20 bytes of msg.data when msg.sender == trustedForwarder. This creates multiple attack vectors:
-
Multicall + ERC-2771 interaction. When a contract implements both
ERC2771ContextandMulticall, an attacker can craft a forwarded request where the multicall’s internaldelegatecallsubcalls each see attacker-controlled bytes as the_msgSender(). The attacker appends a victim’s address to the calldata, and the subcall’s_msgSender()returns the victim’s address — the attacker is now impersonating the victim. -
Custom forwarder with short calldata. If a custom forwarder sends calldata shorter than 20 bytes,
_msgSender()reads out-of-bounds memory, potentially returningaddress(0)or garbage values (CVE-2023-40014). -
Compromised or malicious forwarder. If the trusted forwarder address is upgradeable or its private key is compromised, the forwarder can append any address to calldata, impersonating any user for every function call on the target contract.
Why it matters: ERC-2771 is widely adopted for gasless transactions. The Multicall interaction alone affected thousands of contracts across 37+ chains.
T3: Reentrancy with Preserved CALLER Identity (High)
When contract A calls contract B, and B calls back into A (reentrancy), the re-entrant call has msg.sender == address(B). But in more complex scenarios:
-
Same-contract reentrancy. If contract A has a function that sends ETH to
msg.senderand then updates state, the recipient (msg.sender) can reenter A before the state update. In the re-entrant call,msg.senderis the same attacker contract, passing the same access control checks. -
Cross-contract reentrancy. Contract A calls contract B, which calls contract C, which reenters contract A. In the re-entrant call,
msg.sender == address(C), but contract A’s state is still mid-update from the original call. If A checksmsg.senderfor authorization, C may pass the check while A is in an inconsistent state. -
Read-only reentrancy. A view function in contract A is called during reentrancy while A’s state is inconsistent. External protocols reading A’s state (e.g., oracle prices, LP token valuations) get incorrect values. While
msg.senderisn’t directly the issue, the reentrant caller’s identity enables the callback chain.
Why it matters: Reentrancy remains the most exploited vulnerability class in DeFi. The Curve Finance exploit (July 2023) drained $70M+ through cross-contract reentrancy.
T4: Sole Reliance on msg.sender for Access Control (High)
Using msg.sender as the only authorization factor is brittle:
-
Single key compromise = full access. If the
ownerEOA’s private key is stolen (phishing, malware, supply chain attack), the attacker’smsg.sendermatches the authorized address and the contract has no secondary defense. -
Vanity address brute-forcing. If the admin address was generated with a vanity address tool (e.g., Profanity), an attacker may brute-force the private key. The Wintermute exploit ($160M, September 2022) used this exact vector — the attacker derived the private key of a Profanity-generated admin address and drained the vault.
-
tx.origin vs msg.sender confusion. Contracts that use
tx.origininstead ofmsg.senderfor authorization are vulnerable to phishing: an attacker tricks the admin into calling a malicious contract, which then calls the target contract. The target seestx.origin == admin(correct) butmsg.sender == attacker_contract. Contracts usingtx.originare fooled; those usingmsg.senderare safe against this specific vector. -
No time-locks or multi-sig. Privileged operations gated only by
require(msg.sender == owner)execute atomically with no delay, cooling period, or multi-party approval.
Why it matters: Private key compromises are the #1 cause of DeFi losses by dollar amount. Single-address access control provides zero defense-in-depth.
T5: Confused Deputy via Contract-to-Contract Calls (Medium)
When contract A calls contract B on behalf of a user, B sees msg.sender == address(A), not the original user. If B has authorized A for certain operations, the user inherits A’s permissions:
-
Approved routers/aggregators. DEX aggregators are approved to spend tokens on behalf of users. If the aggregator has a vulnerability (e.g., no input validation), an attacker can trick the aggregator into calling the token contract with
msg.sender == aggregator_address, transferring other users’ approved tokens. -
Callback-based protocols. Flash loan providers call back into the borrower’s contract. During the callback, the borrower can call external contracts with
msg.sender == borrower_contract, which may have elevated permissions in other protocols.
Why it matters: Composability is Ethereum’s superpower, but every contract-to-contract call shifts the msg.sender identity. Approval chains create transitive trust that attackers exploit.
Protocol-Level Threats
P1: No DoS Vector (Low)
CALLER costs a fixed 2 gas with no dynamic component. It reads from the execution context (a register, not storage), making it one of the cheapest opcodes. It cannot be used for gas griefing.
P2: Consensus Safety (Low)
CALLER is deterministic — it returns the caller address from the message frame, which is set by the CALL/DELEGATECALL/STATICCALL that created the frame. All client implementations agree on this. No consensus bugs have been attributed to CALLER.
P3: CALLER in Account Abstraction / EIP-4337 (Medium)
With EIP-4337 account abstraction, transactions can originate from smart contract wallets rather than EOAs. The msg.sender for a UserOperation’s execution is the EntryPoint contract or the smart wallet itself, depending on the call path. Contracts that assume msg.sender is always an EOA (e.g., require(msg.sender == tx.origin) to block contracts) will malfunction with account-abstracted wallets, effectively excluding a growing user base or creating denial-of-service conditions.
P4: CALLER Across Hard Forks (Low)
CALLER semantics have never changed across Ethereum hard forks. The only related change was the introduction of DELEGATECALL (EIP-7, Homestead) which defined the caller-preservation behavior. No upcoming EIPs alter CALLER’s semantics.
Edge Cases
| Edge Case | Behavior | Security Implication |
|---|---|---|
CALLER in DELEGATECALL | Returns the caller of the delegating contract, not the delegating contract itself | Implementation contracts see the proxy’s caller; access control must account for both direct and delegated call paths |
CALLER in STATICCALL | Returns the address that initiated the static call | No special behavior, but the static context prevents state changes regardless of CALLER identity |
CALLER == address(this) | Occurs when a contract calls itself (internal message call) | Self-calls pass msg.sender == address(this) checks, potentially bypassing access control if the contract is both caller and callee |
| CALLER in constructor | Returns the deploying address (EOA or factory contract) | Initializers using msg.sender as owner work during construction but fail if the factory deploys on behalf of a user |
CALLER with CREATE / CREATE2 | During contract creation, CALLER is the deploying address | Newly created contracts see their deployer as msg.sender; factory patterns must propagate the intended owner separately |
CALLER after SELFDESTRUCT (same tx) | CALLER is unaffected; the destroyed contract can still be called within the same tx (pre-Dencun) | Post-Dencun (EIP-6780), SELFDESTRUCT only sends ETH without destroying code in most cases, reducing this edge case |
CALLER == address(0) | Not possible in normal execution; address(0) cannot initiate a call | If _msgSender() returns address(0) due to a bug (CVE-2023-40014), it may bypass zero-address checks or match uninitialized owner slots |
Real-World Exploits
Exploit 1: ERC-2771 + Multicall Address Spoofing — Thirdweb / TIME Token ($190K+, December 2023)
Root cause: Interaction between ERC-2771 meta-transaction _msgSender() extraction and Multicall’s internal delegatecall allowed arbitrary address spoofing.
Details: ERC-2771 works by having a trusted forwarder append the original sender’s address (20 bytes) to the calldata. The target contract checks if (msg.sender == trustedForwarder) and extracts the sender from msg.data[msg.data.length - 20:]. When combined with Multicall (which batches calls via delegatecall), an attacker could craft a forwarded multicall where each subcall’s calldata ended with an arbitrary victim address. The subcall’s _msgSender() then returned the victim’s address, giving the attacker full impersonation.
The attacker exploited the Ethereum TIME token contract on December 7, 2023. They used the trusted forwarder to call multicall() with crafted calldata that spoofed liquidity provider addresses, burned tokens in the liquidity pair, and profited from the resulting price manipulation.
CALLER’s role: The raw msg.sender (CALLER) was the trusted forwarder, which triggered the ERC-2771 path. But the application-layer _msgSender() override read attacker-controlled bytes as the sender identity, bypassing all access control.
Impact: ~$190K stolen from the TIME token. Over 9,800 contracts across 37 chains were affected by the same vulnerability pattern. All ERC-20, ERC-721, and ERC-1155 tokens deployed via Thirdweb before November 22, 2023, were vulnerable.
References:
- OpenZeppelin: Arbitrary Address Spoofing Disclosure
- Thirdweb Incident Report
- Beosin: TIME Contract Hack Analysis
Exploit 2: Parity Wallet First Hack — $30M Stolen via Delegatecall CALLER Bypass (July 2017)
Root cause: Public initWallet() function on a shared library contract, combined with delegatecall CALLER preservation, allowed anyone to take ownership of multi-sig wallets.
Details: Parity’s multi-sig wallet used a thin proxy that delegatecalled all function calls to a shared WalletLibrary contract. The library’s initWallet() function set the wallet owner based on msg.sender. Because of delegatecall, when a user called the proxy, the library saw msg.sender as the user (not the proxy). This was correct behavior — but initWallet() was public with no guard against re-initialization.
An attacker called initWallet() directly through the proxy after deployment, setting themselves as the owner. Since delegatecall preserved their msg.sender, the library’s msg.sender = owner check now authorized the attacker for all privileged operations. They drained 153,000+ ETH from three ICO wallets.
CALLER’s role: The delegatecall CALLER preservation was the enabling mechanism. The library saw the attacker’s address as msg.sender when called through the proxy, and its initWallet() happily assigned ownership to whoever called it.
Impact: ~150M frozen) when the same library was taken over and self-destructed.
References:
Exploit 3: Wintermute Vault Drain — $160M via Compromised Admin Address (September 2022)
Root cause: Single msg.sender-based access control with an admin key generated by the vulnerable Profanity vanity address tool.
Details: Wintermute’s DeFi vault contract used a simple require(msg.sender == admin) check for privileged operations. The admin address was a vanity address (starting with 0x0000000) generated using the Profanity tool, which had a known vulnerability allowing private key derivation via GPU brute-forcing. Despite 1inch publicly disclosing the Profanity vulnerability on September 15, 2022, Wintermute moved ETH out of the compromised address but failed to revoke its admin role on the vault contract. Five days later, the attacker derived the private key and called the vault with msg.sender == admin_address, draining $160M.
CALLER’s role: The vault’s entire security model was msg.sender == admin. Once the attacker had the private key to produce that msg.sender value, there was zero defense-in-depth — no timelock, no multi-sig, no secondary verification.
Impact: 120M stablecoins, 20M altcoins). The attacker deposited $114M into Curve Finance to avoid stablecoin blacklisting.
References:
Exploit 4: Curve Finance — $70M+ via Cross-Contract Reentrancy (July 2023)
Root cause: Vyper compiler reentrancy lock bug allowed re-entrant calls where msg.sender identity enabled callback chains that exploited inconsistent state.
Details: Several Curve pools compiled with Vyper versions 0.2.15-0.3.0 had a broken reentrancy guard due to a compiler bug. Attackers exploited this by calling a pool function, receiving a callback (e.g., during ETH transfer), and re-entering the pool while its invariants were in an inconsistent state. In the re-entrant call, the attacker’s contract was still the msg.sender, passing the same authorization checks. The pools’ state (reserves, LP token supply) was mid-update, allowing the attacker to withdraw more than their fair share.
CALLER’s role: The re-entrant call had the same msg.sender as the original call (the attacker’s contract), allowing it to pass all caller-based checks. The reentrancy guard that should have blocked the second call was broken at the compiler level.
Impact: $70M+ drained across multiple Curve pools (alETH-ETH, msETH-ETH, pETH-ETH, CRV-ETH).
References:
Attack Scenarios
Scenario A: ERC-2771 Multicall Address Spoofing
// Vulnerable: ERC2771Context + Multicall
contract VulnerableToken is ERC20, ERC2771Context, Multicall {
function burn(address from, uint256 amount) external {
require(_msgSender() == from, "not authorized");
_burn(from, amount);
}
}
// Attack: Forwarder calls multicall() with crafted calldata
// The attacker appends victim's address to each subcall's calldata.
// _msgSender() extracts victim's address from calldata tail,
// so burn(victim, amount) passes the authorization check.
interface IForwarder {
struct ForwardRequest {
address from;
address to;
uint256 value;
uint256 gas;
uint256 nonce;
bytes data;
}
function execute(ForwardRequest calldata req) external returns (bytes memory);
}
contract Attacker {
function attack(
IForwarder forwarder,
address vulnerableToken,
address victim,
uint256 amount
) external {
// Craft multicall data where each subcall ends with victim's address
bytes memory burnCall = abi.encodeWithSelector(
VulnerableToken.burn.selector,
victim,
amount
);
// Append victim address so _msgSender() reads it
bytes memory spoofedCall = abi.encodePacked(burnCall, victim);
bytes[] memory calls = new bytes[](1);
calls[0] = spoofedCall;
bytes memory multicallData = abi.encodeWithSelector(
Multicall.multicall.selector,
calls
);
IForwarder.ForwardRequest memory req = IForwarder.ForwardRequest({
from: address(this),
to: vulnerableToken,
value: 0,
gas: 500000,
nonce: 0,
data: multicallData
});
forwarder.execute(req);
}
}Scenario B: Delegatecall CALLER Preservation — Proxy Takeover
// Shared library intended to be called only via delegatecall
contract WalletLibrary {
address public owner;
function initWallet(address _owner) public {
require(owner == address(0), "already initialized");
owner = _owner;
}
function execute(address to, uint256 value, bytes calldata data) public {
require(msg.sender == owner, "not owner");
(bool success,) = to.call{value: value}(data);
require(success);
}
}
// Proxy delegates everything to library
contract WalletProxy {
address public library;
constructor(address _lib) { library = _lib; }
fallback() external payable {
(bool s, bytes memory r) = library.delegatecall(msg.data);
require(s);
assembly { return(add(r, 0x20), mload(r)) }
}
}
// Attack: call initWallet directly through the proxy AFTER deployment
// msg.sender is preserved by delegatecall, so attacker becomes owner
// attacker.call(proxy.initWallet(attacker_address))
// Now attacker can call execute() to drain all fundsScenario C: tx.origin Phishing via Malicious Contract
// Vulnerable vault using tx.origin (NOT msg.sender)
contract VulnerableVault {
address public owner;
constructor() { owner = msg.sender; }
function withdrawAll(address payable to) external {
// WRONG: checks tx.origin instead of msg.sender
require(tx.origin == owner, "not owner");
to.transfer(address(this).balance);
}
}
// Attacker tricks the vault owner into interacting with this contract
contract PhishingContract {
VulnerableVault immutable vault;
address payable immutable attacker;
constructor(VulnerableVault _vault) {
vault = _vault;
attacker = payable(msg.sender);
}
// Owner is tricked into sending a small tx to this contract
receive() external payable {
// tx.origin is still the vault owner!
// msg.sender to the vault is this contract, but the vault checks tx.origin
vault.withdrawAll(attacker);
}
}Scenario D: Reentrancy Exploiting Preserved msg.sender
contract VulnerablePool {
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);
// ETH sent before state update -- msg.sender gets a callback
(bool success,) = msg.sender.call{value: amount}("");
require(success);
// State update happens AFTER the external call
balances[msg.sender] = 0;
}
}
contract ReentrancyAttacker {
VulnerablePool immutable pool;
constructor(VulnerablePool _pool) { pool = _pool; }
function attack() external payable {
pool.deposit{value: msg.value}();
pool.withdraw();
}
// Re-entrant callback: msg.sender is this contract in both calls.
// balances[msg.sender] hasn't been zeroed yet.
receive() external payable {
if (address(pool).balance >= pool.balances(address(this))) {
pool.withdraw();
}
}
}Mitigations
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: Delegatecall CALLER confusion | Prevent direct calls to implementation contracts | Use OpenZeppelin’s _disableInitializers() in the implementation constructor; add onlyDelegateCall modifier |
| T1: Proxy ownership takeover | Initialize implementation immediately on deployment | Deploy and initialize atomically in the same transaction |
| T2: ERC-2771 address spoofing | Never combine ERC-2771 with Multicall without patching | Use OpenZeppelin >= 4.9.4 or 5.0.1 which fix the Multicall + ERC2771 interaction |
| T2: Forwarder compromise | Use immutable trusted forwarder; validate forwarder rigorously | address public immutable trustedForwarder — cannot be upgraded or changed |
| T3: Reentrancy | Checks-Effects-Interactions pattern; reentrancy guards | Use OpenZeppelin’s ReentrancyGuard; update state before external calls |
| T3: Cross-contract reentrancy | Global reentrancy locks across related contracts | Transient storage locks (EIP-1153, available post-Dencun) for gas-efficient cross-contract guards |
| T4: Single-key access control | Multi-sig wallets, timelocks, role-based access | Use Gnosis Safe for admin; OpenZeppelin AccessControl for roles; TimelockController for delays |
| T4: Vanity address compromise | Never use vanity address generators for admin keys | Use hardware wallets; derive keys from standard BIP-39/BIP-44 paths |
| T5: Confused deputy | Minimize token approvals; use permit with deadlines | EIP-2612 permit with nonces and expiry; revoke unused approvals |
| General | Defense-in-depth beyond msg.sender | Combine msg.sender checks with timelocks, multi-sig, rate limits, and pause mechanisms |
Compiler/EIP-Based Protections
- Solidity >= 0.8.0: High-level external calls revert if the target has no code, reducing confused-deputy risk with empty addresses.
- EIP-1153 (Transient Storage, Dencun): Enables gas-efficient reentrancy guards that persist within a transaction but are cleared afterward.
- EIP-4337 (Account Abstraction): Shifts authentication from raw
msg.senderto validatedUserOperationsignatures, enabling multi-factor and social recovery wallets. - OpenZeppelin >= 4.9.4 / 5.0.1: Patches the ERC-2771 + Multicall address spoofing vulnerability by ensuring
_msgSender()is correctly scoped within delegatecall subcalls.
Severity Summary
| Threat ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | Critical | Medium | Parity Wallet (150M frozen) |
| T2 | Smart Contract | Critical | Medium | Thirdweb/TIME ($190K+, 9,800+ contracts vulnerable) |
| T3 | Smart Contract | High | High | Curve Finance (60M, 2016) |
| T4 | Smart Contract | High | High | Wintermute ($160M), numerous key compromises |
| T5 | Smart Contract | Medium | Medium | DEX aggregator approval exploits |
| P1 | Protocol | Low | N/A | — |
| P2 | Protocol | Low | N/A | — |
| P3 | Protocol | Medium | Medium | EIP-4337 adoption breaking msg.sender == tx.origin guards |
| P4 | Protocol | Low | Low | — |
Related Opcodes
| Opcode | Relationship |
|---|---|
| ORIGIN (0x32) | Returns tx.origin (original EOA), not immediate caller. Using ORIGIN for auth enables phishing; CALLER is the safer alternative. |
| ADDRESS (0x30) | Returns the current contract’s own address. CALLER == ADDRESS means the contract called itself. |
| DELEGATECALL (0xF4) | Preserves CALLER from the parent context — the primary mechanism that changes CALLER’s meaning in proxy patterns. |
| CALL (0xF1) | Sets CALLER to the calling contract’s address in the new execution context. Standard call that does NOT preserve the parent’s CALLER. |
| STATICCALL (0xFA) | Like CALL but read-only. CALLER behaves identically but the callee cannot modify state. |
| CALLCODE (0xF2) | Deprecated precursor to DELEGATECALL. Sets msg.sender to the calling contract (unlike DELEGATECALL which preserves it). |