avatarSimon Busch

Summary

The provided content outlines a comprehensive guide to solving the Ethernaut challenges using Foundry, a Rust-based toolkit for Solidity development and smart contract security analysis.

Abstract

The text serves as a detailed walkthrough for tackling the Ethernaut challenges, a series of Solidity-based puzzles designed to test and improve smart contract development skills. It introduces Foundry as the chosen framework for interacting with these challenges due to its efficiency and suitability for writing Solidity code and performing tests. The guide covers strategies for each level, from simple to complex, explaining the underlying Solidity concepts, potential vulnerabilities, and the specific steps required to successfully complete each challenge. It also emphasizes the importance of understanding the Ethereum Virtual Machine (EVM) and Solidity's storage layout for certain challenges. The author provides full solutions and explanations for each level, encouraging readers to practice and familiarize themselves with EVM opcodes, proxy patterns, and secure coding practices in Solidity.

Opinions

  • The author believes that Foundry is an optimal choice for interacting with Ethernaut challenges due to its performance and the ability to write tests in Solidity.
  • There is an emphasis on the educational value of the Ethernaut challenges for learning Solidity and improving smart contract security.
  • The author suggests that practicing with EVM puzzles is beneficial for developers to become more comfortable with opcodes and low-level EVM operations.
  • The text conveys the importance of understanding storage layouts and proxy patterns, such as UUPS and Transparent Proxy, in the context of Ethereum smart contracts.
  • The author encourages the use of Forta bots for monitoring and securing smart contracts, highlighting their potential in detecting and preventing malicious activities.
  • There is a recurring theme that security in smart contract development is paramount, and developers should be aware of common vulnerabilities like reentrancy attacks, overflow issues, and improper access controls.

Foundry — Ethernaut levels full solutions

What are Ethernaut challenges?

Ethernaut challenges are a list of different Solidity challenges. You can find them all here: https://ethernaut.openzeppelin.com/

NB: For these levels, I took the decision to upgrade the Solidity version. You can find all the base levels here: https://github.com/OpenZeppelin/ethernaut/tree/master/contracts/contracts/levels

Foundry set up

For these challenges, I decided to go with Foundry. Why? Because I believe it makes sense to write Solidity code to make the tests and break through the contracts.

Foundry comes with a great variety of tools in order to do so, moreover, it’s written in Rust which makes it blazing fast.

I won’t walk you through the setup of Foundry as this is not the purpose of this post you can check very detailed and clear information on their GitHub: https://github.com/foundry-rs/foundry

Moreover, I advise you to keep the “Foundry bookhttps://book.getfoundry.sh/ close by as you proceed through these levels :)

You can find all my solutions on this Github repo.

Level 0 — Hello Ethernaut

For this first level, we can simply use the console, you will have access to a bunch of new commands. The idea of this simple level is that you discover a way to interact with these levels.

Here is the sequence of methods to call. There is not much to learn in this first level, consider it as the warm up :-)

Level 1 — Fallback

You can consider this level as a warm-up.

Here are the goals:

  1. you claim ownership of the contract
  2. you reduce its balance to 0

Reading the code, we understand that we need to be a contributor in order to trigger the receive 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;
    }
  }
[...]
  receive() external payable {
    require(msg.value > 0 && contributions[msg.sender] > 0);
    owner = msg.sender;
  }
[...]

So by contributing with a very small value, below 0.001 ether, we become a contributor, then we execute a call on the contract to gain ownership. Once we are the owner, we can simply call the withdraw function to drain the contract.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/Fallback.t.sol

Level 2 — Fallout

This level is also fairly easy, it’s more about making sure you read the code carefully.

The goal here is to gain ownership.

  function Fal1out() public payable {
    owner = msg.sender;
    allocations[owner] = msg.value;
  }

Reading the contract you can identify this function, which will set the owner to the msg.sender.

This function is (fortunately for us) unrestricted so anybody can call it. Make sure you pass a msg.valuewith it as well.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/Fallout.t.sol

Level 3 — Coin Flip

Now it starts to get interesting. This level is about fake randomness based on block.number. Please note that this can be manipulated and is not recommended in production. The goal is to guess 10 times correctly in a row.

For this level, like many more to come, we will need to use a malicious contract to interact with the victim.

Here are a few things we know:

  • The flip is using a known FACTOR
  • It uses the block.number

Based on this, we can use the same factory in our malicious contract to know whether the flip is going to be true or false.

[...]
  uint256 FACTOR =
      57896044618658097711785492504343953926634992332820282019728792003956564819968;
