It’s been a while since my first post on the Ethernaut challenge, but now everyone’s favorite pillow is back with a full walk-through of each level. You can find the code for all of my exploit contracts, and an explanation of Level 0 here.

Level 1 - Fallback

This level requires us to become the owner of our instance and then drain the balance of the contract.

When we look at the code, we can see that the owner is set to the address of whoever deployed the contract as part of the constructor function. So how do we become the owner?

When we look at the contribute function, we see that we can become the owner if our total contributions are more than that of the owner… but we can also see that we are required to send less than 0.001 ether each time we call the function. This means that it would take a very long time and more than 1000 ETH to become the owner in this way, so let’s look for another option.

Aha, the fallback function! If we send this contract any amount of ETH while having previously contributed any amount via the contribute method, we become the owner. And once we’re the owner, it’s a simple task to drain the balance of the contract by calling withdraw.

This means that we can beat this level by entering the following lines into the browser console one by one, and then finally submitting the instance.

await contract.contribute({value: 100000000000000})
await contract.sendTransaction({value: 1000000000000})
await contract.withdraw()

Level 2 - Fallout

The goal of this level is to become the owner of the contract. However, unlike the last level, the only function that assigns ownership is the constructor function… or does it?

If you look closely, you can see that the function name is really ‘Fal1out’ with a ‘1’ instead of a second letter ‘l’. This means that the function is not actually a constructor at all and can be called anytime after contract creation. In order to beat the level, we simply need to make the following function call in our console:

await contract.Fal1out()

Level 3 - Coin Flip

To beat this level we need to be able to predict the outcome of the coin flip generated by the contract at least 10 times is a row. Luckily for us, the results of flip are not random but rather based on some globally available variables and functions.

We can copy the same logic the contract uses to calculate side and then submit a correct coin flip guess every time. An easy way to do this is to write the contract code in Remix and to compile and deploy it from there as well.

See the code.

Level 4 - Telephone

Once again the goal of the level is to gain ownership of the contract. In order to do this, we need to understand the difference between tx.origin and msg.sender.

In order to successfully call changeOwner and pass our player address as the new owner, the function call must originate from a different address than msg.sender. A good way to do this is to deploy a new contract to attack our level instance.

See the code.

Level 5 - Token

For this level we need to somehow provoke an overflow that will assign us more tokens than are currently in our assigned balance.

We can see that inside the transfer function there is a subtraction operation:

require(balances[msg.sender] - _value >= 0);

Given that we currently have 20 tokens, if we pass a value larger than 20 to transfer we’ll cause an overflow and the result will be a much larger integer, from which our previous balance of 20 will be subtracted and then the remaining tokens assigned to our address.

Let’s pass the instance address and 21 tokens as arguments to transfer and then check our balance:

await contract.transfer("YOUR_INSTANCE_ADDRESS", 21)
await contract.balanceOf("YOUR_PLAYER_ADDRESS")

We now have a ridiculously large balance and are ready to submit our instance.

Level 6 - Delegation

Here we need to claim ownership of the contract instance. To do this, we need to know how delegation is handled in Solidity, particularly via the delegatecall function.

We can see that inside the fallback function of our Delegation contract is a line of code that passes any data sent to it on to the Delegate contract via delegatecall. This means that we can send anything to be executed by Delegate, but from within the context of Delegation.

Yes, that’s right! We can effect changes to Delegation’s state from within Delegate :)

If we input the following line into our browser console, it will trigger pwn() inside Delegate and change the owner of Delegation to our player address, allowing us to beat this level:

await sendTransaction({from: "YOUR_PLAYER_ADDRESS", to: "YOUR_INSTANCE_ADDRESS", data: web3.eth.abi.encodeFunctionSignature("pwn()")})

Level 7 - Force

This level was one of my favorites because of its simplicity. It’s basically an empty contract with no payable functions, but we have to get it to shut up and take our money already. But how?

Even contracts that do not have a payable fallback function will be forced to receive ETH from a given address if that address is made to self-destruct and designates the target contract’s address as the destination for any stored funds.

See the code.

Level 8 - Vault

We need to find the password and submit it to the unlock function in order to beat this level.

The password variable has been initialized with the private modifier. However, this does not mean that the variable is, in fact, a secret. Anyone can read any smart contract’s storage as it is located on-chain.

