Skip to main content
Sigvex

Callback State Mutation

Detects state mutations after external calls that may trigger callbacks, violating the Checks-Effects-Interactions pattern.

Callback State Mutation

Overview

The callback state mutation detector identifies functions that update storage state after making external calls. When a contract calls an external address, the recipient may execute a callback into the calling contract (via ERC-777 tokensReceived, ERC-721 onERC721Received, ERC-1155 onERC1155Received, or custom hooks). If the calling contract writes to storage after the callback returns, the callback may have already re-entered and observed stale state.

This pattern is responsible for $500M+ in real-world losses, including the Curve/Vyper reentrancy exploit ($70M+) and multiple DEX/AMM exploits during 2023-2024.

Why This Is an Issue

The Checks-Effects-Interactions (CEI) pattern requires that all state changes happen before external calls. When state updates follow an external call, any callback triggered by that call can re-enter the contract and operate on the pre-update state. For token transfer functions, this means balances can be double-spent.

Unlike traditional reentrancy (which focuses on the re-entrant call itself), this detector targets the ordering violation: the fact that storage writes happen after the point where callbacks execute.

How to Resolve

// Before: Vulnerable -- state update after external call
function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount);
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok);
    balances[msg.sender] -= amount;  // Too late -- callback already ran
}

// After: Fixed -- state update before external call (CEI)
function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount);
    balances[msg.sender] -= amount;  // Effect first
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok);
}

Examples

Vulnerable

function transferWithHook(address to, uint256 amount) external {
    IERC20(token).transfer(to, amount);       // External call -- may trigger callback
    userDeposits[msg.sender] -= amount;        // State update after callback
    emit Transfer(msg.sender, to, amount);
}

Fixed

function transferWithHook(address to, uint256 amount) external nonReentrant {
    userDeposits[msg.sender] -= amount;        // Effect before interaction
    IERC20(token).transfer(to, amount);
    emit Transfer(msg.sender, to, amount);
}

Sample Sigvex Output

[HIGH] callback-state-mutation
  State mutation after external call in transfer function 'withdraw'
  Location: withdraw @ block 2, instruction 5
  Confidence: 0.75

  Function 'withdraw' performs external call at block 1 (inst 3) followed
  by state change at block 2 (inst 5). This violates Checks-Effects-
  Interactions (CEI) pattern.

Detection Methodology

  1. External call identification: Locates CALL and DELEGATECALL instructions that may trigger callbacks.
  2. Post-call storage write scanning: Finds SSTORE operations that execute after the external call within the same function.
  3. Reentrancy guard detection: Checks for early SLOAD patterns in the first few instructions that indicate a mutex guard, reducing false positives.
  4. Transfer function prioritization: Functions with names containing transfer, withdraw, claim, mint, or burn are flagged at higher severity.

Limitations

False positives: Functions that use STATICCALL (read-only) may be grouped with state-changing calls. Functions protected by a reentrancy guard are reported at reduced confidence. False negatives: Indirect callbacks through multi-hop call chains spanning multiple contracts are not traced.

References