Securite·8 min de lecture·Par Solingo

Reentrancy Attack Explained — How It Works and How to Prevent It

Understand the reentrancy vulnerability that led to The DAO hack and learn proven techniques to protect your smart contracts from this critical attack vector.

# Reentrancy Attack Explained — How It Works and How to Prevent It

Reentrancy is one of the most devastating vulnerabilities in smart contract security. The infamous DAO hack in 2016, which drained over $60 million worth of Ether, was caused by a reentrancy attack. Despite being well-known, reentrancy vulnerabilities continue to appear in modern contracts.

In this article, we'll explore how reentrancy attacks work, examine different types of reentrancy, and learn proven prevention techniques.

What Is a Reentrancy Attack?

A reentrancy attack occurs when an external contract call is made before the state is updated, allowing the called contract to re-enter the original function and exploit inconsistent state.

Classic Example: Vulnerable Withdrawal

contract VulnerableBank {

mapping(address => uint256) public balances;

function deposit() external payable {

balances[msg.sender] += msg.value;

}

// VULNERABLE: External call before state update

function withdraw(uint256 amount) external {

require(balances[msg.sender] >= amount, "Insufficient balance");

// External call BEFORE state update

(bool success, ) = msg.sender.call{value: amount}("");

require(success, "Transfer failed");

// State update happens AFTER external call

balances[msg.sender] -= amount;

}

}

The Attack

An attacker can create a malicious contract with a receive() or fallback() function that calls withdraw() again:

contract Attacker {

VulnerableBank public bank;

uint256 public constant AMOUNT = 1 ether;

constructor(address _bank) {

bank = VulnerableBank(_bank);

}

// Attack function

function attack() external payable {

require(msg.value >= AMOUNT);

bank.deposit{value: AMOUNT}();

bank.withdraw(AMOUNT);

}

// Reentry point

receive() external payable {

if (address(bank).balance >= AMOUNT) {

bank.withdraw(AMOUNT);

}

}

}