In this case, the password is stored in slot(1) (slot(0) is occupied by the variable locked). We can read the password from our browser console by calling:

await web3.eth.getStorageAt("YOUR_INSTANCE_ADDRESS", 1)

We will then need to pass the result to unlock in order to beat the level when we submit our instance.

Level 9 - King

The premise is quite simple. Whenever the contract receives more ETH than the current prize, it send the previous king the new prize amount and the sender becomes the new king.

First, let’s check the current prize amount. We can do this by checking the contract’s storage like in the previous level. The value of the prize variable is again stored at slot(1).

await web3.eth.getStorageAt("YOUR_INSTANCE_ADDRESS", 1)

Since the current prize is 1 ether, we need to send slightly more than that to this contract in order to become the king. If we initiate this transaction from a contract with no fallback function, the level will be unable to reclaim kingship as it cannot transfer the prize amount to us.

See the code.

Level 10 - Re-entrancy

The goal here is to drain this contract of its funds. There’s a problem– we can only withdraw what we’ve put into the contract via donate, right?

Let’s look at the logic of the withdraw function. The contract first checks that the amount we’re withdrawing is <= our balance. Then it sends the funds back to us via our fallback function before finally adjusting our balance to reflect the withdrawal.

The vulnerability lies in the fact the our fallback function is triggered before our balance is adjusted. We can re-enter the contract by placing some malicious code in our fallback function that will repeatedly withdraw funds from the contract until the contract’s balance is zero.

See the code.

Level 11 - Elevator

This level was a bit tricky for me.

Once we call goTo from our attacker contract, our instance contract will call isLastFloor in our contract to first evaluate if the floor number we’ve pushed to it with goTo is not the top floor and then subsequently if it is the top floor. All of this without us being able to modify the state, since isLastFloor has a view modifier.

What in the world?? @$&%!!! I know what you’re thinking, dear reader, I’ve been there. But fear not, just because we can’t modify the state within the function itself doesn’t mean that we can’t from outside of it…

I initialized a bool variable called penthouseButton within the constructor function with the value false. Once we call goTo, the target contract will evaluate whichever floor number we’ve given it with our isLastFloor function, changing penthouseButton to true and returning false. When the function is called a subsequent time it will then return true.

See the code.

Level 12 - Privacy

We need to unlock this contract to beat this level. Looking at the unlock function, we can see that the only two variables we need to worry about are data and locked. We’ll need to look into how the data array is stored in order to create the key for the level.

Just like with levels 8 and 9, we can look inside the storage to find the value of data. We need to know about data storage optimization in Solidity in order to know in which slots to look for our value.

Our first variable, locked, is a bool located at slot(0). The next declared variable, ID, is a constant and is stored elsewhere. Since the next three variables together along with locked have a size of less than 32 bytes, they are also stored all together at slot(0). This means that data is located in slots 1-3. Since our target contract tells us we need the information store at data[2], we know we need to look into slot(3):

await web3.eth.getStorageAt("YOUR_INSTANCE_ADDRESS", 3)

Since this call returns a 32-byte value, we will need to convert it to 16 bytes and pass this new value to the target contract as our key. In this case we can just use the first half of our 32 bytes.

See the code.

Level 13 - Gatekeeper One

This level is a three-in-one challenge. We need to call enter in such a way that we pass the three modifier’s requirements.

The first modifier is easy to pass, all we need to do is send our function call from an attack contract.

The second modifier is trickier. We’ll need to specify a gas amount with our function call such that the remaining gas after we’ve reached this point in the stack execution is a multiple of 8191. To do this, we’ll need to have a look in the Remix debugger. Make sure to have the right compiler version for the contract.

Finally, for the third modifier we have to create a key that we will pass to enter. It’s helpful to remember what we learned about conversions in the last level. Our key needs to satisfy three conditions:

1. uint32(_gateKey) has to be equal to uint16(_gateKey)
2. uint32(_gateKey) has to be different from uint64(_gateKey)
3. uint32(_gateKey) has to be equal to uint16(tx.origin)

To start, let’s take the last 8 bytes of our player address (tx.origin) and then add a mask to that value to pad it with zeroes:

_gateKey = bytes8(tx.origin) & 0x000000000000FFFF

This satisfies the first and last conditions for our key. However, it doesn’t meet the second condition as a truncated _gateKey would still return the same value. For this condition to be met we need to change any of the first 8 characters (half of the key value), which we can do by modifying our mask:

_gateKey = bytes8(tx.origin) & 0xF00000000000FFFF

See the code.

Level 14 - Gatekeeper Two

Just like the last level, we’ll need to call this contract’s enter function in such a way that we pass the conditions set by its three modifiers.

The first modifier is the same as in the last level. The second modifier is different now, but still tricky. In order for extcodesize == 0, we’ll need to place all of our contract’s logic inside of its constructor function.

The third modifier requires us to remember our bitwise operators, specifically NOT and XOR (For even more details about the beauty of XOR, check this out.)

See the code.

Level 15 - Naught Coin

To beat this level we’ll have to dig deeper as there is no vulnerability in the code contract itself (at least as far as I could see).

We’re given a balance of an ERC20 token called NaughtCoin and told that to beat this level we have to transfer it to another account. The catch is that the transfer function inside the contract has a modifier that will keep us from calling the function for 10 years.

What stuck out to me was the use of the keyword “override”. If this function was already defined in a contract from which this one inherits, maybe the base contract also contains another transfer method we can use that is not subject to the Naught Coin contract’s timelock modifier:

await contract.approve("YOUR_PLAYER_ADDRESS",  "1000000000000000000000000")
await contract.transferFrom("YOUR_PLAYER_ADDRESS", "SOME_RANDOM_PAYABLE_ADDRESS", "1000000000000000000000000")

Level 16 - Preservation

For this level it is helpful to remember what we learned in level 6 about Solidity delegation.

We can create and deploy a malicious library that contains a setFirstTime function that sets the value of owner to whatever we address we pass to it. We need to make sure its storage layout will be exactly the same as our instance contract since we will exploit the instance’s use of delegatecall to change its state storage from within the execution of our library contract.

Then from our attack contract we can call setFirstTime and pass to it the address of our malicious library contract in order to change the library address. After that we’ll want to call setFirstTime again, but this time passing in our player address in order to become the owner and beat the level.

See the code.

Level 17 - Recovery

Following the path of money around the internet is somewhat of a specialty of mine. For this level we’ll need to fire up Etherscan in order to find the lost contract. Once we have the contract’s address, it’s quite simple to retrieve the remaining ether thanks to the contract’s handy destroy function.

To find the lost address, look up your instance address in Etherscan and click on “Internal Txns”. You’ll see two contract creation events. We want the most recent one which is located at the top. Click on the “Contract Creation” hyperlink for that event and you’ll find the address you’re looking for.

See the code.

Level 18 - MagicNumber

If you didn’t dig deep into opcodes in while poking around the debugger in level 13 like I did, now is your chance :O

We need to create a contract that cthat returns “42”… but the catch is that we have to do it with a maximum of 10 opcodes.

I referred to this excellent article while writing my attack contract and I suggest that you do so as well.

Let’s put our opcodes together. First we need to store our value 42 somewhere in memory. We’ll need to push both the value and the memory slot where we want to put it and then return our value:

602a    // v: push1 0x2a (value = 42 == 0x2a)
6000    // p: push1 0x00 (position = slot(0) in memory == 0x00)
52      // mstore (stores arguments v, p)

6020    // s: push1 0x20 (size of our value = 32 bytes)
6000    // p: push1 0x80 (position = slot(0) == 0x00)
f3      // return (returns s, p)

Our 10 opcode payload is the bytecode sequence 602a60005260206000f3. Now we need to pass it to our instance in order to beat the level. So how do we do that?

We’re going to need to add some initialization opcodes to our sequence in order to copy our runtime opcodes (our payload) to memory and return them to the EVM. Note that these opcodes take up 12 bytes, and since they are stored first our runtime opcodes are located in memory beginning at slot(12):

600a    // s: push1 0x0a (size of our payload == runtime opcode length = 10 bytes)
600c    // f: push1 0x0c (runtime opcodes begin from slot(12))
6000    // t: push1 0x00 (destination memory index 0)
39      // CODECOPY (copies arguments s, f, t)

600a    // s: push1 0x0a (runtime opcode length = 10 bytes)
6000    // p: push1 0x00 (access memory index 0)
f3      // return (returns s, p to EVM)

Our final bytecode sequence is thus 600a600c600039600a6000f3602a60005260206000f3.

Alright, now that we have our bytecode we need to create the contract whose address _solver will be the argument that we need to pass to pur instance’s setSolver function in order to beat this level.

