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.
Recommended Fix
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 "@openzeppelin/contracts/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.