
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 book” https://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:
- you claim ownership of the contract
- 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 1In 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). Thesenderandnonceare 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 setcontactto 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:
- buy
- 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
swapmethod 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:
- Propose our player as the new admin
- Then, add to the whitelist ( with storage clash we can now ).
- Then abuse the
multicallmethod. 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 adelegatecallon puzzleWallet with data[i]. - 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:
- Motorbike [Proxy Contract] There is a constant defined ( _IMPLEMENTATION_SLOT ) that stores the address of the implementation contract
- 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:
- First, it calls the internal function _authorizeUpgrade which checks if the
msg.sender == upgrader - 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 :
- initialize the engine
- initialize the engineHack contract
- encode the initialize function call →
engineHackbecome the upgrader as it is themsg.sender - 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://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






