Ethernaut Level 13  Gatekeeper One [Foundry-Hardhat]

Ethernaut Level 13  Gatekeeper One [Foundry-Hardhat]

The Ethernaut-Solutions repository contains the solutions using Foundry and Hardhat.


Objectives

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

This level requires you to learn about bit masking and type conversions.


Analysis

Let's take a look at the level contract.

contract GatekeeperOne {
  address public entrant;

  modifier gateOne() {
    require(msg.sender != tx.origin);
    _;
  }

  modifier gateTwo() {
    require(gasleft() % 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(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
    _;
  }

  function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
    entrant = tx.origin;
    return true;
  }
}

We have to get past all three gates (modifiers) by passing _gateKey to the enter function to become the entrant. We have to find out the _gateKey value.

Let’s take a look at the modifiers one by one.

First Gate:-

require(msg.sender != tx.origin);

We have seen this condition in Level 4 — Telephone.

According to the Solidity docs,

  • msg.sender (address): the sender of the message (current call)

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

This modifier makes sure that msg.sender is not equal to tx.origin. We can pass this modifier by creating an intermediate contract that will call the enter(bytes8 _gateKey) function of the GatekeeperOne contract. Now, in the context of the GatekeeperOne contract, msg.sender will be the intermediate contract, and tx.origin will be the wallet address.

First gate cleared!

Third Gate:-

We are solving gate 3 first because we need this gate cleared before we can solve gate 2. You will understand when we solve gate 3.

Let's understand the concept needed to solve the gate.

Type casting

The Solidity documentation contains a good explanation of typecasting.

In the case of numbers, when you convert a smaller data type to a larger data type, higher-order bits are padded with zeros. Solidity can perform this type of conversion implicitly.

uint16 small = 0x1234;
uint32 large = uint32(small); // 0x00001234

The problem arises when you downcast a variable from a larger datatype to a smaller datatype. In this conversion, higher-order bits are truncated and the data is lost. Hence Solidity won't perform this conversion implicitly. You have to explicitly downcast a value.

uint16 num16 = 0x0101    // 257 in decimal
uint8 num8 = uint8(0x0101)    // 1    Higher order bits are truncated

As seen above, when you convert uint16 257 to uint8, the resulting value is 1. We have seen a similar condition in Level 5 - Token(overflow).

bytes datatype casting works differently than numbers. bytes data is treated as a sequence of bits. In this conversion, lower-order bits are truncated.

bytes8 large = 0x12345678;
bytes4 small = bytes4(large); // 0x1234

Bit masking

If you are not familiar with bitwise operations, read this first before continuing. Bitmasking is the act of applying a mask over a value to keep, change or modify a piece of given information. A mask determines which bits to take and which bits to clear off binary data. Bitmasking can be used to mask a value to represent the subsets of a set using various bitwise operations. (source)

Masking is the act of applying a mask to a value. This is accomplished by doing:(source)

  • Bitwise AND, to extract a subset of the bits in the value

  • Bitwise OR, to set a subset of the bits in the value

  • Bitwise XO, to toggle a subset of the bits in the value

Below is an example of extracting a subset of the bits in the value:

Let's say we want to clear the first (higher) 4 bits and keep the last (lower) 4 bits. Thus we have extracted the lower 4 bits. The result is:

Any bit AND with 0 makes it 0, while AND with 1 keeps the same value. In hexadecimal notation, 0x00 is 00000000 and 0xFF is 11111111. We removed the first 4 bytes while keeping the last 4 bytes the same. This removal of unwanted bits is bit masking.

With this knowledge let's take a look at the 3rd gate.

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(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");

Let's solve one require statement at a time.

_gateKey is of bytes8 type. The uint64(_gateKey) is just to make the code more cryptic; since _gateKey is of the bytes8 data type, it's the same as uint64 because both take 8 bytes.

Let's assume uint64(_gateKey) to be 0x B1 B2 B3 B4 B5 B6 B7 B8, where each B represents a byte.

require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))

Recall that when downcasting numbers, the most significant bits(leftmost) are truncated.

Therefore, uint32(0x B1 B2 B3 B4 B5 B6 B7 B8) returns 0x B5 B6 B7 B8.(32 bits=4 bytes)

Similarly uint16(0x B1 B2 B3 B4 B5 B6 B7 B8) returns 0x B7 B8.(16 bits=2 bytes)

A smaller variable is converted to a larger one when it is compared to a larger one. Here uint16 is converted to uint32.

So it's clear that we have to satisfy the following condition:

0x B5 B6 B7 B8 == 0x 00 00 B7 B8

Straightaway, we can see that B5 and B6 should be 0 to satisfy the condition.

Second require statement:-

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

If we write out the line with bytes, we get

require(uint32(0x B1 B2 B3 B4 B5 B6 B7 B8)) != 0x B1 B2 B3 B4 B5 B6 B7 B8

uint32(0x B1 B2 B3 B4 B5 B6 B7 B8) returns 0x B5 B6 B7 B8.

We need to satisfy the following condition:

0x 00 00 00 00 B5 B6 B7 B8 != 0x B1 B2 B3 B4 B5 B6 B7 B8