[...]
  function attack() external payable {
      uint256 blockValue = uint256(blockhash(block.number - 1));
      uint256 coinFlip = blockValue / FACTOR;
      bool side = coinFlip == 1 ? true : false;
      challenge.flip(side);
  }
[...]

Then we can simply call the attack function 10 times. You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/CoinFlip.t.sol

Level 4 — Telephone

The goal of this level is to claim ownership.

It’s quite important and interesting here to understand the difference between tx.originand msg.sender.

  function changeOwner(address _owner) public {
    if (tx.origin != msg.sender) {
      owner = _owner;
    }
  }

To make it simple:

tx.origin:

The original user wallet that initiated the transaction

The origin address of potentially an entire chain of transactions and calls

Only user wallet addresses can be the tx.origin

A contract address can never be the tx.origin

msg.sender:

The immediate sender of this specific transaction or call

Both user wallets and smart contracts can be the msg.sender

In our case, we can use a malicious contract to call the changeOwner.

function attack() external payable {
    // the condition to change the owner is that
    //msg.sender != tx.origin because called from contract
    challenge.changeOwner(msg.sender);
}

Therefore, msg.sender will be the account that triggers the action, but tx.origin will be the malicious contract itself.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/Telephone.t.sol

Level 5 — Token

At this level, the goal is to get a large amount of tokens. We start with 20.

Here, we have a classic example of overflow. Please note that this is not prevented from Solidity ^0.8.0.

The idea of this hack is to use 2 addresses, player1, and player2.

using the transfer function with a value of 2**256 — 21 will cause an overflow and ultimately drain the contract.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/Token.t.sol

Level 6 — Delegation

The goal here is to gain ownership.

It’s interesting to understand also the delegatecalllow-level function.

The breach lies here:

fallback() external {
  (bool result,) = address(delegate).delegatecall(msg.data);
  if (result) {
    this;
  }
}

By executing a call function to the Delegation contract, we trigger the fallback function as we know. Here, the key is to pass the right function signature as msg.data. That means that the function will be called on delegate contract, which manages ownership, therefore, we know that we want to call this public function:

function pwn() public {
  owner = msg.sender;
}

We can simply pass this as msg.data: (abi.encodeWithSignature(“pwn()”))

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/Delegation.t.sol

Level 7 — Force

This level is quite interesting because the contract is … empty? or is it?

The goal of this level is to make the balance of the contract greater than zero.

Here again, we will need a malicious contract to interact with the Force contract.

This malicious contract is quite straightforward, in the constructor, we directly call self-destruct, pointing to the Force address. By passing 1 ether on the creation of this hack contract, it will directly be sent to Force contract.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/Force.t.sol

Level 8 — Vault

The goal is pretty straightforward again: Unlock the vault to pass the level!

Looking at the contract layout, you may think that as the password is a private variable, it’s impossible to access. But NOTHING is really private on the blockchain.

We can access the storage of the contract.

Let’s have a look at the contract’s storage layout:

contract Vault {
  bool public locked; // -> SLOT 0
  bytes32 private password; // -> SLOT 1

In Foundry, this can be achieved with helper functions.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/Vault.t.sol

Level 9 — King

When you submit the instance back to the level, the level is going to reclaim kingship. You will beat the level if you can avoid such a self-proclamation.

This is very important because we don’t want to become the new king, so we need to interact with the malicious contract.

We need to execute a call function with a msg.value ≥ prize.

in order to do so, a simple function in our malicious contract to execute this call will do.

You’ll find the complete solution here:https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/King.t.sol

Level 10 — Re-entrancy

This level is meant to make us discover a classic re-entrancy attack scheme.

The vulnerability lies in the withdraw function:

function withdraw(uint _amount) public {
  if(balances[msg.sender] >= _amount) {
    (bool result,) = msg.sender.call{value:_amount}("");
    if(result) {
      _amount;
    }
    balances[msg.sender] -= _amount;
  }
}

In order to achieve this, we will need a malicious contract.

In order to call the attack function, we will need to pass a msg.value. The amount will be passed to the donate function in the Reetrance contract. Then, we call another function which will have the following flow: 1. Check if the target contract still has funds 2. if yes, we calculate the withdrawAmount:

  uint256 withdrawAmount = initialDeposit < reetranceRemainingBalance
      ? initialDeposit
      : reetranceRemainingBalance;
  challenge.withdraw(withdrawAmount);

As the withdraw function executes a call function on the msg.sender ( our malicious contract, to withdraw the ether, it will trigger the receive() function, in this function, we will call again our callWithdrawfunction helper function. and it will loop until there is no balance remaining in the target contract.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/Reetrance.t.sol

Level 11 Elevator

The goal here is to set bool top = true.

The idea here is to create a malicious contract and shadow the defined interface:

interface Building {
  function isLastFloor(uint) external returns (bool);
}

By creating a function isLastFloor we can return true.

function attack() external payable {
    challenge.goTo(0);
}

function isLastFloor(
    uint256 /* floor */
) external returns (bool) {
    floorUp++;
    if (floorUp > 1) {
        return true;
    } else {
        return false;
    }
}

You’ll find the complete solution here:https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/Elevator.t.sol

Level 12 Privacy

This level will force us to dive a bit deeper into contract storage. We need to find the value of the _key storage in bytes32[3] data at the position [2].

Let’s look at the storage layout:

bool public locked = true; // SLOT0
uint256 public ID = block.timestamp; // SLOT1
uint8 private flattening = 10; // SLOT2
uint8 private denomination = 255; // SLOT2
uint16 private awkwardness = uint16(now); // SLOT2
bytes32[3] private data; // SLOT3 data[0] SLOT4 data[1] SLOT5 data[2]

The important thing to learn and keep in mind here is that 1 memory slot is 32bytes. bool = 32 bytes

uint256 = 32 bytes

uint8 = 8 bytes

uint16 = 16 bytes

So we want to reach SLOT5, we can do the same process as the level Vault to access the right slot. This will return a variable in bytes32, we need to cast it to bytes16 to pass it to the unlock function.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/Privacy.t.sol

Level 13 — Gatekeeper 1

The level is a bit more complex. The goal is to pass the 3 modifiers:

  modifier gateOne() {
    require(msg.sender != tx.origin);
    _;
  }

  modifier gateTwo() {
    require(gasleft().mod(8191) == 0);
    _;
  }

  modifier gateThree(bytes8 _gateKey) {
      require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
      require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
      require(uint32(uint64(_gateKey)) == uint16(tx.origin), "GatekeeperOne: invalid gateThree part three");
    _;
  }

gateOne: We learned this earlier.

In our case we start the prank (in Foundry) as Player, to deploy the contract, stop the prank and restart it as tx.origin, which will be our GatekeeperOneTest contract for this purpose.

gateTwo

Ok so this one is interesting, we need to pass an amount of gas and we have a require(gasleft().mod(8191)==0); You may need to make a few tests to find out the right amount of gas to start with.

gateThree

This gate is a bit more tricky. We need to dive into type casting between uint.

1) uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)

means => 0x11111111 == 0x1111 so the only possibility is to “mask” as following 0x0000FFFF

2) uint32(uint64(_gateKey)) != uint64(_gateKey)

means => 0x1111111100001111 != 0x00001111 “mask” as following 0xFFFFFFFF0000FFFF

3) uint32(uint64(_gateKey)) == uint16(uint160(tx.origin))

bytes8 gateKey = bytes8(uint64(uint160(tx.origin))) & 0xFFFFFFFF0000FFFF;

=> “&” means that we are applying the mask “0xFFFFFFFF0000FFFF” calculated before.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/GatekeeperOne.t.sol

Level 14 Gatekeeper 2

In this level, we are again presented with 3 different modifiers:

modifier gateOne() {
  require(msg.sender != tx.origin);
  _;
}

modifier gateTwo() {
  uint x;
  assembly { x := extcodesize(caller()) }
  require(x == 0);
  _;
}

modifier gateThree(bytes8 _gateKey) {
  require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1);
  _;
}

gateOne

We know this one, here, we will interact with a malicious contract.

gateTwo

In order to understand extcodesize, please have a look here: https://www.evm.codes/

In this assembly code, we need to understand it as follows:

extcodesize of the caller must be 0.

In order to achieve this, we need to interact with the constructor of the malicious contract.

gateThree

Here we have a require that we need to pass: require(uint64(bytes8(keccak256(abi.encodePackged(msg.sender))))^uint64(_gateKey)==uint64(0)-1)

We need to understand the ^ [XOR] operator.

That would basically mean that bytes8(keccak256(abi.encodePacked(msg.sender))) must be the opposite of uint64(_gateKey) And should be equal to uint64(0) — 1

In order to find the key, we can use the same condition:

uint64(bytes8(keccak256(abi.encodePacked(this)))) ^ (uint64(0) - 1);

Therefore, this would be equal to the gateKey.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/GatekeeperTwo.t.sol

Level 15 — Naught Coin

The goal of this contract is to get our balance to 0. Now we know that it inherits from ERC20, so has the available ERC20 functions. This level teaches us about ERC20, and the fact that we shouldn’t rely only on what we see but we should explore the available function from the inherited contract.

We see that there is a system of timeLock used, that locks the token for 10 years… Or does it?

