The Ethernaut Challenge #13 Solution — Gatekeeper One

This is Part 13 of the “Let’s play OpenZeppelin Ethernaut CTF” series, where I will explain how to solve each challenge.

The Ethernaut is a Web3/Solidity based wargame created by OpenZeppelin. Each level is a smart contract that needs to be ‘hacked’. The game acts both as a tool for those interested in learning ethereum, and as a way to catalogue historical hacks in levels. Levels can be infinite and the game does not require to be played in any particular order.

Challenge #13: Gatekeeper One

Make it past the gatekeeper and register as an entrant to pass this level.

Things that might help:
-
Remember what you’ve learned from the Telephone and Token levels.
- You can learn more about the special function gasleft(), in Solidity's documentation (see here and here).

Level author(s): 0age

To solve this challenge, we need to open three different “gates”, each one with a different requirement. Bear with me because they are pretty tough.

Study the contracts

The contract per se is pretty short

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@openzeppelin/contracts/math/SafeMath.sol";contract GatekeeperOne {
using SafeMath for uint256;
address public entrant;
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");
_;
}
function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
entrant = tx.origin;
return true;
}
}

As you can see, we need to solve three different little puzzles inside those three function modifier; otherwise the contract will revert.

Let’s split the explanation in three different parts

Gate 1: msg.sender and tx.origin

To open this gate, we have to understand what msg.sender and tx.origin are and which are the difference between them.

Let’s see what Solidity Docs say about those Global Variables:

  • msg.sender (address): sender of the message (current call)
  • tx.origin (address): sender of the transaction (full call chain)

When the transaction is made by an EOA, and it directly interacts with a smart contract, those variables will have the same value. But if it interacts with a middle-man contract A that then interact with another contract B via a direct call (not a delegatecall) those values will be different.

In this case:

  • msg.sender will have the EOA address
  • tx.origin will have the address of the A contract

Because for gateOne to not revert, we need to have msg.sender != tx.origin this mean that we have to call enter from a smart contract and not directly from the player's EOA.

It’s not part of the challenge, but I suggest you to read what I have listed in Further Reading about some security concerns and best practice about tx.orgin and when you shouldn't use it.

Gate 2: gasleft()

From the Solidity Docs about Global Variables we know that gasleft() returns (uint256) is a function that returns the remaining gas left for the transaction.

It’s important to know that each Solidity instruction is in reality a high-level representation of a series of low level EVM Opcodes. After executing the GAS opcode (read more on EVM codes documentation site) the returned value is the amount of gas left after executing also the GAS opcode that costs currently 2 gas.

Things get overcomplicated here because to pass the gateTwo checks you have to call level.enter{gas: exactAmountOfGas}(gateKey) with a very specific amount of gas that will make gasleft().mod(8191) return 0 (the gas left must be a multiple of 8191).

You can’t guess the number because you would need to translate all the Solidity code in EVM opcodes, calculate the gas consumed by each of them and waste a ton of times (unless your goal is also to master EVM, but for this topic there are tons of other resources like Let’s play EVM Puzzles — learning Ethereum EVM while playing!). You also need to remember that gas cost could differ depending on which Solidity compiler version has been used to compile the code into bytecode and which compile flags has been used during this process. It’s a mess.

What can we do? Well, we can go and with the easy way and brute force it! Following cmichel suggestion, we can leverage the fact that we are using a local test environment (or a forked one).

We know that the gas used by the enter transaction must be at least 8191 plus all the gas spent to execute those opcodes. We can make a range guess and brute force it until it works. This is the code example:

for (uint256 i = 0; i <= 8191; i++) {
try victim.enter{gas: 800000 + i}(gateKey) {
console.log("passed with gas ->", 800000 + i);
break;
} catch {}
}

You start with a base gas value just to be sure that the transaction will not revert because of an Out of Gas exception, and you try to find which value of gas will make the transaction succeed.

In our case (solidity compiler + optimization flags) the correct gas value is: 802929

Gate 3: how casting works in Solidity

To solve the final gate, we need first to understand how casting from a type to a different type and down casting works. The Solidity documentations explain it very well:

When you cast from a smaller type to a bigger one, there’s no problem. All the high order bits are filled with zero and the value does not change. The problem is when you cast a bigger type to a smaller one. Depending on the value, you could encounter in data loss because those high order bits are lost and truncated. For example, uint16(0x0101) is 257 in decimal but if you down cast it to uint8 it will be 1 in decimal!

At this point, we need to find one _gateKey value that satisfies at the same time all these requirements:

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");

In Solidity, you can solve this challenge, applying a “mask” to the input with the AND operator. This operator will put the input binary value in the output position if the mask has a 1 (binary) and a 0 (doesn't matter what we have as input) if in the mask there's a 0.

If you need a well-made explanation of this solution, you can look at 0xSage solution.

Let’s start with the first requirement: uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)). The less important 2 bytes must equal the less important 4 bytes. This means that we want to "remove" the 2 more important bytes of those 4 bytes, but maintain the value of the less important one. Because what we want is to make 0x11111111 be equal to 0x00001111 the mask to accomplish this is equal to 0x0000FFFF.

The second requirement says that the less important 8 bytes of the input must be different compared to the less important 4 bytes. We need to remember that we also have to maintain the first requirement. We have to make 0x00000000001111 != 0xXXXXXXXX00001111 To achieve that, we have to update our mask to make all the first 4 bytes "pass" to the output Our new mask will be 0xFFFFFFFF0000FFFF

Now we just have to apply that mask to our tx.origin cast to a bytes8 (an address is a 20 bytes type).

The key to solve this third gate will be equal to bytes8(uint64(uint160(address(player)))) & 0xFFFFFFFF0000FFFF.

Solution code

To solve the challenge, we need to first deploy “middle” contract. By doing so, tx.origin will have a different value compared to msg.sender and the first gate check will pass.

contract Exploiter is Test {
GatekeeperOne private victim;
address private owner;
constructor(GatekeeperOne _victim) public {
victim = _victim;
owner = msg.sender;
}
function exploit(bytes8 gateKey) external {
victim.enter{gas: 802929}(gateKey);
}
}

Now we can call the test function and solve it

function exploitLevel() internal override {
// calculate the key needed to solve the third gate
bytes8 key = bytes8(uint64(uint160(address(player)))) & 0xFFFFFFFF0000FFFF;
// deploy the middle man contract to make `msg.sender != tx.origin`
Exploiter exploiter = new Exploiter(level);
vm.prank(player, player); // call the exploit function to solve the challenge
exploiter.exploit(key);
// Check we have solved the challenge
assertEq(level.entrant(), player);
}

You can read the full solution of the challenge opening GatekeeperOne.t.sol

Further reading

Disclaimer

All Solidity code, practices and patterns in this repository are DAMN VULNERABLE and for educational purposes only.

I do not give any warranties and will not be liable for any loss incurred through any use of this codebase.

DO NOT USE IN PRODUCTION.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
StErMi

StErMi

#web3 dev + auditor | @SpearbitDAO security researcher, @yAcademyDAO resident auditor, @developer_dao #459, @TheSecureum bootcamp-0, @code4rena warden