Opcode Summary

PropertyValue
Opcode0x36
MnemonicCALLDATASIZE
Gas2
Stack Input(none)
Stack Outputlen(msg.data)
BehaviorPushes 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:

  1. 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.

  2. 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 via delegatecall. An incorrect size calculation corrupts the forwarded call or causes silent failures.

  3. Function selector gating: The Solidity compiler emits calldatasize() >= 4 as 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.

  4. Gas optimization abuse: calldatasize() is one of the cheapest ways to test for zero (2 gas). Some contracts use iszero(calldatasize()) as a proxy for “is this a plain ETH transfer?” — a pattern that breaks when data is included with ETH sends.

  5. 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 to address(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 to address(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() and fallback() 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 CaseBehaviorSecurity Implication
Empty calldata (size = 0)Returns 0; triggers receive() or fallback in SolidityContracts using iszero(calldatasize()) as ETH-receive detection may mishandle
Calldata with only selector (size = 4)Returns 4; function matched but no argumentsFunctions expecting parameters get zero-padded values if ABI decoder doesn’t validate
Calldata shorter than 4 bytesReturns 1-3; Solidity dispatcher falls to fallbackCustom dispatchers that don’t check calldatasize() >= 4 may read partial selectors
Very large calldata (>100KB)Returns the full size; memory expansion if copiedQuadratic memory cost can cause gas griefing in contracts that copy all calldata
Calldata with trailing garbage bytesReturns full size including garbagecalldatasize() includes extra bytes; ABI decoder ignores them but manual parsing may not
DELEGATECALL forwardingReturns size of the forwarded calldata, not the original transactionProxies that add/remove prefix data change the effective calldatasize for the implementation
Internal call with crafted calldataReturns whatever size the caller specifiedAttacker 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:


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:

  1. Submit a transaction through the trusted forwarder (passing the msg.sender == trustedForwarder check).
  2. Include multiple subcalls within the Multicall batch.
  3. Each subcall’s calldata is crafted to contain a spoofed 20-byte address suffix.
  4. Because delegatecall preserves msg.sender, each subcall believes it came from the trusted forwarder and reads the attacker’s spoofed address via calldatasize().

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:


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:


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

ThreatMitigationImplementation
T1: Truncated calldata zero-paddingUse Solidity >= 0.5.0 (ABI decoder v2 validates calldatasize)Default behavior; no action needed for high-level Solidity
T1: Manual calldata parsingAlways validate read offsets against calldatasize()require(calldatasize() >= expectedLength) in assembly
T2: Proxy forwarding errorsUse 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 misuseUse Solidity’s receive() / fallback() distinction instead of calldatasize()Let the compiler handle ETH receive vs function dispatch
T4: Meta-transaction spoofingValidate msg.data.length >= 20 before extracting ERC-2771 suffixUse OpenZeppelin’s patched ERC2771Context (>= v4.9.4 / v5.0.1)
T4: Multicall + ERC-2771Never combine delegatecall-based Multicall with ERC-2771Use call-based multicall, or disallow multicall through the trusted forwarder
T5: Memory expansion griefingLimit how much calldata is copied to memorylet size := min(calldatasize(), MAX_EXPECTED_SIZE) before calldatacopy
GeneralValidate calldata length matches expected ABI encodingrequire(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 >= 20 before extracting the sender suffix, preventing underflow in the calldatasize() subtraction.
  • EIP-1167 (Minimal Proxy): The standard bytecode unconditionally copies all calldata and forwards it, avoiding size-dependent logic entirely.

Severity Summary

Threat IDCategorySeverityLikelihoodReal-World Precedent
T1Smart ContractHighMediumSolidity ABI decoder bugs; 1inch calldata corruption ($5M)
T2Smart ContractHighMediumCustom proxy implementations with size errors
T3Smart ContractMediumLowGas optimization anti-patterns
T4Smart ContractCriticalMediumERC-2771 + Multicall spoofing (Thirdweb ecosystem, 9,800+ contracts)
T5Smart ContractMediumLowMemory expansion griefing in relay contracts
P1ProtocolLowN/A
P2ProtocolLowN/A
P3ProtocolLowLowL2 calldata cost asymmetries

OpcodeRelationship
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