Let’s do this with some inline assembly similar to what we’ll see level 21. We can use the create2 opcode to create the contract. Check this out for reference.

See the code.

Level 19 - Alien Codex

In order to claim ownership of this level we’ll need to remember what we learned about data storage in level 12 (I also found this part of the Solidity documentation to be very helpful for this level).

We can see that the first variable of the contract is of a static size and will thus occupy a single memory slot. However, this contract inherits from Ownable, so the first slot will first be assigned to Ownable’s static variable owner. Both of these variables together are less than 32 bytes, though, so still only slot(0) is reserved in storage.

Now we need to understand how storage is assigned for dynamically-sized variables like our codex array. Once we know that codex is located beginning at keccak256(1) and from there sequentially in memory for the length of the array, we’re ready to crack this challenge.

We can make codex underflow by calling retract. First, though, we’ll need to call make_contact because of the contacted modifier of this function.

Now we can write to slot(0) where our owner variable is stored, setting its value to our player address. In order to do this we’ll need to pass two parameters to the revise method: the slot number of codex which writes to slot(0) and our player address converted to 32 bytes.

See the code

Level 20 - Denial

In this level we need to become a partner and prevent the contract owner from withdrawing funds.

Becoming a partner is straightforward enough, we just have to call setWithdrawPartner and pass in our player address. Then if we look at how funds are withdrawn from the contract, we’ll see that we can re-enter this contract with a malicious fallback function, quite similar to what we did in level 10.

See the code.

Level 21 - Shop

Because this contract uses a fixed amount of gas, it may soon become completely impossible to beat. I was still able to successfully pass this level thanks to some hard-working soul’s Yul code on pastebin.

Yul is an intermediate language that can be used for inline assembly inside of a Solidity file, optimizing a program and saving a whole lot of gas. So, to whoever wrote that pastebin file, many thanks kind human(?) <3

When we call our target contract’s buy function it will in turn call into our price function. Similar to level 11, we need to make it so our function can return two different prices without modifying the value of the price variable.

To do this, we can use the isSold boolean of our target contract to change our price given that the value of isSold changes between function calls.

See the code.

Level 22 - Dex

My solution to this level was not very elegant and I’m working on translating it over to code. I’ll link my efforts here; perhaps you have some suggestions for me.

Anyway, we’ll need to first check out our level address in Etherscan and click the drop-down menu where it says “Token”. We’ll find options for “Token 1” and “Token 2”. Click on both of these and save the addresses.

Next, let’s approve our contract instance to spend all of our current balance of Token 1:

await contract.approve("YOUR_INSTANCE_ADDRESS", 10)

Now let’s swap all of our Token 1 balance for 10 TKN2:

await contract.swap("TKN1_ADDRESS", "TKN2_ADDRESS", 10)

We now have 0 TKN1 and 20 TKN2. The price of TKN2 is now higher than that of TKN1 because we’ve altered the price ratio of the liquidity pool of our AMM-DEX for this currency pair. Let’s test this by comparing the results of the following:

await contract.get_swap_price("TKN2_ADDRESS", "TKN1_ADDRESS", 20)
await contract.get_swap_price("TKN1_ADDRESS", "TKN2_ADDRESS", 20)

We see that we can now trade our whole balance of 20 TKN2 for 24 TKN1, whereas someone who wanted to trade 20 TKN1 would only receive 16 TKN2 from the contract in exchange.

So how do we get the DEX contract’s balance of one of these tokens to zero and thus beat this challenge?

You guessed it! We just need to keep swapping. Repeat the following, swapping between tokens until you find you get a swap price >= 100 for either token:

await contract.approve("YOUR_INSTANCE_ADDRESS", MAX_BALANCE_TO_SWAP) //For the first time, the value is 20
await contract.get_swap_price("TKN1_ADDRESS", "TKN2_ADDRESS", BALANCE_TKN1) //Result is the next value to approve && swap
await contract.swap("TKN2_ADDRESS", "TKN1_ADDRESS", MAX_BALANCE_TO_SWAP) 

If the swap price is > 100, check the contract’s balance to see how many tokens it has left of the base currency:

await contract.balanceOf("TOKEN_ADDRESS_BASE_CURRENCY", "YOUR_INSTANCE_ADDRESS")

Then play around with the swap price function to see what amount you’ll need to trade in order to fully drain the funds.