ChainScore Labs
All Guides

Reentrancy Attacks Explained with EVM Call Flow

LABS

Reentrancy Attacks Explained with EVM Call Flow

Chainscore © 2025

Core Concepts for Understanding Reentrancy

Essential EVM and Solidity mechanics that enable reentrancy attacks.

EVM Call Context

The Ethereum Virtual Machine (EVM) executes calls within a single-threaded environment. A call can be an external transaction or a message call between contracts. Crucially, the EVM allows a called contract to make a new call back to its caller before the initial execution finishes. This lack of atomicity in state changes is the foundational vulnerability exploited in reentrancy attacks.

Fallback & Receive Functions

Fallback functions are unnamed functions in Solidity that execute when a contract receives plain Ether with no data, or when no other function matches. The receive() function is a specialized fallback for plain Ether transfers. Attackers design malicious contracts with these functions to automatically trigger a callback to the vulnerable contract, initiating the reentrant loop during a funds transfer.

State Variable Updates

State variables are stored persistently on the blockchain. In a classic reentrancy attack, the vulnerable contract's critical state (like a user's balance) is updated after an external call. For example, balances[msg.sender] -= amount; should happen before msg.sender.call{value: amount}(""). If the order is reversed, the attacker's contract can re-enter and withdraw funds repeatedly before their balance is decremented.

Gas and the .call() Method

The low-level .call() function in Solidity forwards all remaining gas by default, unlike older methods like .transfer() or .send(). This provides the attacker's fallback function with sufficient computational resources to execute complex logic and multiple reentrant calls. The availability of gas is a key enabler for sophisticated multi-function reentrancy attacks that can drain a contract's entire balance in a single transaction.

Checks-Effects-Interactions Pattern

The Checks-Effects-Interactions pattern is the primary defensive coding practice against reentrancy. First, perform all checks (e.g., balance sufficient). Second, apply all effects to state variables (e.g., update the balance). Finally, perform external interactions (e.g., send Ether). This order ensures the contract's state is finalized and immune to manipulation before any external call is made, effectively breaking the reentrancy loop.

Reentrancy Guards

A reentrancy guard is a boolean state variable, often called locked or _notEntered, that acts as a mutex. It is set to true at the start of a function and false at the end. If a reentrant call attempts to re-enter the same function, the guard check will revert the transaction. The OpenZeppelin ReentrancyGuard contract provides a standardized, audited implementation of this pattern for developers.

Step-by-Step EVM Call Flow During a Normal Withdrawal

Process overview

1

User Initiates Withdrawal Transaction

The user calls a withdrawal function on the vulnerable contract.

Detailed Instructions