There is a modifier that checks if the timeLock allows the transfer function.

function transfer(address _to, uint256 _value) override public lockTokens returns(bool) {
  super.transfer(_to, _value);
}

// Prevent the initial owner from transferring tokens until the timelock has passed
modifier lockTokens() {
  if (msg.sender == player) {
    require(now > timeLock);
    _;
  } else {
   _;
  }
} 

However, as we are in a contract inheriting from ERC20, there are other functions available, which don’t mind about the timelock. Simply using the transferFrom function will allow us to instantly transfer the tokens.

Don’t forget to use the approve function before.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/NaughtCoin.t.sol

Level 16 — Preservation

The goal here is to claim ownership. We are presented with 2 different contract Preservationand LibraryContract

LibraryContract is supposed to act as a Library, which would have been better in this case. A good takeaway here is that you should never store state variables in libraries.

What we want to hack is these two functions, which call the function setTime on the “library”.

// set the time for timezone 1
function setFirstTime(uint _timeStamp) public {
  timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}

// set the time for timezone 2
function setSecondTime(uint _timeStamp) public {
  timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp));
}

By creating a malicious contract, with the exact same layout as the victim (Preservation) we can redefine the setTime function to allow us to do what we want. Casting our address as uint256 to make us Owner for instance.

This will work because the libraryContractis called with delegatecall, so that executes in the challenge context, and so with the same storage.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/Preservation.t.sol

Level 17 — Recovery

Here, the goal is to find the address of the contract and withdraw the balance. We also see directly that there is a destroy function that will probably be interesting.

function destroy(address payable _to) public {
  selfdestruct(_to);
}

In order to find a lost address, we need to understand what’s happening in the opcodes.

newAddress = keccak256_encode(rlp_encode(sender_address, nonce))

The address for an Ethereum contract is deterministically computed from the address of its creator (sender) and how many transactions the creator has sent (nonce). The sender and nonce are RLP encoded and then hashed with Keccak-256.

Source: https://ethereum.stackexchange.com/questions/760/how-is-the-address-of-an-ethereum-contract-computed

It can be re-computed as follows:

  address addressToFind = address(
      uint160(
          uint256(
              keccak256(
                  abi.encodePacked(
                      bytes1(0xd6),// RLP encoding of a 20-byte address
                      bytes1(0x94),// RLP encoding of a 20-byte address
                      address(creatorAddress),
                      bytes1(0x01) // RLP encoding for none 1
                  )
              )
          )
      )
  );

This is the most important to understand

abi.encodePacked(
    bytes1(0xd6),
    bytes1(0x94),
    address(creatorAddress),
    bytes1(0x01)
)

According to Ethereum yellow paper:

The address of the new account is defined as being the rightmost 160 bits of the Keccak hash of the RLP encoding of the structure containing only the sender and the account nonce.

RLP = RECURSIVE-LENGTH PREFIX, you can find some more information here: https://ethereum.org/en/developers/docs/data-structures-and-encoding/rlp/

  • RLP — The purpose of RLP is to encode arbitrarily nested arrays of binary data, and RLP is the primary encoding method used to serialize objects in Ethereum’s execution layer.
  • RLP for a 20-byte address will be 0xd6, 0x94.
bytes1(0xd6),
bytes1(0x94),
  • creatorAddress — It is the address that created the contract. [We know that]
address(creatorAddress),
  • nonce —In our case, it is the number of contracts created by the factory contract. In this case, it will be 1 assuming it is the first contract created by the factory.
bytes1(0x01)

Ref: This article was particularly helpful: https://ethereum.stackexchange.com/questions/760/how-is-the-address-of-an-ethereum-contract-computed

Once we have all this information, we can re-compute the address and simply call destroy

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/Recovery.t.sol

Level 18 — MagicNumber

To solve this level, you only need to provide the Ethernaut with a Solver, a contract that responds to whatIsTheMeaningOfLife() with the right number.

We can assume from the contract a clear indication that the number is supposed to be 42.

We have also an instruction that the solver’s code can be 10 opcodes at most.

Right, let’s dive into opcodes.

First part

Decompose the byte code:

69 => PUSH 10

602A => PUSH1 2A

6000 => PUSH1 00

52 => MSTORE takes 1)offset and 2)value ( 32 bytes ) and store it in memory

-> Offset == 00

-> Value == 2A ( what we need )

6020 => PUSH1 20

6000 => PUSH1 00

F3 => RETURN

This piece of bytes code returns: 000000000000000000000000000000000000000000000000000000000000002a

Which is what is needed for the function whatIsTheMeaningOfLife and also equal to 42

