Skip to main content
Sigvex

Remediating Callback State Mutation Vulnerabilities

How to fix state mutations after external calls by applying the Checks-Effects-Interactions pattern and reentrancy guards.

Remediating Callback State Mutation Vulnerabilities

Overview

Related Detector: Callback State Mutation

Callback state mutation occurs when a function writes to storage after making an external call. The external call may trigger a callback (via ERC-777 tokensReceived, ERC-721 onERC721Received, or custom hooks) that re-enters the contract before the storage write executes. The fix is to reorder operations: update state before making external calls. A reentrancy guard provides defense-in-depth.

Apply Checks-Effects-Interactions (CEI) Pattern

// BEFORE: State update after external call
function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient");
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "Transfer failed");
    balances[msg.sender] -= amount;  // Vulnerable: too late
}

// AFTER: State update before external call
function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient");
    balances[msg.sender] -= amount;  // Effect before interaction
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "Transfer failed");
}

Alternative Mitigations

Add a Reentrancy Guard (Defense-in-Depth)

Even with CEI ordering, add a reentrancy guard to prevent future regressions:

import "@audited/security/ReentrancyGuard.sol";

contract Vault is ReentrancyGuard {
    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount, "Insufficient");
        balances[msg.sender] -= amount;
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");
    }
}

Use Pull-Over-Push for Token Distributions

For batch distributions, let recipients claim instead of pushing tokens:

mapping(address => uint256) public pending;

function recordDistribution(address[] calldata recipients, uint256[] calldata amounts) external onlyOwner {
    for (uint256 i = 0; i < recipients.length; i++) {
        pending[recipients[i]] += amounts[i];
    }
}

function claim() external nonReentrant {
    uint256 amount = pending[msg.sender];
    pending[msg.sender] = 0;
    token.safeTransfer(msg.sender, amount);
}

Common Mistakes

Mistake: Protecting with CEI but Missing ERC-777 Hooks

// INCOMPLETE: CEI applied but ERC-777 hook triggers mid-transfer
function deposit(uint256 amount) external {
    balances[msg.sender] += amount;  // Effect first -- good
    IERC20(token).transferFrom(msg.sender, address(this), amount);
    // If token is ERC-777, tokensToSend hook fires DURING transferFrom,
    // allowing re-entry into deposit() before transfer completes
}

Add nonReentrant as defense-in-depth when interacting with arbitrary ERC-20 tokens, since some implement ERC-777 hooks.

Mistake: Guard on External Function but Not Internal Helper

function withdraw(uint256 amount) external nonReentrant {
    _withdraw(msg.sender, amount);  // Protected
}

// VULNERABLE: internal helper callable from other unprotected paths
function _withdraw(address user, uint256 amount) internal {
    (bool ok, ) = user.call{value: amount}("");
    require(ok);
    balances[user] -= amount;  // Still CEI violation
}

Apply CEI ordering in the internal helper, not just the external entry point.

References