Ethernaut walkthrough [#4]

This is the fourth part of the Ethernaut challenges walkthrough. In this series, I try to share my experience of learning Solidity via the Ethernaut challenges. You can find the previous part here.

The code solutions could be found here:

https://github.com/aleksandar-had/ethernaut-walkthrough/

however, I suggest trying to solve the challenges first as the posts are structured in a walkthrough fashion (with hints). Of course, if you feel stuck at any moment, look at my code implementations and try again yourself.

Level 11 — Elevator

This is a very nice approach when trying to reverse engineer a programming issue like in this case. Start with the end goal and look at what’s needed to reach it. So:

  1. You want the right-hand side (RHS) expression on this line to evaluate to true:
    top = building.isLastFloor(floor); // MUST be true
  2. To reach that line, you want the if-statement condition to evaluate to true:
    if (!building.isLastFloor(floor)) // MUST be true
  3. To reach that line, you have to send the transaction from a contract that implements the Building interface, hence defines the isLastFloor function as declared here:
    function isLastFloor(uint) external returns (bool);

Alright, number 3 is easy enough. Number 2 is manageable as well. Now, for number 1 — it requires that the negated evaluation of number 2 returns the same result as the normal evaluation. That’s slightly confusing, right? How does a function evaluate to different things called with the same parameter?

Think about it and see if you could come up with a solution.

If not, don’t worry, as it’s a confusing topic. Two approaches can solve the challenge.

The first one utilizes the fact that isLastFloor isn’t a view function and it allows state changes! This means that you can change a state variable’s value in the attack contract between the first and second call of the function. Going this route, you can completely ignore the floor parameter being passed both times and produce an isLastFloor function implementation that returns false on the first call and true on the second call, regardless of the parameter value. The ways to achieve this are quite a lot, feel free to get creative.

The second approach depends on the value of the function parameter and is very clever. I stumbled upon this solution by chance. So, you should consider the fact how an elevator works. You press the floor button, the elevator checks if you’re already on that floor (or maybe is that the last floor). If not (think of the if-statement), it takes you to it (think of floor = _floor) and maybe you’re now on the last floor. In the end, you’re the one who decides which one is the actual last floor. Sorry for being cryptic, if you can’t follow my hint, maybe look at the implementation here.

Level 12 — Privacy

Luckily, there’s already a great Medium post with some incredible examples showing how Ethereum contract storage works. It is by far the most complete guide I’ve found, so give it some cheers and bookmark it for future reference!

It should be enough for you to find the correct way to find out the key to unlocking the contract.

Level 13 — Gatekeeper One

Obviously, the solution is to successfully call the enter function. Looking at the function modifiers and their order, you see that your transaction call should pass the different gates in their respective order: gateOne → gateTwo → gateThree.

gateOne is straightforward: the msg.sender and tx.origin values should be different (already discussed in previous levels). So, the transaction call MUST be from a contract.

gateTwo is trickier, but still quite passable. As the level’s description points out, the gasleft function returns the remaining gas at the point of calling it, look at Solidity language documentation. If you want to go even deeper — the gasleft function gets translated to the EVM Opcode 0x5a (GAS), you can find this Code in Ethereum’s Yellow Paper by searching for 0x5a. If you feel like diving in the yellow paper, this isn’t a bad place to start. Back to the challenge, the easiest way to pass the gateTwo modifier would be to emulate both the level and attack contracts in Remix, send an attack transaction with some arbitrary amount of gas (enough for the transaction to pass, of course) and look at the point at which the transaction fails in the Remix debugger. It’s a trial and error method, unfortunately. For an interesting observation on Gas Limit in Remix, see the end of the post.

gateThree is more intuitive than the second one, in my opinion. I didn’t remember exactly how explicit type conversion in Solidity works, although I assumed it followed the regular laws: larger → smaller takes higher order bits and smaller → larger pads 0s to the left. If you’re like me, you can always write a dummy contract in Remix with the different conversions, just to test it out. Now for the _gateKey parameter — it’s a bytes8 type, hence a 16 character variable. Remember that 1 byte = 8 bits → 0b11111111 = 0xff (2 characters), so 8 bytes = 64 bits → 16 characters.

You should pay attention to the third require statement in the modifier, since tx.origin is the only thing that you can’t influence. That’s why you should start by making sure it passes. Find out what’s your address (tx.origin) by entering “player” in the console of your active Ethernaut tab. In my case, the address ends in d571. Now, for the conversions:

uint32(uint64(_gateKey)) == uint16(tx.origin)
  • RHS first: uint16(tx.origin) is simply the 4 last digits of your address
  • LHS: uint32(...) regardless of what’s inside the parenthesis, the equality check compares an uint32 and uint16 variable. In order for those two to be true, the bigger variable should be equal to the smaller one and the remaining characters to be padded with 0s: 00008888 == 8888 // true . The uint64 doesn’t really bother us in this case, since the higher order bits will be cut out.

End result: _gateKey MUST satisfy the following regular expression:

^[0-9a-fA-F]{8}0000[d|D]571$ // XXXXXXXX0000d571 - X is any hexadecimal

You can test regular expressions here. Now, for the second require statement.

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

In plain terms, at least one of the first 8 characters of _gateKey MUST be different than 0, this way the uint32 variable would be different than the uint64 one. For simplicity, let’s assume the highest order bit is a 1, that leaves us with:

^1[0-9a-fA-F]{7}0000[d|D]571$ // XXXXXXXX0000d571 - X is any hexadecimal

As for the first require statement, it doesn’t further restrict the already defined _gateKey value. In my case, I decided to go with the value

0x100000000000ddc4

Keep in mind that the last 4 characters MUST be the ones of your own player address, ddc4 are those of my player address.

As usual, I hope my hints helped you along the way. Maybe you jumped straight to the solutions repo, then came back to make sure you got everything right. Either way, the important thing is to have understood the topics. This time, I feel like the challenges were more about different aspects of the language and EVM rather than exploits. :)

Part [#5] coming soon…

Previous [#3]

P.S. Re: Gas in Remix. So, after trying out on the local Remix network, I calculated that if I send along 409804 gas, the transaction should go through (254 gas used up to the gasleftcheck + 50*8191, a nice round number). Afterwards, I switched to Rinkeby and deployed a version of the contract that called the enterfunction the following way:

bytes memory payload = abi.encodeWithSignature("enter(bytes8)", _gateKey);
(bool success,) = gatekeeperOneAddress.call(payload);

And I entered 409804 gas via the UI of Remix directly. I accepted the prompt from the MetaMask extension and… it failed. I checked my calculations again, everything was correct. So, this time I deployed another contract with the hardcoded gas value:

bytes memory payload = abi.encodeWithSignature("enter(bytes8)", _gateKey);
(bool success,) = gatekeeperOneAddress.call{ gas: 409804 }(payload);

To my surprise, after calling the function from the new contract, the MetaMask extension opened up, but with another gas value: 443070. I accepted the transaction prompt without changing the gas limit and… it succeeded.

And if you’re wondering if calling the function in the first contract with 443070 gas via the UI of Remix directly works. It doesn’t, it still fails. I tried all combinations and in the end, only the version with the hardcoded gas limit worked and on the condition that I don’t change MetaMask’s gas limit value to 409804, but rather leave it at 443070. Why? I’m still looking for the answer, will share it if I find out.

Electrical engineer turned software engineer. Passionate for blockchain and crypto tech, but then again, who isn’t. Work blog: https://hack.bg/author/aleksandar