-> This minimal smart contract always and only returns 42

Second part:

Then we prefix the first part with 69 — PUSH10 to place 10 bytes in the stack

That will basically push the first part of the code

6000 => PUSH1 00

52 => MSTORE => store this in the memory -> Offset == 00

-> Value == return of 69602A60005260206000F3 ( as prefixed with 69 ) return the 10 bytes 600A => PUSH1 0A

6016 => PUSH1 16

F3 return

NB: Opcodes are quite confusing at first, it takes a bit of practice to get used to them. I strongly recommend diving into it and playing around with EVM puzzles. You can have a look here: https://github.com/fvictorio/evm-puzzles And see my post about the solutions and walkthrough here: https://readmedium.com/evm-puzzle-full-solution-57550fbee9a5

Once we have our opcode, we can store it in bytes variable. Then with a bit of assembly re-construct the address.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/MagicNum.t.sol

Level 19 — Alien Codex

In this challenge, the goal is to claim ownership of the contract.

In this contract, the confusing bit is that we don’t clearly see an owner variable, but it’s actually handled by the fact that it inherits from Ownable.

The storage layout of the contract would look actually like this:

SLOT0 = bool public contact && address private _owner

SLOT1= codex.length ( as the array is of a dynamic length )

SLOT keccak256(1) = codex[0]

SLOT keccak256(1) + 1 = codex[1]

SLOT keccak256(1) + 2 = codex[2]

SLOT keccak256(1) + 3 = codex[3]

….

So the idea of the attack would be like this:

  • call the make_contact() to set contact to true.
  • call the retract()function to make it underflow
  • we need to compute the codex index corresponding to slot 0

Then we can use this to set the owner to the right slot and manipulate the storage:

 alienCodexContract.call(
      abi.encodeWithSignature(
          "revise(uint256,bytes32)",
          codexIndexForSlotZero,
          leftPaddedAddress
      )
  );

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/AlienCodex.t.sol

Level 20 — Denial

The goal of this level is to become a withdraw partner and drain all gas of the transactions.

The idea is to create a Hack contract, and set it as a “withdraw partner”.

Once the withdraw function is done, will go through an infinite loop in the fallback with:

while (true) {} and basically drain all the gas.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/Denial.t.sol

Level 21 — Shop

The goal of this level is to buy an item for less than the price asked.

We also have a very important hint: Shop expects to be used from a Buyer

The idea here is to create a malicious contract to interact with Shop.

In our hack contract, we will have 2 functions:

  1. buy
  2. price: This function will shadow the price function from Buyer interface.

The idea is that, when we call buy, it actually triggers the price() function. Directly on execution, it will check if the item is isSold. If yes -> price will be 1. Therefore we are able to buy the item for 1 instead of 100.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/Shop.t.sol

Level 22 — Dex

With this level, we are making our first steps in DeFi with a Decentralized exchange.

We know that we start with 10 token1 and 10 token2; the Dex starts with 100 of each. The goal is to drain all of at least 1 of the 2 tokens.

In order to do so, as this contract inherits from ERC20 of course, we can approve both tokens for type(uint256).max

The issue of this contract lies in the math behind the swap.

function getSwapPrice(address from, address to, uint amount) public view returns(uint){
  return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}

Read the function as follows:

The number of tokens to be returned is equal to the amount of the first token to be swapped times the amount of the second token balance of the contract. The total is divided by the total contract balance of the first token.

Therefore, if we execute a series of swaps, we can trick the math here, see the screenshot to see the evolution of balances:

See the math as follows:

1st swap = Amount of T2 = (10*100)/100 = 10

— Total of 20 T2

2nd swap = (20*110)/90 = 24.44

— Total of 24 T1

3rd swap = (24*110)/86 = 30.69

— Total of 30 T2

….

We can see the values incrementing.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/Dex.t.sol

Level 23 — Dex Two

You need to drain all balances of token1 and token2 from the DexTwo contract to succeed at this level.

You will still start with 10 tokens token1 and 10 token2. The DEX contract still starts with 100 of each token.

We have a couple of very helpful hints here:

How has the swap method been modified?

Could you use a custom token contract in your attack?

Studying the difference between both contracts we can see a very important check has been removed:

 require(
  (from == token1 && to == token2) ||
      (from == token2 && to == token1),
  "Invalid tokens"
 );

This actually means that there is no check whether the token passed is token1 or token2 and we could use any tokens here basically.

The idea here will be to create a malicious token to interact with our DEX.

maliciousToken1.approve(address(ethernautDexTwo), 2**256 - 1);
maliciousToken2.approve(address(ethernautDexTwo), 2**256 - 1);

