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 theaddress
of the deployed level instance.validateInstance
– Validates state of the level contract instance after exploit. In other words it checks the win conditions.
The interaction diagram for a successful exploit scenario:
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:
registerLevel
– RegistersLevelFactory
to be allowed for generating and validating level instances.createLevelInstance
– Ensures that a givenLevelFactory
is registered, deploys a new level instance and stores it’s in theemittedInstances
mapping. It also emits theLevelInstanceCreatedLog
event that can be used to read the address of deployed instance on the client side.submitLevelInstance
– Checks the given level instance and emits theLevelCompletedLog
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 theLevelFactory.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 anaddress
from the given namestring
.createUsers
– Creates a given number of users with default initial balance.mineBlocks
– Movesblock.number
forward by a given number of blocks.mineTime
– Moves forward by a given number of seconds.
We expect the derived test contact to:
- Initialize the
levelFactory
variable in its constructor. - Override the
init
andexploit
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!