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,
Take the last 8 bytes of the address and store it in
uint64
variable.Make
B5
andB6
0 by bit maskinguint64
variable with0x000000000000FFFF
.To pass 2nd
require
statement we need to keep any one of theB1
,B2
,B3
, andB4
bytes as it is so that it will not match with the left-hand side. We can keep B4 as it is by bit-maskinguint64
variable with0x000000FF00000000
.
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)
Start local blockchain. Use Anvil or ganache-cli.
Go to Remix IDE. Connect Remix IDE to the local blockchain. (eg.
Dev - Foundry Provider
for Anvil)Deploy
GatekeeperOne
contract on the local blockchain. Copy the code from the start of the article or from the Ethernaut site.Create and deploy the following contract on the local blockchain.
%[gist.github.com/Chirag21/85790f059d1f577286..
Execute
bruteHack()
function passing the address ofGatekeeperOne
. 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 tillgasleft()
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.
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 theGatekeeperOne
contract.hack()
function takes 2 arguments, one of which isbruteGas
, which is the amount of gas that is used untilgasleft()
is called. We got this value in the previous step.(8191*10)
ensures that the remaining gas is in multiples of8191
, thus fulfilling the gate two condition.Execute the
hack()
function with the address of the level contract, i.e.,GatekeeperOne
andbruteGas
, 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 the 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.
After the transaction is successful, check the value of the entrant. Go to the 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.
Remember that, when calculating gas consumption 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.