Intro Link to heading

The Ethernaut CTF game exists for more than 3 years already and has 26 challenges. The first one is a kind of introductory challenge that gives you steps on what you need to set up. If you have Metamask and open the browser’s console, you should see greeting messages:

Set up Link to heading

Originally, this game runs on the Rinkeby test network, but we’re going to play it locally. As I mentioned in the first post, we will use Foundry toolchain and here is the initial/empty project layout:

tree --gitignore
.
├── foundry.toml
├── lib
│   └── forge-std
├── remappings.txt
├── script
│   └── Contract.s.sol
├── src
│   └── Contract.sol
└── test
    └── Contract.t.sol

To make imports easier in the remappings.txt I have these 3 lines:

forge-std/=lib/forge-std/src/
ds-test/=lib/forge-std/lib/ds-test/src/
openzeppelin/=lib/openzeppelin-contracts/contracts/

The step-0 branch represents the current state of the solutions repo. This is our starting point.

Framework Link to heading

Next, we need to have some kind of framework that will run the game. Googling for “ethernaut foundry github” shows these two repositories:

I will use them for inspiration.

The Ethernaut.sol is the game’s main smart contract. It is responsible for generating level instances. When we ask for a new level instance, Ethernaut.sol deploys it to a new address and returns it.

The LevelFactory.sol is the base smart contract for every game level factory. We use level factories to deploy levels and check player’s exploits:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "openzeppelin/access/Ownable.sol";

abstract contract LevelFactory is Ownable {
    function createInstance(address _player)
        public
        payable
        virtual
        returns (address);

    function validateInstance(address payable _instance, address _player)
        public
        virtual
        returns (bool);
}

It contains two functions:

  • createInstance – Deploys a new level for a given player and returns the address of the deployed level instance.
  • validateInstance – Validates state of the level contract instance after exploit. In other words it checks the win conditions.
%%{init: {'theme':'neutral'}}%% classDiagram class LevelFactory { +createInstance(address player) address +validateInstance(address payable instance, address player) bool } LevelFactory <|-- Level1Factory LevelFactory <|-- Level2Factory LevelFactory <|-- Level3Factory

The interaction diagram for a successful exploit scenario:

%%{init: {'theme':'neutral'}}%% sequenceDiagram actor P as Player participant E as Ethernaut participant LF as LevelFactory participant L as Level P->>E: createLevelInstance(levelFactory) E->>LF: createInstance(player) LF->>LF: prepare ctor args for Level LF->>+L: new(args) L-->>LF: address LF-->>E: address(level) E->>E: emit LevelInstanceCreatedLog E-)P: address(level) P->>L: exploit P->>E: submitLevelInstance(level) E->>LF: validateInstance(level, player) LF-->>LF: check win conditions LF-->>E: true E->>E: emit LevelCompletedLog E-->>P: true

Now, let’s have a closer look at the Ethernaut.sol contract:

 // SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "./LevelFactory.sol";
import "openzeppelin/access/Ownable.sol";

contract Ethernaut is Ownable {
    // ----------------------------------
    // Owner interaction
    // ----------------------------------

    mapping(address => bool) registeredLevels;

    // Only registered levels will be allowed to
    // generate and validate level instances.
    function registerLevel(LevelFactory _level) public onlyOwner {
        registeredLevels[address(_level)] = true;
    }

    // ----------------------------------
    // Get/submit level instances
    // ----------------------------------

    struct EmittedInstanceData {
        address player;
        LevelFactory level;
        bool completed;
    }

    mapping(address => EmittedInstanceData) emittedInstances;

    event LevelInstanceCreatedLog(address indexed player, address instance);
    event LevelCompletedLog(address indexed player, LevelFactory level);

    function createLevelInstance(LevelFactory _level)
        public
        payable
        returns (address)
    {
        // Ensure level is registered.
        require(registeredLevels[address(_level)]);

        // Get level factory to create an instance.
        address instance = _level.createInstance{value: msg.value}(msg.sender);

        // Store emitted instance relationship with player and level.
        emittedInstances[instance] = EmittedInstanceData(
            msg.sender,
            _level,
            false
        );

        // Retrieve created instance via logs.
        emit LevelInstanceCreatedLog(msg.sender, instance);

        // Return data - not possible to read emitted events via solidity.
        return instance;
    }

    function submitLevelInstance(address payable _instance)
        public
        returns (bool)
    {
        // Get player and level.
        EmittedInstanceData storage data = emittedInstances[_instance];
        // Instance was emitted for this player.
        require(data.player == msg.sender);
        // Not already submitted.
        require(data.completed == false);

        // Have the level check the instance.
        if (data.level.validateInstance(_instance, msg.sender)) {
            // Register instance as completed.
            data.completed = true;

            // Notify success via logs.
            emit LevelCompletedLog(msg.sender, data.level);

            // Return data - not possible to read emitted events
            return true;
        }

        // Return data - not possible to read emitted events via solidity.
        return false;
    }
}