maliciousToken1.transfer(address(ethernautDexTwo), 1);
maliciousToken2.transfer(address(ethernautDexTwo), 1);

ethernautDexTwo.swap(address(maliciousToken1), address(token1), 1);
ethernautDexTwo.swap(address(maliciousToken2), address(token2), 1);

First, we approve as many as possible tokens.

Then we make a transfer for both our malicious tokens for a small value.

Then we swap.

In order to understand what’s happening in getSwapAmount() here, we can read it as follows:

function getSwapAmount(address from, address to, uint amount) public view returns(uint){
  return((amount * IERC20(to).balanceOf(address(this)))/IERC20(from).balanceOf(address(this)));
}

understand it as: getSwapAmount(MaliciousToken1, Token1, 1)

return ((1 * 100) / 1) ==> 100

Therefore, we can drain the tokens.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/DexTwo.t.sol

Level 24 — Puzzle Wallet

This level is quite interesting, our first steps with UpgradeableProxy. We will need to understand what it is and how it works.

I recommend reading this post on OpenZeppelin: https://docs.openzeppelin.com/contracts/3.x/api/proxy

Here they are using a pattern called Transparent Proxy.

First, we need to understand how it works. Here is the big picture:

We have 2 contracts here. The user interacts with the Proxy contract ( here: ethernautPuzzleProxy ), which stores all data.

The Proxy contract will send the information to the Implementation contract

The implementation of the proxy is implemented in the Implementation Contract ( here: ethernautPuzzleWallet )

This will allow the ethernautPuzzleProxy owner ( here admin ) to upgrade the pointer to the implementation.

NB: interesting if changes need to be done in the Implementation contract Usually one of the main tasks of the proxy contract is to handle upgrade/auth ( role )

Usually has a fallback function that will send all the user’s interactions to the Implementation contract

⚠️ done through delegatecall.

Let’s look at both contracts: PuzzleProxy && PuzzleWallet. PuzzleProxy: Here, the fallback function is triggered if no other function is called. In this contract, the only explicit function we can call without being an admin is proposeNewAdmin.

PuzzleWallet: the only function we can call without BeeingWhitelisted is addToWhitelist. However, it requires msg.sender == owner

Now let’s look at the storage layout of both contracts:

SLOT — — — — PuzzleProxy — — — — PuzzleWallet

0 — — — — — — PendingAdmin — — —owner

1 — — — — — — — admin — — — — — — — maxBalance

How are we becoming the owner then? It seems we will need to overwrite the content of SLOT1 => Admin && maxBalance.

In PuzzleWallet, there are 2 functions modifying the state of maxBalance. Init => this function requires maxBalance == 0; impossible as the contract has already been instantiated.

setMaxBalance => Restricted to whitelisted

Then you may be wondering, how to be whitelisted ?

First, there is a require statement msg.sender == owner …. Therefore, we need to be the owner and attack SLOT0.

In PuzzleProxy, we can proposeNewAdmin, open to anyone. That function takes an address as a parameter and sets it to pendingAdmin. Same storage slot and Owner.

Therefore, if we call this function, we will automatically become the owner of the PuzzleWallet contract because both variables are stored in slot 0.

Here are the steps to the solution:

  1. Propose our player as the new admin
  2. Then, add to the whitelist ( with storage clash we can now ).
  3. Then abuse the multicall method. We haven’t talked yet about this function. Basically, we pass a bytes[] data, the function will iterate through all these data[i]. Then, the function will execute a delegatecallon puzzleWallet with data[i].
  4. We need to prepare our bytes[] as follows:
bytes[] memory depositSelector = new bytes[](1);
depositSelector[0] = abi.encodeWithSignature("deposit()");
bytes[] memory nestedMultiCall = new bytes[](2);
nestedMultiCall[0] = abi.encodeWithSignature("deposit()");
nestedMultiCall[1] = abi.encodeWithSignature(
    "multicall(bytes[])",
    depositSelector
);

5. we call execute to drain the balance.

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/PuzzleWallet.t.sol

Level 25 — Motorbike

The goal of this level is to call selfdestruct on the engine and make the motorbike unusable.

Here we are also using a proxy ( see Puzzle Wallet ), but the pattern is different, the proxy pattern used is Universal Upgradeable Proxy Standard ( UUPS ).

Here, the contract logic will also be coded in the implementation contract and not in the proxy contract.

