Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0x36 |
| Mnemonic | CALLDATASIZE |
| Gas | 2 |
| Stack Input | (none) |
| Stack Output | len(msg.data) |
| Behavior | Pushes the byte length of the transaction’s calldata onto the stack. Returns 0 for calls with no data (e.g., plain ETH transfers). The value is always an unsigned integer in range [0, ~max_tx_size]. |
Threat Surface
CALLDATASIZE is a pure environment-reading opcode — it queries the length of the input data attached to the current message call. Despite being trivially simple, it sits at the center of several critical security patterns because calldata length is the first line of defense for input validation. When CALLDATASIZE is misused or omitted, the consequences cascade through the entire call:
-
Input length validation: Contracts that parse calldata manually (in assembly or via low-level patterns) rely on CALLDATASIZE to ensure that read operations (CALLDATALOAD, CALLDATACOPY) don’t extend beyond the actual data boundary. Reading past the calldata boundary silently returns zero-padded bytes instead of reverting, creating a class of truncation vulnerabilities.
-
Proxy fallback dispatch: Proxy contracts and minimal proxies (EIP-1167) use
calldatasize()in their fallback functions to determine how much data to copy and forward viadelegatecall. An incorrect size calculation corrupts the forwarded call or causes silent failures. -
Function selector gating: The Solidity compiler emits
calldatasize() >= 4as the first check in the dispatcher. If calldata is shorter than 4 bytes, the contract falls through to the fallback or receive function. Contracts that skip this check or implement custom dispatchers may misroute calls. -
Gas optimization abuse:
calldatasize()is one of the cheapest ways to test for zero (2 gas). Some contracts useiszero(calldatasize())as a proxy for “is this a plain ETH transfer?” — a pattern that breaks when data is included with ETH sends. -
Meta-transaction suffix attacks: ERC-2771 meta-transaction protocols append the original sender address (20 bytes) to the end of calldata. If the contract doesn’t validate
calldatasize()is large enough to contain this suffix, it reads out-of-bounds data (zero-padded), resolving the sender toaddress(0)or corrupted addresses.
Smart Contract Threats
T1: Missing Length Validation on Truncated Inputs (High)
When contracts parse calldata manually using calldataload() in assembly, reading beyond calldatasize() does not revert — the EVM silently returns zero-padded bytes. This means an attacker can send truncated calldata where trailing parameters are implicitly set to zero:
function transfer(address to, uint256 amount) external {
// If attacker sends only 4 + 32 bytes (selector + 'to', omitting 'amount'),
// Solidity's ABI decoder reads 'amount' as 0 from zero-padded calldata.
// Pre-0.5.0 Solidity did NOT check calldatasize against expected length.
}Before Solidity 0.5.0, the ABI decoder did not validate that calldatasize() matched the expected parameter length. Contracts compiled with earlier versions accept truncated calldata, silently zero-filling missing parameters. This allows attackers to call functions with fewer arguments than expected, potentially bypassing validation that assumes all parameters are present.
Contracts that use inline assembly to read calldata (calldataload, calldatacopy) without bounds-checking against calldatasize() remain vulnerable regardless of Solidity version.
T2: Proxy Fallback Without Size Checks (High)
Proxy contracts forward calldata to an implementation via delegatecall. The standard pattern copies all calldata to memory, then delegates:
fallback() external payable {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}Vulnerabilities arise when:
- Implementation address is computed from calldata itself: If the proxy reads the target address from calldata without verifying
calldatasize()is sufficient, truncated calldata can zero the implementation address, delegating toaddress(0)(which succeeds as a no-op via STOP). - Custom proxies that split calldata: Some proxies interpret part of the calldata as routing metadata and the rest as the forwarded payload. If the split point isn’t validated against
calldatasize(), the payload becomes truncated or empty. - Minimal proxies (EIP-1167) themselves are safe since they forward all calldata unconditionally, but custom variants that add routing logic must validate sizes.
T3: CALLDATASIZE as a Zero-Test for ETH Receive (Medium)
A common gas-optimization pattern uses calldatasize() to detect plain ETH transfers:
assembly {
if iszero(calldatasize()) {
// Assume this is a plain ETH transfer, handle receive logic
// ...
stop()
}
// Otherwise, proceed with function dispatch
}This breaks when:
- ETH is sent with calldata (e.g.,
addr.call{value: 1 ether}("0x1234")) — the receive logic is skipped even though ETH was sent. - A contract sends zero-length calldata but doesn’t intend it as an ETH transfer (e.g., calling a function with no arguments using raw
.call("")). - The pattern is used in a receive/fallback context where the distinction between
receive()andfallback()matters.
T4: Calldata Length Manipulation in Meta-Transactions (Critical)
ERC-2771 meta-transaction protocols append the original sender’s address (20 bytes) to the end of calldata. The _msgSender() function extracts this suffix:
function _msgSender() internal view returns (address sender) {
if (msg.sender == trustedForwarder) {
assembly {
sender := shr(96, calldataload(sub(calldatasize(), 20)))
}
} else {
sender = msg.sender;
}
}If calldatasize() is less than 20 bytes, sub(calldatasize(), 20) underflows (wraps to a huge number), and calldataload at that offset returns zero-padded bytes. The sender resolves to address(0) or an attacker-controlled value.
When combined with multicall() using delegatecall, this becomes critically exploitable: the attacker crafts calldata for each subcall within the multicall to contain a spoofed sender suffix. The trusted forwarder check passes on the outer call, and each inner delegatecall preserves msg.sender, allowing the attacker to impersonate any address.
T5: Oversized Calldata Causing Memory Expansion Griefing (Medium)
While CALLDATASIZE itself costs only 2 gas, subsequent operations that copy all calldata to memory (calldatacopy(0, 0, calldatasize())) pay for memory expansion. An attacker can submit a transaction with extremely large calldata to force expensive memory expansion in contracts that blindly copy all calldata:
// Vulnerable: copies ALL calldata regardless of size
assembly {
calldatacopy(0, 0, calldatasize())
// Memory expansion cost is quadratic for large sizes
}The intrinsic gas cost for calldata (4 gas per zero byte, 16 gas per non-zero byte) provides some natural rate-limiting, but memory expansion’s quadratic cost component means a sufficiently large calldata payload can cause disproportionate gas consumption within the contract.
Protocol-Level Threats
P1: No Direct DoS Vector (Low)
CALLDATASIZE costs a fixed 2 gas with no dynamic component. It reads an environment value and pushes it to the stack. It cannot be used for gas griefing by itself.
P2: Consensus Safety (Low)
CALLDATASIZE is trivially deterministic — it returns the byte length of the input data, which is fixed at the start of the call and identical across all EVM implementations. No known consensus divergence has occurred due to CALLDATASIZE.
P3: Calldata Size Limits Across Contexts (Low)
The maximum calldata size depends on the context:
- External transactions: Limited by the block gas limit (calldata costs 4/16 gas per zero/non-zero byte). At 30M gas, theoretical max is ~7.5MB of zero bytes or ~1.875MB of non-zero bytes.
- Internal calls: No calldata size limit beyond available gas for memory operations.
- L2 rollups: Calldata is the primary cost on rollups (stored on L1 as data availability). The economic incentive to minimize calldata is much stronger, but the protocol-level maximum is the same.
Contracts that assume calldata fits within a certain size (e.g., 2^16 bytes) may silently truncate or misindex on larger inputs.
P4: Calldata Immutability Within a Call Frame (Informational)
Calldata is read-only within a call frame — there is no CALLDATASTORE opcode. This means CALLDATASIZE is constant throughout execution of a single message call. This immutability is a security advantage: calldata cannot be modified by reentrancy or callbacks within the same frame. However, each nested CALL/DELEGATECALL creates a new call frame with its own calldata.
Edge Cases
| Edge Case | Behavior | Security Implication |
|---|---|---|
| Empty calldata (size = 0) | Returns 0; triggers receive() or fallback in Solidity | Contracts using iszero(calldatasize()) as ETH-receive detection may mishandle |
| Calldata with only selector (size = 4) | Returns 4; function matched but no arguments | Functions expecting parameters get zero-padded values if ABI decoder doesn’t validate |
| Calldata shorter than 4 bytes | Returns 1-3; Solidity dispatcher falls to fallback | Custom dispatchers that don’t check calldatasize() >= 4 may read partial selectors |
| Very large calldata (>100KB) | Returns the full size; memory expansion if copied | Quadratic memory cost can cause gas griefing in contracts that copy all calldata |
| Calldata with trailing garbage bytes | Returns full size including garbage | calldatasize() includes extra bytes; ABI decoder ignores them but manual parsing may not |
DELEGATECALL forwarding | Returns size of the forwarded calldata, not the original transaction | Proxies that add/remove prefix data change the effective calldatasize for the implementation |
| Internal call with crafted calldata | Returns whatever size the caller specified | Attacker contracts can call with arbitrary calldata sizes |
Real-World Exploits
Exploit 1: 1inch Fusion v1 Calldata Corruption — $5M Drained (March 2025)
Root cause: Missing validation on interactionLength read from untrusted calldata in the _settleOrder() function’s Yul assembly code. The code calculated a memory write offset using calldata-derived values without checking bounds against calldatasize().
Details: The attacker crafted a malicious payload where interactionLength was set to -512 (0xFFFF...FE00 in two’s complement). The vulnerable assembly code computed a suffix write position as:
let offset := add(add(ptr, interactionOffset), interactionLength)This caused an integer underflow in the memory offset, writing a forged suffix containing the attacker’s address where the legitimate resolver address should have been. The Settlement contract then called the attacker’s forged resolver instead of the legitimate one, enabling transfers of ~$5M in USDC and WETH.
CALLDATASIZE’s role: The vulnerability fundamentally stemmed from trusting length values derived from calldata without validating them against calldatasize() bounds. The crafted calldata was precisely sized to position the malicious payload so that the underflowed offset landed on the target memory location. Proper bounds checking of interactionLength against calldatasize() would have detected the negative/out-of-range value.
Impact: ~$5M drained from the TrustedVolumes resolver contract. Most funds were returned after negotiation. The exploit survived multiple audits because it required assembly-level understanding of calldata parsing rather than high-level logic analysis.
References:
- Olympix: 1inch Fusion v1 Calldata Corruption Analysis
- Decurity: Yul Calldata Corruption — 1inch Postmortem
- BlockSec: From Calldata Corruption to Forged Settlement
Exploit 2: ERC-2771 + Multicall Address Spoofing — Thirdweb Ecosystem (December 2023)
Root cause: Calldata length manipulation enabled arbitrary address spoofing in contracts combining ERC-2771 meta-transactions with Multicall’s delegatecall pattern.
Details: ERC-2771 appends the original sender’s address (20 bytes) to the end of calldata. The _msgSender() function uses calldatasize() to locate this suffix: calldataload(sub(calldatasize(), 20)). When combined with Multicall (which uses delegatecall to execute batched calls), an attacker could:
- Submit a transaction through the trusted forwarder (passing the
msg.sender == trustedForwardercheck). - Include multiple subcalls within the Multicall batch.
- Each subcall’s calldata is crafted to contain a spoofed 20-byte address suffix.
- Because
delegatecallpreservesmsg.sender, each subcall believes it came from the trusted forwarder and reads the attacker’s spoofed address viacalldatasize().
The attack allowed impersonation of any address, enabling theft of tokens from contracts that used _msgSender() for authorization.
CALLDATASIZE’s role: The suffix extraction relies entirely on calldatasize() to determine where the sender address is located. When delegatecall is used within Multicall, each subcall has its own calldata (and thus its own calldatasize()), which the attacker controls. The fix involved checking msg.data.length >= 20 before extracting the suffix and disabling Multicall-via-delegatecall for ERC-2771 contexts.
Impact: Exploited in the wild across multiple chains. Confirmed losses included 84.59 ETH, 17,394 USDC, and smaller amounts. Over 9,800 contracts across 37 chains required migration via Thirdweb’s mitigation tool.
References:
- OpenZeppelin: ERC2771Context Multicall Public Disclosure
- Thirdweb: Vulnerability Incident Report
- OpenZeppelin: Secure Implementations & Vulnerable Integrations
Exploit 3: Solidity Nested Calldata Array ABI-Reencoding Bug (Versions 0.5.8-0.8.13)
Root cause: The Solidity compiler’s code generator failed to validate nested dynamic array data against calldatasize() during ABI re-encoding, reading zero-padded values beyond the calldata boundary.
Details: When a contract received a nested dynamic array (e.g., uint256[][]) in calldata and re-encoded it (via abi.encode(), external calls, or events), the compiler should have verified that the nested array’s data area fell within [0, calldatasize()]. Instead, it deferred this validation, and when re-encoding, it read past the calldata boundary without reverting. The EVM returned zero-padded bytes for out-of-bounds reads, silently corrupting the re-encoded data.
This meant a malicious caller could send truncated calldata where nested array elements appeared valid (non-reverting) but contained attacker-controlled zero values, potentially bypassing validation logic that expected non-zero data.
CALLDATASIZE’s role: The bug was precisely a failure to check data offsets against calldatasize(). The fix (Solidity 0.8.14) added proper bounds checking to ensure all nested array data is fully contained within the calldata boundary before re-encoding.
Impact: Rated “very low” severity by the Solidity team because exploitation requires specific contract patterns (nested dynamic arrays re-encoded from calldata). However, the vulnerability class — reading beyond calldatasize() without reverting — is fundamental and has appeared in other contexts.
References:
- Solidity Blog: Nested Calldata Array ABI-Reencoding Size Check Bug
- Certora: Incorrect Calldata Validation Disclosure
Attack Scenarios
Scenario A: Truncated Calldata Bypassing Parameter Validation
// Pre-Solidity 0.5.0 or manual calldata parsing
contract VulnerableVault {
function withdraw(address to, uint256 amount, uint256 nonce) external {
require(nonce == userNonces[msg.sender], "bad nonce");
require(amount <= balances[msg.sender], "insufficient");
balances[msg.sender] -= amount;
userNonces[msg.sender]++;
payable(to).transfer(amount);
}
}
// Attack: send calldata with only selector + 'to' (36 bytes instead of 100).
// 'amount' and 'nonce' are zero-padded by the EVM.
// If userNonces[attacker] == 0, the nonce check passes.
// amount == 0, so no funds move -- but the nonce increments,
// potentially desynchronizing off-chain nonce tracking.Scenario B: Proxy Forwarding Corrupted Calldata
contract VulnerableRouter {
// Routes calls to different implementations based on a prefix byte
fallback() external payable {
assembly {
let target := shr(248, calldataload(0)) // first byte = target index
let impl := sload(target)
// Bug: copies from byte 1 onward, but uses calldatasize() as length
// instead of calldatasize() - 1. Forwards one extra zero byte.
calldatacopy(0, 1, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
// The forwarded calldata is now 1 byte too long (trailing zero).
// For most ABI-encoded calls this is harmless (trailing garbage ignored),
// but for contracts doing manual calldata parsing, the extra byte
// shifts all offset calculations.
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}Scenario C: ERC-2771 Sender Spoofing via Calldata Length
contract VulnerableMetaTxToken {
address public trustedForwarder;
mapping(address => uint256) public balances;
function _msgSender() internal view returns (address sender) {
if (msg.sender == trustedForwarder) {
// Bug: doesn't check calldatasize() >= 20
assembly {
sender := shr(96, calldataload(sub(calldatasize(), 20)))
}
} else {
sender = msg.sender;
}
return sender;
}
function transfer(address to, uint256 amount) external {
address sender = _msgSender();
// If attacker controls calldata via multicall + delegatecall,
// they can make sender == any address
require(balances[sender] >= amount);
balances[sender] -= amount;
balances[to] += amount;
}
}Scenario D: Gas Griefing via Oversized Calldata
contract VulnerableRelay {
function relay(address target) external {
assembly {
// Copies ALL calldata to memory, then forwards after the first 36 bytes
// Attacker sends 1MB of calldata: memory expansion costs ~3.8M gas
calldatacopy(0, 36, sub(calldatasize(), 36))
let ok := call(gas(), target, 0, 0, sub(calldatasize(), 36), 0, 0)
if iszero(ok) { revert(0, 0) }
}
}
}
// Attack: call relay() with 1MB of zero-byte calldata.
// Intrinsic calldata gas: ~4M gas (1M * 4 gas/zero-byte)
// Memory expansion: additional ~3.8M gas within the contract
// Total: ~7.8M gas, mostly wasted on memory the relay doesn't need.
// If the relay is called by another contract that pays gas on behalf of users,
// this becomes a griefing vector.Mitigations
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: Truncated calldata zero-padding | Use Solidity >= 0.5.0 (ABI decoder v2 validates calldatasize) | Default behavior; no action needed for high-level Solidity |
| T1: Manual calldata parsing | Always validate read offsets against calldatasize() | require(calldatasize() >= expectedLength) in assembly |
| T2: Proxy forwarding errors | Use audited proxy patterns (EIP-1167, OpenZeppelin) | Avoid custom proxy assembly; if necessary, test with edge-case calldata sizes (0, 1, 3, 4, very large) |
| T3: Zero-check misuse | Use Solidity’s receive() / fallback() distinction instead of calldatasize() | Let the compiler handle ETH receive vs function dispatch |
| T4: Meta-transaction spoofing | Validate msg.data.length >= 20 before extracting ERC-2771 suffix | Use OpenZeppelin’s patched ERC2771Context (>= v4.9.4 / v5.0.1) |
| T4: Multicall + ERC-2771 | Never combine delegatecall-based Multicall with ERC-2771 | Use call-based multicall, or disallow multicall through the trusted forwarder |
| T5: Memory expansion griefing | Limit how much calldata is copied to memory | let size := min(calldatasize(), MAX_EXPECTED_SIZE) before calldatacopy |
| General | Validate calldata length matches expected ABI encoding | require(msg.data.length == 4 + n * 32) for fixed-parameter functions |
Compiler/EIP-Based Protections
- Solidity >= 0.5.0: The ABI decoder v2 (default since 0.5.0, mandatory since 0.8.0) validates that
calldatasize()matches the expected ABI-encoded parameter length and reverts on short calldata. - Solidity >= 0.8.14: Fixes the nested calldata array re-encoding bug, adding proper
calldatasize()bounds checks for nested dynamic types. - OpenZeppelin ERC2771Context >= 4.9.4: Checks
msg.data.length >= 20before extracting the sender suffix, preventing underflow in thecalldatasize()subtraction. - EIP-1167 (Minimal Proxy): The standard bytecode unconditionally copies all calldata and forwards it, avoiding size-dependent logic entirely.
Severity Summary
| Threat ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | High | Medium | Solidity ABI decoder bugs; 1inch calldata corruption ($5M) |
| T2 | Smart Contract | High | Medium | Custom proxy implementations with size errors |
| T3 | Smart Contract | Medium | Low | Gas optimization anti-patterns |
| T4 | Smart Contract | Critical | Medium | ERC-2771 + Multicall spoofing (Thirdweb ecosystem, 9,800+ contracts) |
| T5 | Smart Contract | Medium | Low | Memory expansion griefing in relay contracts |
| P1 | Protocol | Low | N/A | — |
| P2 | Protocol | Low | N/A | — |
| P3 | Protocol | Low | Low | L2 calldata cost asymmetries |
Related Opcodes
| Opcode | Relationship |
|---|---|
| CALLDATALOAD (0x35) | Reads 32 bytes from calldata at a given offset; returns zero-padded data if offset + 32 > calldatasize, which is the root of truncation bugs |
| CALLDATACOPY (0x37) | Copies calldata to memory; uses calldatasize as the upper bound for safe copying. Over-copying causes memory expansion costs |
| CODESIZE (0x38) | Analogous opcode for bytecode length; similar size-based dispatch patterns (e.g., codesize used in constructor vs runtime detection) |
| RETURNDATASIZE (0x3D) | Similar environment query for return data length; used alongside CALLDATASIZE for end-to-end I/O validation |
| DELEGATECALL (0xF4) | Proxy forwarding creates new call frames with caller-specified calldata, making calldatasize attacker-controlled in the delegated context |
| STOP (0x00) | Calls to addresses with no code return success; combined with calldatasize-based dispatch, can cause silent no-ops |