It has 2 mappings, 3 functions and emits 2 types of events:

%%{init: {'theme':'neutral'}}%% classDiagram class Ethernaut { mapping~address => bool~ registeredLevels mapping~address => EmittedInstanceData~ emittedInstances +registerLevel(LevelFactory level) +createLevelInstance(LevelFactory level) address +submitLevelinstance(address payable instance) bool } class EmittedInstanceData { +address player +LevelFactory level +bool completed } class LevelInstanceCreatedLog class LevelCompletedLog Ethernaut .. EmittedInstanceData Ethernaut .. LevelInstanceCreatedLog : when new level instance deployed Ethernaut .. LevelCompletedLog : when player have passed the level
  • registerLevel – Registers LevelFactory to be allowed for generating and validating level instances.
  • createLevelInstance – Ensures that a given LevelFactory is registered, deploys a new level instance and stores it’s in the emittedInstances mapping. It also emits the LevelInstanceCreatedLog event that can be used to read the address of deployed instance on the client side.
  • submitLevelInstance – Checks the given level instance and emits the LevelCompletedLog event in case of success.

Let’s keep files for each level in separate directories:

...
└── src
    ├── game
    │   ├── Ethernaut.sol
    │   └── LevelFactory.sol
    ├── levels
    │   ├── Fallback
    │   │   ├── Fallback.sol
    │   │   └── FallbackFactory.sol
    │   └── Hello
    │       └── Instance.sol
    └── test
        ├── Fallback.t.sol
        └── common
            ├── LevelTest.sol
            └── Utils.sol
  • {Level}.sol – Contains the main game logic.
  • {Level}Factory.sol – Extends the LevelFactory.sol abstract base contract and implements construction and validation logic for a specific level.

We’ll keep tests in the ./src/test folder and have the naming convention {Level}.t.sol. Our contracts for testing exploits will follow the same structure, so it makes sense to have some base contract which I called LevelTest (the idea is based on the BaseTest by StErMi):

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

import {Test} from "forge-std/Test.sol";
import {Vm} from "forge-std/Vm.sol";
import {Utils} from "./Utils.sol";

import {Ethernaut} from "../../game/Ethernaut.sol";
import {LevelFactory} from "../../game/LevelFactory.sol";

abstract contract LevelTest is Test {
    Utils internal utils;

    Ethernaut private ethernaut;
    LevelFactory internal levelFactory;
    address payable internal levelAddress;

    address payable[] internal users;
    address payable internal owner;
    address payable internal player;

    function setUp() public virtual {
        require(
            address(levelFactory) != address(0),
            "levelFactory is not initialized"
        );

        utils = new Utils();
        ethernaut = new Ethernaut();
        ethernaut.registerLevel(levelFactory);

        users = utils.createUsers(2);
        owner = users[0];
        player = users[1];

        vm.label(owner, "owner");
        vm.label(player, "player");
    }

    function create() external payable returns (address) {
        vm.prank(player);
        return ethernaut.createLevelInstance{value: msg.value}(levelFactory);
    }

    function run() public {
        init();
        exploit();
        check();
    }

    function init() internal virtual;

    function exploit() internal virtual;

    function check() internal {
        vm.startPrank(player);
        bool exploited = ethernaut.submitLevelInstance(levelAddress);
        assertTrue(exploited, "Not exploited");
        vm.stopPrank();
    }
}