Another difference is that there is a storage slot defined in the proxy contract that stores the address of the logic contract ( https://eips.ethereum.org/EIPS/eip-1967 )

We have, then, 2 contracts:

  1. Motorbike [Proxy Contract] There is a constant defined ( _IMPLEMENTATION_SLOT ) that stores the address of the implementation contract
  2. Engine [Implementation/logic] We can see in this contract that there is no explicitly defined self-destruct in this contract. The goal here would be to upgrade the implementation contract and point it to our deployed attacker contract

In order to do so, there is a function to perform this: upgradeAndCall:

  1. First, it calls the internal function _authorizeUpgrade which checks if the msg.sender == upgrader
  2. Then it calls the internal function _upgradeToAndCall

However, we are not yet the upgrader … Can we become one?

This upgrader is assigned in this function:

function initialize() external initializer {
    horsePower = 1000;
    upgrader = msg.sender;
}

This function also has the initializer modifier, inherited from Initializable from openZeppelin. It acts as a constructor which can only be called once.

This function initialize is also called in the Motorbike constructor.

  constructor(address _logic)  {
      require(Address.isContract(_logic), "ERC1967: new implementation is not a contract");
      _getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic;
      (bool success,) = _logic.delegatecall(
          abi.encodeWithSignature("initialize()")
      );
      require(success, "Call failed");
  }

Knowing all this, here is the walkthrough :

  1. initialize the engine
  2. initialize the engineHack contract
  3. encode the initialize function call → engineHack become the upgrader as it is the msg.sender
  4. After that, we can call upgradeToAndCall which will trigger the selfdestruct

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/MotorBike.t.sol

Level 26 DoubleEntryPoint

At this level, we will discover a Forta Bot.

The token implemented is an underlying instance of DET in the DoubleEntryPoint definition and the CryptoVault holds 100 of them. The CryptoVault holds also 100 LegacyToken ( LGT ).

The goal here is to protect it from being drained out of tokens

LegayToken is an ERC20 token. It’s important to notice that it overrides the default transfer function by adding its own logic. It will check if the delegate address ( SLOT 0 ) is set to 0.

function transfer(address to, uint256 value)
    public
    override
    returns (bool)
{
    if (address(delegate) == address(0)) {
        return super.transfer(to, value);
    } else {
        return delegate.delegateTransfer(to, value, msg.sender);
    }
}

If that case simply calls transfer(to, value). If not, there is delegateTransfer to delegate contract

It’s important to understand that in our case, delegate is DoubleEntryPoint contract.

There is also the function delegateToNewContract to change the delegate but we need to be the owner.

DoubleEntryPoint is also an ERC20 Token Contract. In the constructor, all addresses are defined + minting 100LGT token to the cryptoVault

The onlyDelegateFrom modifier means that whichever function has this modifier, that function can only be called by the LegacyToken, and no one else

The modifier fortaNotify is the one used by the Forta bot. It checks the old number of alerts with the new number of alerts and sees if an alert was raised. If yes, the transaction reverted.

modifier fortaNotify() {
  address detectionBot = address(forta.usersDetectionBots(player));

  // Cache old number of bot alerts
  uint256 previousValue = forta.botRaisedAlerts(detectionBot);

  // Notify Forta
  forta.notify(player, msg.data);

  // Continue execution
  _;

  // Check if alarms have been raised
  if (forta.botRaisedAlerts(detectionBot) > previousValue)
      revert("Alert has been triggered, reverting");
}

DelegateTransfer Has onlyDelegateFrom modifier -> allowing only LegacyToken to call this function. Has fortaNotify modifier → acts as a bot detection and monitoring feature. Is calling _transfer (ERC20 function)

CryptoVault

setUnderlying sets the address for the underlying token which is DoubleEntryPoint in this case. sweepToken is not very secure, it basically checks if the ERC20token passed is != from underlying and sweeps the whole balance will send the funds to sweptTokensRecipient.

sweptTokensRecipient is initialized directly in the constructor.

Forta

setDetection is the user to set a new bot address. function notify is calling handleTransactionon the bot address. This is how the call data is sent to the bot.

-> This function is also called in fortaNotify modifier function raiseAlert increment the alert

Here is the breakdown of the attack:

1) CryptoVault.sweepToken(LGT) 2) This triggers transfer from LegacyToken (sweptTokensRecipient, Vault’s balance) 3) A call happens in LegacyToken: delegate.delegateTransfer(to, value, msg.sender); -> (sweptTokensRecipients, Vault balance, Vault address) The origSender is the same as the Crypto vault, that’s what we want to avoid.

Reminder, the goal here is to protect it from being drained out of tokens :

In order to do so, we need to code a more secure bot: 1) get the address of the crypto vault 2) Instantiate the new bot with the vault address 3) Set the new detection bot

How could the updatedBot be?

