Reentrancy

The Four Faces of Reentrancy

by VirtualZero Research · 12 min read · Solidity 0.8+ · EVM

Reentrancy remains one of the most misclassified vulnerabilities in smart contracts. Since the famous TheDAO hack (2016), the EVM has evolved — but so have the variants. This article breaks down the four distinct types of reentrancy every EVM developer must understand.

1. Single‑Function Reentrancy

The classic case: a function makes an external call before updating state, and the callee re‑enters the same function.

// ❌ VULNERABLE
pragma solidity ^0.8.0;

contract EtherStore {
    mapping(address => uint256) public balances;

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0);

        // external call BEFORE state update
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok);

        balances[msg.sender] = 0; // too late
    }

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }
}
Attack flow: Attacker calls withdraw() → receives ether → fallback() calls withdraw() again → state not yet zeroed → drain.

Mitigation: Checks-Effects-Interactions pattern, or use a reentrancy guard (e.g., OpenZeppelin's ReentrancyGuard).

2. Cross‑Function Reentrancy

The attacker re‑enters a different function of the same contract that shares the same inconsistent state.

// ❌ VULNERABLE
contract CrossFunction {
    mapping(address => uint256) public credits;
    bool public locked;

    function redeem() external {
        require(!locked);
        uint256 amount = credits[msg.sender];
        require(amount > 0);

        locked = true;
        (bool ok, ) = msg.sender.call{value: amount}(""); // re‑enters borrow()
        require(ok);
        credits[msg.sender] = 0;
        locked = false;
    }

    function borrow() external {
        require(!locked);
        // uses credits[] as collateral — but redeem() hasn't updated it yet
        uint256 loan = credits[msg.sender] / 2;
        credits[msg.sender] -= loan; // underflow if not careful
        // ...
    }
}

Key insight: The attacker calls redeem(), then during the external call, calls borrow() which reads the still‑non‑zero credits[msg.sender].

Mitigation: Apply a mutex (reentrancy guard) across all external functions that touch the same state.

3. Cross‑Contract Reentrancy

The re‑entrancy happens across two different contracts that share state – often via a callback or a proxy.

// Contract A (pool)
function withdraw() external {
    uint256 amt = balances[msg.sender];
    // calls B.getReward()
    (bool ok, ) = msg.sender.call{value: amt}("");
    require(ok);
    balances[msg.sender] = 0;
}

// Contract B (reward manager)
function getReward() external view returns (uint256) {
    // reads A.balances[msg.sender] — still non‑zero during reentrancy
    return A.balances(msg.sender) / 10;
}

Real‑world example: Uniswap V1 ETH‑>token swap used a callback; a malicious token contract could re‑enter the swap function before the ETH transfer was finalised.

Mitigation: Avoid cross‑contract calls that assume external state consistency. Use a guard that covers the entire contract boundary.

4. Read‑Only Reentrancy

The most subtle variant. The attacker does not modify state but re‑enters a view / pure function that returns a value used by a third party (oracle, liquidation, accounting).

// ❌ VULNERABLE — read-only reentrancy
contract Vault {
    uint256 public totalAssets;
    mapping(address => uint256) public shares;

    function redeem(uint256 sharesToBurn) external returns (uint256) {
        uint256 assets = (sharesToBurn * totalAssets) / totalSupply();
        // external call before state update
        (bool ok, ) = msg.sender.call{value: assets}("");
        require(ok);
        _burn(msg.sender, sharesToBurn);
    }

    // view function returns inflated value during reentrancy
    function totalAssets() public view returns (uint256) {
        return address(this).balance; // still includes the *not yet burned* shares
    }
}

Impact: A liquidator or protocol that uses totalAssets() as a price oracle will see a manipulated value during the reentrancy window.

Mitigation: Never use address(this).balance or similar mutable state inside view functions that are used for critical accounting. Apply a reentrancy guard even on view calls if they are used in a sensitive context.

Summary Table

| Type               | Re‑enters same function? | Re‑enters different function? | Cross‑contract? | Read‑only? |
|--------------------|--------------------------|-------------------------------|-----------------|------------|
| Single‑function    | ✅                       | ❌                            | ❌              | ❌         |
| Cross‑function     | ❌                       | ✅                            | ❌              | ❌         |
| Cross‑contract     | possible                 | possible                      | ✅              | ❌         |
| Read‑only          | ❌                       | ❌                            | ❌              | ✅         |

Always ask: “Can an external call before state update lead to a re‑entry that sees stale data?” If yes, protect it.

← Back to Research