In the setUp function we:

  • Check that the derived contract has initialized the levelFactory.
  • Create owner and player test users and assign their labels for debugging.

The Utils helps us setup test users (contract owner and player) with some initial balance. It contains 4 functions:

  • writeTokenBalance – Modifies the storage of a token to mint new tokens to an address.
  • getNamedAddress – Generates an address from the given name string.
  • createUsers – Creates a given number of users with default initial balance.
  • mineBlocks – Moves block.number forward by a given number of blocks.
  • mineTime – Moves forward by a given number of seconds.

We expect the derived test contact to:

  1. Initialize the levelFactory variable in its constructor.
  2. Override the init and exploit functions.

Here is how a typical test contract (Foo.t.sol) might look like:

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

import {Ethernaut} from "../game/Ethernaut.sol";
import {Foo} from "../levels/Foo/Foo.sol";
import {FooFactory} from "../levels/Foo/FooFactory.sol";
import {LevelTest} from "./common/LevelTest.sol";

contract FooTest is LevelTest {
    Foo private level;

    constructor() {
        // Initialize level factory
        levelFactory = new FooFactory();
    }

    // This test-prefixed function gets called when we run "forge test"
    function testFoo() public {
        run();
    }

    function init() internal override {
        // Our setup code goes here

        levelAddress = payable(this.create());
        level = Foo(levelAddress);

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

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

        // Our exploit code goes here

        vm.stopPrank();
    }
}

This is our framework. The step-1 branch reflects the current state of our repository. I’ll copy levels and their factories from the ethernaut repository as we go.

First challenge Link to heading

The first challenge requires us to find a secret password. We only need browser’s console and Metamask to solve it. Let’s take a look at the contract. It has the following methods:

Notice that the authenticate function takes a password and the contract has the password getter function which returns, well, a password. All we need to do is to call the authenticate function with that password string.

Then we can submit the solution:

That’s all:

After submitting we get the message on the web page:

Congratulations! You have completed the tutorial. Have a look at
the Solidity code for the contract you just interacted with below.

You are now ready to complete all the levels of the game, and as
of now, you're on your own.

Godspeed!!

And the code of the challenge contract:

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

contract Instance {
    string public password;
    uint8 public infoNum = 42;
    string public theMethodName = "The method name is method7123949.";
    bool private cleared = false;

    // constructor
    constructor(string memory _password) public {
        password = _password;
    }

    function info() public pure returns (string memory) {
        return "You will find what you need in info1().";
    }

    function info1() public pure returns (string memory) {
        return 'Try info2(), but with "hello" as a parameter.';
    }

    function info2(string memory param) public pure returns (string memory) {
        if (
            keccak256(abi.encodePacked(param)) ==
            keccak256(abi.encodePacked("hello"))
        ) {
            return
                "The property infoNum holds the "
                "number of the next info method to call.";
        }
        return "Wrong parameter.";
    }

    function info42() public pure returns (string memory) {
        return "theMethodName is the name of the next method.";
    }

    function method7123949() public pure returns (string memory) {
        return "If you know the password, submit it to authenticate().";
    }

    function authenticate(string memory passkey) public {
        if (
            keccak256(abi.encodePacked(passkey)) ==
            keccak256(abi.encodePacked(password))
        ) {
            cleared = true;
        }
    }

    function getCleared() public view returns (bool) {
        return cleared;
    }
}

As you can see we’ve skipped a lot of hints. We didn’t call any of those infoX functions %)

On a final note Link to heading

Looks like now we’re prepared to start hacking the first “real” challenge – Ethernaut, #2 Fallback. Thanks for reading!