Ethernaut Full Write-Up
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.
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.
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.
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.
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.
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
.
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.
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
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.)
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.
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.
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.
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.
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.
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.
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.