Straightaway, we can see that we can leave any one of the B1, B2, B3, and B4 bytes as it is so that it will not match with the left-hand side.

Third require statement:-

require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin))

Let's assume 0x73379d8B82Fda494ee59555f333DF7D44483fD58 is our wallet address.

uint16(uint160(tx.origin) returns 0xfD58.

So, We need to satisfy the following condition:

0x B5 B6 B7 B8 == 0x 00 00 fD 58

Straightaway, we can see that B5 and B6 should be 0, while the last 2 bytes should be the same as the last 2 bytes of the address that initiated the transaction.

We can use our wallet address to calculate gateway. We need to,

  1. Take the last 8 bytes of the address and store it in uint64 variable.

  2. Make B5 and B6 0 by bit masking uint64 variable with 0x000000000000FFFF.

  3. To pass 2nd require statement we need to keep any one of the B1, B2, B3, and B4 bytes as it is so that it will not match with the left-hand side. We can keep B4 as it is by bit-masking uint64 variable with 0x000000FF00000000.

Combining both bit masks, we get 0x000000FF0000FFFF.

By using the bitwise AND operation, we can change the values of B5 and B6 to 0, and the last two bytes (FFFF) to our tx.origin's last two bytes.

bytes8 gateKey = bytes8(uint64(uint160(tx.origin)) & 0x000000FF0000FFFF);    // 0x000000d40000fd58

This is in our intermediate contract. You can also use msg.sender in place of tx.origin since, in our intermediate contract, msg.sender and tx.origin are both the same.

Third gate cleared!

Second Gate:-

require(gasleft() % 8191 == 0);

gasleft() returns the remaining gas after the execution of gasleft() function.

This modifier requires that after the execution of gasleft() remaining gas should be in multiples of 8191. We need to find the amount of gas to supply that will meet this condition.

At runtime, every smart contract code is converted into a series of opcodes. Every opcode has the gas cost associated with it. We can use the Remix IDE debugger to go through each opcode one by one and calculate the gas amount. Then we can estimate the gas amount that is in multiple of 8191. Of course, we are not going to do that.

Another way we can get the gas amount is by brute force. Up until the transaction is successful, we will repeatedly run the function, increasing the gas supplied in each iteration. This is why I put this gate last. To test if brute force is successful, we need the transaction to succeed; for that, we need other gates to pass.

Let's say g is the amount of gas needed before gasleft() is called. We need to supply an additional amount of gas that is a multiple of 8191. So, the total amount of gas we need to send is g + (8191 * 10). We will call the enter() function repeatedly with all possible values of g until the transaction succeeds.


Exploit

First, we will find the value of g using brute force on a local blockchain. (Avil/Ganache)

  1. Start local blockchain. Use Anvil or ganache-cli.

  2. Go to Remix IDE. Connect Remix IDE to the local blockchain. (eg. Dev - Foundry Provider for Anvil)

  3. Deploy GatekeeperOne contract on the local blockchain. Copy the code from the start of the article or from the Ethernaut site.

  4. Create and deploy the following contract on the local blockchain.

    %[gist.github.com/Chirag21/85790f059d1f577286..

    • Execute bruteHack() function passing the address of GatekeeperOne. The execution could take a few seconds.

    • After it is successful, expand the transaction. In the logs array, we get the emitted value of the event Hacked(i). This is the amount of gas that is consumed till gasleft() is executed from the 2nd modifier.

    • Expand the transaction receipt and copy the emitted value.

      Note that the value of bruteForce may differ based on the compiler version and the chain fork version.

  5. Now, create the following contract. Connect the Remix IDE to the testnet and deploy the contract.

    %[gist.github.com/Chirag21/836a3b037ae4855387..

    This is our intermediate contract that calls enter() function of the GatekeeperOne contract. hack() function takes 2 arguments, one of which is bruteGas, which is the amount of gas that is used until gasleft() is called. We got this value in the previous step. (8191*10) ensures that the remaining gas is in multiples of 8191, thus fulfilling the gate two condition.

  6. Execute the hack() function with the address of the level contract, i.e., GatekeeperOne and bruteGas, obtained in step 4, as arguments. Keep in mind that this value may change in the future as gas prices of EVM opcodes change.

    Sometimes Remix IDE does not show emitted event in the transaction receipt. You can write a simple foundry or hardhat test to run the BruteGasTest contract and get the value or use this contract to hack the level.

  7. After the transaction is successful, check the value of the entrant. Go to level page on Ethernaut.

await contract.entrant()

This should return your wallet address.

Submit the instance.

Level passed!!!😄


Key Takeaways

  • Beware of data loss when converting data types of different sizes.

  • Solidity can pack multiple variables in the same slot if possible. eg., two variables of type uint128 will occupy only one slot. Use bitmasking while retrieving the values.

  • When calculating gas consumption, keep in mind that gas usage varies with different compiler versions and chain forks.

  • Do not implement critical logic around gas consumption. It can be bypassed easily.


The Ethernaut-Solutions repository contains the solutions using Foundry and Hardhat.

More Levels

Did you find this article valuable?

Support Chirag Patil by becoming a sponsor. Any amount is appreciated!