The idea of this contract is to detect potential abuse of CryptoVault 1) Need to extend from IDetectionBot 2) actual address of the CryptoVault 3) we have seen that this function is needed in a bot. Will raise an alert if certain conditions are met 4) msg.data is prefixed by the 4-byte function signature we start from the fifth [4:] msg.data is a bytes calldata type of data that represents the complete calldata.

->address[TO], uint256[VALUE], address [FROM/origSender]
-> this is before it's the msg.data passed to function
 delegateTransfer(
     address to,
     uint256 value,
     address origSender
 )

that triggers -> fortaNotify modifier

That calls notify from Forta contract

that triggers -> handleTransactionon 5) additional check, if == cryptoVault, raise alert.

contract UpdatedBot is IDetectionBot {
  address private cryptoVault;

  // -- 2 --
  constructor(address _cryptoVault) {
      cryptoVault = _cryptoVault;
  }

  // -- 3 --
  function handleTransaction(address user, bytes calldata msgData)
      external
      override
  {
      // -- 4 --
      (, , address origSender) = abi.decode(
          msgData[4:],
          (address, uint256, address)
      );
      // -- 5 --
      if (origSender == cryptoVault) {
          IForta(msg.sender).raiseAlert(user);
      }
  }
}

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/DoubleEntryPoint.t.sol

Level 27 Good Samaritan

Let’s deconstruct the contracts

Coin

1) Constructor: on initialization, we need to pass an address that receives 1_000_000 coins. This also updates mappings of address => uint256 balances.

2) Transfer:

This function receives a _dest and _amount. There is a condition checking if the amount is <= the current balance of the msg.sender. There is also a check if the address is a contract and notifies the amount. If the first condition is not met, it reverts with a custom error.

Wallet

It’s linked to the Coin contract

1) Constructor: sets the deployer as the owner of the contract.

2) There is a modifier onlyOwner checking if the “user” of the wallet is the owner or not

3) donate10 [onlyOwner]

Basically, check if the coin balance is < 10 -> reverts with a custom error

If > 10, trigger a transfer from coin to dest_ for the amount 10.

Else reverts with error NotEnoughBalance

4) transferRemainder [onlyOwner]

Transfer to dest_ the balance of coin, no real check on the target address, but callable only from the owner

Seems to be a good breach?

5) setCoin [onlyOwner]

Sets a new coin contract address but is only callable from the owner.

GoodSamaritan

1) constructor:

Create a new Wallet ( and therefore is the owner );

Create a new Coin with a Wallet address ( so the balance is 1_000_000 )

setCoin to the newly created coin.

2) requestDonation

So that function is interesting. By default, it will try to donate 10 coins to the msg.sender

If it’s going through, that ends here.

If not, we catch the error, and there is the following check:

"keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)"

The key here is to know that Solidity doesn’t support a direct comparison of two strings but can be hashed to compare their values.

If we manage to go in this catch block and trick this comparison, the wallet will transfer the remainder to msg.sender.

And therefore we would have drained the wallet and reverted NotEnoughBalance;

Here is the walkthrough of the attack:

1) We instantiate the Hack contract to access the challenge

2) Call the attack function to trigger requestDonation on the challenge -> behind the scene, the function calls wallet.donate10(msg.sender) in try / catch block. -> as balance is > 10, it calls transfer -> transfer will understand that dest_.isContract() == true

NB: isContract is coming from the Address library from OpenZeppelin -> notify will be called from our contract with:

INotifyable(dest_).notify(amount_);

Here is the flow behind the scene:

request the first donation call donate10 balance is: 1000000 calling coin.transfer for the amount for 10 calling transfer current balance is: 1000000 calling notifywith amount: 10 ( but we have in our hack contract a revert if amount ≥ 10 ) in the catch block, as the error comparison failed calling transferRemainder to msg.sender with total balance calling transfer current balance is: 1000000 calling notify with amount: 1000000

You’ll find the complete solution here: https://github.com/Simon-Busch/Foundry-ethernaut-solutions/blob/main/test/GoodSamaritan.t.sol

Additional thoughts

Learning Solidity by solving these challenges is a very good way to start your journey, whether you want to become a smart contract auditor or not. It forces you to understand what’s happening behind the scene and write more secure code.

A few resources:

https://www.evm.codes/

https://ethereum.org/en/developers/docs/data-structures-and-encoding/rlp/

https://ethereum.stackexchange.com/questions/760/how-is-the-address-of-an-ethereum-contract-computed

New to trading? Try crypto trading bots or copy trading on best crypto exchanges

Solidity
Blockchain
Security
Ethernaut
Blockchain Security
Recommended from ReadMedium