A user (EOA or contract) sends a transaction calling a function like withdraw(uint amount) on a target smart contract. This transaction is submitted to the network and included in a block. The msg.sender is set to the caller's address and the msg.value is zero for a standard ERC-20 withdrawal. The transaction's calldata specifies the function selector and the withdrawal amount as an argument. The EVM begins execution by loading the contract's bytecode and jumping to the function's entry point based on the selector.

  • Sub-step 1: User constructs transaction with calldata for withdraw(100) (where 100 is in the token's smallest unit).
  • Sub-step 2: Transaction is broadcast and validated by network nodes.
  • Sub-step 3: The executing EVM context sets msg.sender and loads the contract's state.
solidity
// User's transaction triggers this function function withdraw(uint256 _amount) public { require(balanceOf[msg.sender] >= _amount, "Insufficient balance"); // Function logic continues... }

Tip: The function's visibility (public) and the lack of a reentrancy guard are critical initial observations.

2

Contract Validates State and Updates Balances

The contract checks conditions and deducts the amount from the user's internal balance.

Detailed Instructions

The contract's execution context reads from storage to verify the user's internal balance. A require statement checks that balanceOf[msg.sender] >= _amount. If this passes, the contract proceeds to update its state. Crucially, the Checks-Effects-Interactions pattern is followed in a secure function: the state change (the effect) happens before any external call. The contract deducts the _amount from the user's mapped balance in storage. This update is a SSTORE opcode, modifying the contract's persistent state. The new balance is calculated and written, preventing the same funds from being withdrawn again in a subsequent call.

  • Sub-step 1: Execute SLOAD to read balanceOf[msg.sender] from storage slot.
  • Sub-step 2: Evaluate the require condition; revert if false.
  • Sub-step 3: Execute SSTORE to write balanceOf[msg.sender] = oldBalance - _amount.
solidity
// Secure order: Checks, then Effects, then Interactions require(balanceOf[msg.sender] >= _amount, "Insufficient balance"); balanceOf[msg.sender] -= _amount; // EFFECTS: State updated first // INTERACTION would come next

Tip: The gas cost for SSTORE is high, but this state update is essential for security.

3

Contract Performs External Token Transfer

The contract calls an external token contract to transfer funds to the user.

Detailed Instructions

After updating its internal ledger, the contract must transfer the actual tokens. It does this by making an external call to the ERC-20 token contract's transfer function. This is a low-level call (like call or transfer) or a high-level interface call. The EVM creates a new sub-context for this call: the msg.sender becomes the vulnerable contract's address, and the target is the token contract. The calldata encodes transfer(userAddress, amount). The EVM executes the token contract's code. If successful, the token contract updates its own state to reflect the transfer, and returns a boolean true. Control and execution resume in the original vulnerable contract.

  • Sub-step 1: Construct calldata: token.transfer(msg.sender, _amount).
  • Sub-step 2: EVM executes CALL opcode, switching context to token contract.
  • Sub-step 3: Token contract logic runs, transferring balance, and returns success.
solidity
// The external interaction bool success = IERC20(tokenAddress).transfer(msg.sender, _amount); require(success, "Transfer failed");

Tip: This CALL is the potential re-entry point if state wasn't updated first.

4

Execution Completes and State is Finalized

The function concludes, and the transaction's state changes are committed.

Detailed Instructions

After the external call returns successfully, execution flows back to the vulnerable contract's withdraw function. There may be a final require to check the transfer success. The function then hits an implicit or explicit STOP or RETURN opcode, concluding its execution. The EVM finalizes the state changes from this transaction: the user's internal balance in the vulnerable contract is reduced, and their balance in the external token contract is increased. All gas not consumed is refunded to the original transaction sender. A LOG event (e.g., Withdrawal) may be emitted. The transaction is now complete, and the global blockchain state is updated atomically; no intermediate state from this call flow is visible to other transactions.

  • Sub-step 1: Validate return data from external call (require(success, ...)).
  • Sub-step 2: Emit an event: emit Withdrawal(msg.sender, _amount).
  • Sub-step 3: Execute RETURN, finalizing all state changes in the block.
solidity
// Final steps in the function emit Withdrawn(msg.sender, _amount); // Function ends, implicit return occurs. }

Tip: The atomic nature of transactions ensures that if the external call fails and reverts, the initial balance deduction is also reverted.

Executing a Single-Function Reentrancy Attack

Process overview

1

Identify a Vulnerable Contract

Locate a contract with a flawed withdrawal pattern.

Detailed Instructions

First, analyze the target contract's state update logic. A classic vulnerability exists when a contract sends Ether before updating its internal balance tracking. Use a block explorer or static analysis tool to examine the contract's source code or bytecode. Look for a pattern where call.value() or transfer() is invoked on a user-provided address before a state variable like balances[msg.sender] is set to zero or reduced.

  • Sub-step 1: Decompile the contract bytecode using a tool like panoramix or review verified source code on Etherscan.
  • Sub-step 2: Trace the control flow of functions that handle user withdrawals or fund transfers.
  • Sub-step 3: Confirm the presence of an external call to the user (or a contract they control) that occurs prior to the crucial state update.
solidity
// Vulnerable pattern example function withdraw() public { uint amount = balances[msg.sender]; require(amount > 0); (bool success, ) = msg.sender.call{value: amount}(""); // State update AFTER external call require(success); balances[msg.sender] = 0; // Vulnerability: State updated too late }

Tip: Contracts using transfer() or send (which limit gas to 2300) can still be vulnerable if the attacker's fallback function is kept simple.

2

Deploy the Malicious Contract

Create an attacker contract with a reentrant fallback function.

Detailed Instructions

Write and deploy a contract designed to exploit the identified flaw. The attacker contract must have a fallback or receive function that re-invokes the vulnerable function on the target. It must also store the target's address and have a function to initiate the attack. The contract needs a way to receive the stolen funds.

  • Sub-step 1: Code the attacker contract. The receive() or fallback() function should call target.withdraw() again.
  • Sub-step 2: Include a public function like attack() to make the initial deposit and withdrawal call to the target.
  • Sub-step 3: Deploy the contract to the same network as the target, for example, using forge create or Remix IDE.
solidity
contract Attacker { VulnerableBank public target; constructor(address _target) { target = VulnerableBank(_target); } // Fallback function called when VulnerableBank sends Ether receive() external payable { if (address(target).balance >= 1 ether) { target.withdraw(); // Reentrant call } } function attack() external payable { require(msg.value == 1 ether); target.deposit{value: 1 ether}(); target.withdraw(); // Initial call } }

Tip: Use a condition in the fallback to limit recursion and avoid running out of gas or hitting the block gas limit.

3

Fund and Initiate the Attack

Deposit into the target and trigger the recursive withdrawal.

Detailed Instructions

Execute the attack sequence by first providing capital to the malicious contract and then calling its attack function. This step puts the exploit logic into motion, causing the recursive calls between the contracts.

  • Sub-step 1: Send Ether (e.g., 1 ETH) to the attacker contract's attack() function. This will deposit the funds into the vulnerable contract, establishing a legitimate balance.
  • Sub-step 2: The attack() function then calls target.withdraw() for the first time.
  • Sub-step 3: The vulnerable contract sends Ether to the attacker contract, triggering its receive() function before updating the attacker's balance to zero.

Monitor the transaction closely. The attacker's receive() function will call withdraw() again. Because the target contract has not yet updated the balances[msg.sender] state variable, the require(amount > 0) check will still pass, allowing another withdrawal. This loop continues until a stopping condition is met, such as the target's balance being drained below a threshold or gas constraints.

Tip: Test the attack on a forked mainnet or a local testnet (e.g., Anvil) first to estimate gas costs and ensure the recursion terminates correctly.

4

Analyze the Post-Attack State

Verify the exploit succeeded and examine the final balances.

Detailed Instructions

After the transaction is mined, inspect the on-chain state to confirm the attack's success. The key indicators are the Ether balances of the contracts and the internal accounting of the vulnerable contract.

  • Sub-step 1: Check the Ether balance of the vulnerable contract using await ethers.provider.getBalance(targetAddress). It should be significantly drained, often to zero or near-zero.
  • Sub-step 2: Check the Ether balance of the attacker contract. It should now hold most or all of the funds originally in the target.
  • Sub-step 3: Query the vulnerable contract's state variable for the attacker's address (e.g., balances[attackerAddress]). Due to the reentrancy, this may still show the initial deposit amount, proving the state was never properly updated.

This analysis demonstrates the core failure: the contract's internal ledger does not match its actual Ether holdings. The attacker has withdrawn their deposit multiple times because the state was stale during the recursive calls. This discrepancy is the definitive proof of a successful single-function reentrancy attack.

Tip: Review the transaction trace in a tool like Tenderly or Etherscan's "State Changes" tab to visualize the sequence of calls and balance changes step-by-step.

Reentrancy Attack Patterns and Examples

Comparison of attack types, their mechanisms, and real-world impact.

Attack PatternMechanismVulnerable StateReal-World Example / Impact

Single-Function Reentrancy

Attacker re-enters the same vulnerable function before state updates.

Balance before withdrawal

The DAO Hack (2016), ~$60M drained from TheDAO smart contract.

Cross-Function Reentrancy

Attacker re-enters a different function that shares state with the vulnerable one.

Shared storage variable (e.g., user balance)

Uniswap/Lendf.Me hack (2020), $25M exploited via ERC777 tokens.

Read-Only Reentrancy

Attack exploits view functions or oracles reading inconsistent intermediate state.

Intermediate contract state during a call

2022 Lodestar Finance exploit, where a price oracle read manipulated collateral balances.

Deferred Reentrancy

Attack uses a callback or event to trigger reentrancy after the initial call.

Post-transaction state or external contract

More complex DeFi composability attacks, often involving flash loans and multiple protocols.

Cross-Contract Reentrancy

State inconsistency is exploited across multiple interdependent contracts.

Shared state across a protocol's contract system

Attacks on complex DeFi vaults or lending protocols with multiple integrated components.

Gas-Limited Reentrancy

Attack fails if the reentrant call runs out of gas, a mitigation via gas stipends.

Gas remaining in the call context

Early Ethereum attacks mitigated by limiting gas forwarded to external calls (e.g., using transfer).

Implementing the Checks-Effects-Interactions Pattern

Process overview

1

Validate All Preconditions

Perform security and state checks before any state changes.

Detailed Instructions

Begin by executing all validation logic to ensure the transaction is legitimate and safe to proceed. This includes verifying caller permissions, checking input parameters, and confirming the current contract state meets the required conditions.

  • Sub-step 1: Use require() statements to validate function arguments, e.g., require(msg.value == 1 ether, "Incorrect payment").
  • Sub-step 2: Check access control modifiers or internal state, such as require(balances[msg.sender] >= amount, "Insufficient balance").
  • Sub-step 3: Verify that the contract is not in a paused or locked state by checking a boolean flag like paused.
solidity
function withdraw(uint256 amount) public { // CHECKS require(balances[msg.sender] >= amount, "Insufficient balance"); require(!locked, "Reentrancy lock active"); }

Tip: Perform checks against state variables that are stored in storage, as reading is a safe operation that cannot trigger reentrancy.

2

Update Internal State (Effects)

Apply all state changes to storage variables.

Detailed Instructions

After all checks pass, immediately update the contract's persistent state. This step modifies storage variables to reflect the new state before any external calls are made, which is the core defense against reentrancy.

  • Sub-step 1: Deduct the withdrawal amount from the user's internal balance mapping: balances[msg.sender] -= amount;.
  • Sub-step 2: Update a total supply or accumulator variable, e.g., totalWithdrawn += amount;.
  • Sub-step 3: Set any status flags, such as a reentrancy guard locked = true; if using a mutex.
solidity
function withdraw(uint256 amount) public { require(balances[msg.sender] >= amount); // EFFECTS balances[msg.sender] -= amount; totalWithdrawn += amount; }

Tip: The state is now in its final, post-transaction form. Even if reentrancy occurs later, the attacker's balance is already zeroed out, preventing double-spending.

3

Execute External Calls (Interactions)

Perform safe external calls after state is finalized.

Detailed Instructions

Only after all state updates are complete, perform any external calls to other contracts or EOAs. This isolates the dangerous interaction phase, ensuring your contract's state is already consistent and cannot be maliciously manipulated by a callback.

  • Sub-step 1: Use call or transfer to send Ether: (bool success, ) = msg.sender.call{value: amount}("");.
  • Sub-step 2: Always check the return value of low-level calls: require(success, "Transfer failed");.
  • Sub-step 3: Interact with other smart contracts via their interface, e.g., IERC20(token).transfer(to, amount);.
solidity
function withdraw(uint256 amount) public { require(balances[msg.sender] >= amount); balances[msg.sender] -= amount; // INTERACTIONS (bool sent, ) = msg.sender.call{value: amount}(""); require(sent, "Failed to send Ether"); }

Tip: Consider using the Pull Over Push pattern for payments, where users withdraw funds themselves, to further minimize risks from external calls.

4

Apply a Reentrancy Guard

Add a non-reentrant modifier as a secondary defense layer.

Detailed Instructions

While the CEI pattern is primary, combine it with a reentrancy guard for critical functions. This is a boolean lock that prevents any function from being called recursively within the same transaction, providing a robust safety net.

  • Sub-step 1: Define a private boolean state variable: bool private locked;.
  • Sub-step 2: Create a modifier that checks and sets the lock: modifier nonReentrant() { require(!locked, "No reentrancy"); locked = true; _; locked = false; }.
  • Sub-step 3: Apply the nonReentrant modifier to the vulnerable function alongside the CEI pattern.
solidity
bool private locked; modifier nonReentrant() { require(!locked, "Reentrant call"); locked = true; _; locked = false; } function withdraw(uint256 amount) public nonReentrant { // CEI pattern inside the guarded function require(balances[msg.sender] >= amount); balances[msg.sender] -= amount; (bool sent, ) = msg.sender.call{value: amount}(""); require(sent, "Transfer failed"); }

Tip: For simpler integration, use OpenZeppelin's ReentrancyGuard contract, which implements an optimized gas-efficient guard using uint256 instead of bool.

5

Test with Malicious Contracts

Validate your implementation against attack simulations.

Detailed Instructions

Thoroughly test your pattern implementation by deploying a malicious attacker contract designed to exploit reentrancy. Use a development framework like Foundry or Hardhat to simulate the attack flow and verify your defenses hold.

  • Sub-step 1: Write an attacker contract with a receive() or fallback() function that calls back into your vulnerable function.
  • Sub-step 2: In a test, deploy both contracts, fund the victim, and have the attacker initiate the exploit attempt.
  • Sub-step 3: Use assertions to check that the victim's final state (e.g., total balance) is correct and the attacker did not drain extra funds.
solidity
// Example Foundry test snippet function test_CEI_ResistsReentrancy() public { Attacker attacker = new Attacker(address(victimContract)); vm.deal(address(victimContract), 10 ether); // Attempt the attack attacker.attack(); // Assert the victim's balance is correctly reduced only once assertEq(address(victimContract).balance, 9 ether); }

Tip: Also test edge cases like multiple nested reentrant calls and interactions with other state-changing functions to ensure the guard and CEI pattern work comprehensively.

Advanced Mitigation Techniques and Trade-offs

Foundational Security Patterns

Reentrancy attacks exploit the ability of a malicious contract to call back into a vulnerable function before its state is finalized. The primary defense is the Checks-Effects-Interactions (CEI) pattern, which mandates the order of operations: first validate all conditions (Checks), then update all internal state (Effects), and only then interact with external contracts (Interactions). This prevents an attacker from manipulating a contract in an inconsistent state.

Key Mitigation Strategies

  • CEI Pattern: Always update balances and state variables before making external calls. This is the most critical and widely adopted rule.
  • Reentrancy Guards: Simple boolean locks (like OpenZeppelin's ReentrancyGuard) prevent recursive calls into a function. They are effective but can be bypassed if multiple functions share state.
  • Pull-over-Push Payments: Instead of "pushing" funds to users (transfer, send, call), design systems where users "pull" their funds. This is seen in protocols like Uniswap V3, where liquidity providers must call a separate function to collect fees.

Practical Limitation

While CEI is robust, it requires disciplined development. A single misplaced external call in a complex function, such as a multi-step swap in a DEX aggregator, can reintroduce the vulnerability even if other parts follow the pattern.

SECTION-AUDIT_FAQ

Reentrancy in Security Audits: Common Questions

Ready to Start Building?

Let's bring your Web3 vision to life.

From concept to deployment, ChainScore helps you architect, build, and scale secure blockchain solutions.