Attack flow:

  • Attacker deposits 1 ETH
  • Attacker calls withdraw(1 ETH)
  • Bank sends 1 ETH to attacker contract
  • Attacker's receive() is triggered
  • Attacker calls withdraw(1 ETH) again before balance is updated
  • Check passes (balance still shows 1 ETH)
  • Bank sends another 1 ETH
  • Loop continues until bank is drained
  • Types of Reentrancy Attacks

    1. Single-Function Reentrancy

    The classic case shown above, where the same function is called recursively.

    2. Cross-Function Reentrancy

    Reentering through a different function that shares state:

    contract CrossFunction {
    

    mapping(address => uint256) public balances;

    function withdraw() external {

    uint256 amount = balances[msg.sender];

    require(amount > 0);

    (bool success, ) = msg.sender.call{value: amount}("");

    require(success);

    balances[msg.sender] = 0;

    }

    // Different function, same state variable

    function transfer(address to, uint256 amount) external {

    require(balances[msg.sender] >= amount);

    balances[msg.sender] -= amount;

    balances[to] += amount;

    }

    }

    An attacker can call transfer() during the withdraw() callback, exploiting the stale balance.

    3. Cross-Contract Reentrancy

    When two contracts share state (common in upgradeable contracts or proxy patterns):

    contract TokenSale {
    

    mapping(address => uint256) public contributions;

    Token public token;

    function buyTokens() external payable {

    contributions[msg.sender] += msg.value;

    // External call to another contract

    token.mint(msg.sender, msg.value * 100);

    }

    }

    contract Token {

    TokenSale public sale;

    function mint(address to, uint256 amount) external {

    require(msg.sender == address(sale));

    // If to is malicious contract, it can reenter TokenSale

    _mint(to, amount);

    }

    }

    Prevention Techniques

    1. Checks-Effects-Interactions (CEI) Pattern

    Always update state before making external calls.

    function withdraw(uint256 amount) external {
    

    // Checks

    require(balances[msg.sender] >= amount, "Insufficient balance");

    // Effects (update state FIRST)

    balances[msg.sender] -= amount;

    // Interactions (external call LAST)

    (bool success, ) = msg.sender.call{value: amount}("");

    require(success, "Transfer failed");

    }

    This simple reordering prevents reentrancy because the balance is updated before the external call.

    2. ReentrancyGuard Modifier

    OpenZeppelin provides a battle-tested ReentrancyGuard:

    import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
    
    

    contract SafeBank is ReentrancyGuard {

    mapping(address => uint256) public balances;

    function withdraw(uint256 amount) external nonReentrant {

    require(balances[msg.sender] >= amount);

    (bool success, ) = msg.sender.call{value: amount}("");

    require(success);

    balances[msg.sender] -= amount;

    }

    }

    How it works:

    • Sets a lock flag before function execution
    • Reverts if function is called while lock is active
    • Releases lock after execution

    3. Pull Over Push Pattern

    Instead of pushing funds to users, let them pull:

    contract SafeWithdrawal {
    

    mapping(address => uint256) public pendingWithdrawals;

    function withdraw() external {

    uint256 amount = pendingWithdrawals[msg.sender];

    require(amount > 0, "No funds");

    pendingWithdrawals[msg.sender] = 0;

    (bool success, ) = msg.sender.call{value: amount}("");

    require(success);

    }

    }

    Each user manages their own withdrawal, eliminating complex state interactions.

    4. Mutex Locks

    For cross-contract reentrancy, use a shared mutex:

    contract MutexProtected {
    

    bool private locked;

    modifier noReentrancy() {

    require(!locked, "Reentrant call");

    locked = true;

    _;

    locked = false;

    }

    function criticalFunction() external noReentrancy {

    // Protected code

    }

    }

    Real-World Examples

    The DAO Hack (2016)

    • Impact: 3.6M ETH stolen (~$60M at the time)
    • Cause: Reentrancy in splitDAO() function
    • Result: Ethereum hard fork to ETH and ETC

    Uniswap V1 (Mitigated)

    Early Uniswap versions were vulnerable to reentrancy via ERC-777 tokens (which have hooks). V2+ implemented proper reentrancy guards.

    Cream Finance (2021)

    • Impact: $130M stolen
    • Cause: Flash loan + reentrancy in money market protocol
    • Lesson: Reentrancy + flash loans = devastating combination

    Detection and Testing

    Static Analysis

    Use tools like:

    • Slither: Detects reentrancy patterns
    • Mythril: Symbolic execution
    • Securify: Formal verification

    Manual Audit Checklist

    ✅ All external calls happen after state updates

    ✅ ReentrancyGuard on all functions with external calls

    ✅ No shared state between reentrant paths

    ✅ Pull pattern used where possible

    ✅ Cross-contract reentrancy considered

    Fuzzing

    Test with reentrancy scenarios:

    // Foundry test
    

    function testReentrancy() public {

    Attacker attacker = new Attacker(address(bank));

    vm.expectRevert("ReentrancyGuard: reentrant call");

    attacker.attack{value: 1 ether}();

    }

    Best Practices Summary

  • Always use CEI pattern — it's free and eliminates 90% of reentrancy risks
  • Add nonReentrant to all functions with external calls — defense in depth
  • Prefer pull over push — let users initiate their own withdrawals
  • Audit cross-contract interactions — shared state is high-risk
  • Test with malicious contracts — don't trust external code
  • Use established libraries — OpenZeppelin's ReentrancyGuard is battle-tested
  • Run static analysis — catch issues before deployment
  • Conclusion

    Reentrancy remains one of the most critical smart contract vulnerabilities. The good news? It's entirely preventable with disciplined coding practices.

    The golden rule: External calls are untrusted. Always update your state before making them.

    By combining the CEI pattern, ReentrancyGuard, and careful architecture, you can build contracts that are secure against reentrancy attacks.

    Practice reentrancy prevention on Solingo — our interactive exercises include real-world scenarios and automated security checks to help you master secure coding patterns.

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement