Ethernaut - Gatekeeper One walkthrough
Introduction to type-conversions and bit-masking in solidity
Backstory
Want to get into web3 security/hacking?
Ethernaut's challenges are awesome to learn about security vulnerabilities in solidity smart contracts, and how you can look out for them and exploit them during audits.
I have been playing the levels since some time now, and the hardest (and most interesting) I found till now is the 'Gatekeeper One' level.
This level will take you through understanding type conversions and bit-masking in solidity as you pass the 'gates'.
The challenge
Let's have a look at what the challenge needs us to do (besides suffering a headache). The description reads:
Make it past the gatekeeper and register as an entrant to pass this level.
Below is the smart contract we need to work on:
// 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;
}
}
Looking through it, it's obvious that we need to invoke the enter(bytes8)
function with the correct key to make it through the 3 modifiers (gates).
Let's have a closer look at the modifiers.
Modifier 1:
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
Modifier 2:
modifier gateTwo() {
require(gasleft().mod(8191) == 0);
_;
}
Modifier 3:
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");
_;
}
Bypassing the gates
I will be going over each of the gates, explaining any concepts needed for that gate, and then show how that concept needs to be used.
First gate
require(msg.sender != tx.origin);
Concept needed
This is the easiest gate to pass through. msg.sender
is the immediate entity that sent the transaction, whereas tx.origin
is the entity that originally started the transaction. In most cases, these would be the same, EXCEPT if you call a smart contract function that calls another contract's function.
Say you invoke Contract A's funcA()
function, which in turn invokes Contract B's funcB()
function. For contract A, both msg.sender
and tx.origin
would be the same, and that's you. But for contract B, msg.sender
would be contract A, whereas tx.origin
would be you!
To use the concept
Knowing this, we just need to make sure that msg.sender
does not equal tx.origin
. How do you do that? Just use the example above; you need an intermediate contract which would then call the enter(bytes8)
function.
First gate passed!
Third gate
Yup, we are doing the 3rd gate now, and putting the 2nd one after this. You'll see why!
These are the checks your key has to pass through:
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");
To get through this, you need to understand type conversions in solidity.
Concept needed: Data type conversion
Imagine you had 10 bottles of water, but you were told to pour them in an empty container that you know can only hold 5 bottles of water. Obviously, the rest 5 bottles' water would spill out. In other words, that container would not hold the water that was spilled out.
Variables can be compared to the container, they hold some data (water). But what happens when you over-fill them? A part of the data is lost, since it's 'spilled' out. What this causes is that the part of the data that remains becomes a new data that is not at all equal to the actual data.
Let's now dive deeper.
Let's work with a simple example. Look at the code below. Tell me, what would the value of smallerVariable
be?
uint32 largerVariable = 0x12345678; // 4 bytes
uint16 smallerVariable = uint16(largerVariable); // 2 bytes
To understand this, here's a visualization I came up with that may help. Earlier when you were imagining pouring 10 bottles of water into the container, only the water from the first 5 bottles filled the container, right? The remaining 5 bottles of water after that spilled out, right?
An integer is constructed from its lowest valued digit, all the way to its highest digit. What I mean by that is, when storing numbers, if you were working with largerVariable
, 0x78
would be stored first, then 0x56
, then 0x34
and finally 0x12
, each of this is one byte each. Bytes are the smallest possible piece of accessible data. What it means is that, all that you can access, are really just streams of bytes. Imagine that the world had no other water-carrying containers except bottes. Bottles would then be to water as bytes are to data.
But what about a half-filled bottle?
You're a real smart-ass, aren't you? Well, a half-filled bottle is equivalent to a half filled byte! Bytes are made of 8 bits each, and bits are actually the smallest unit of data. But, most systems use bytes as the smallest data, to prevent further fragmentation into smaller units, just of usage's sake.
So, a half filled byte would be 00001111
. (0's are empty, 1's are the data. 8 digits = 8 bits = 1 byte).
But what about a half-empty bottle?
Go away pessimist! (It's the same thing as a half-filled bottle!)
Now, let's come to the question I asked before. What's the value of smallerVariable
? Well, this can have only 2 bytes, whereas largerVariable
is ready to 'pour' all 4 bytes. So, only the first 2 bytes would be stored, rest would be discarded. Therefore, smallerVariable
would be 0x5678
.
Adios 0x1234
! You will be missed!
BUT WAIT! What if I pour 5 bottles of water into a container that can store 10 bottles' water?
Good question, given that you have never used a bucket before. It would be half full, and half-empty. Here's a small exercise: figure out the reason why the value of largerVariable
would be 0x00001234
.
uint16 smallerVariable = 0x1234; // 2 bytes
uint32 largerVariable = uint32(smallerVariable); // 4 bytes
There's a catch though, with bytes
data. Unlike numbers that mean something, when viewed as bytes, it's meaningless, right? How you view bytes give them meaning.
So, if you were pouring bytes, it's actually the higher order bits that would be stored, which is completely opposite to that of numbers. And why's that? Well, since bytes by themselves are meaningless, an array of them would actually have its first element on the far left (higher order bits), no?
Always remember, what comes first stays, the rest spills out.
This example would help you visualize it.
bytes8 largerVariable = 0x12345678;
bytes4 smallerVariable = bytes4(largerVariable); // This would be 0x1234
Concept needed: Bit-masking
Oh yes, this gate alone needs two concepts. I promise, this is the last one (for this gate).
Say, you had a data 0x12345678
, and you wanted to discard the first 2 bytes and make it 0x00005678
. One clever way would be to use the concept I discussed above: Store it in a uint16
first, then store it in a uint32
. That's good, but there's a cleverer, one-step way.
You must know bitwise operations, yes? If not, read this first before continuing.
Think of the original data, 0x12345678
. What if you could just 'cover up' the first 4 bytes, so only the last bytes remain?
We know that any bit (0 or 1) when AND-ed with 0 makes it 0, right? And the same operation when done with 1 makes it whatever the actual bit was. So, 0 AND 1
equals 0 itself.
Now, what if you do 0x12345678 AND 0x0000FFFF
? You'd get 0x00005678
! Take a moment to figure out why. The above paragraph is all you need. Remember, 0x00
is 00000000
and 0xFF
is 11111111
.
What we just did, hiding a few bits, is called 'bit-masking'.
To use the concept
Now, time to become Elliot Anderson and come up with the key! Please tell me you got the reference?
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");
The uint64(_gateKey)
is just to make the code obscure, since _gateKey
is of bytes8
data-type, it's the same as uint64
, because both take 8 bytes. However, this would play a part when converting it to a smaller data type.
Assume the gate key to send is 0x B1 B2 B3 B4 B5 B6 B7 B8
. We need to figure out the values of all 8 bytes now.
- If the first line was written out, you'd have to satisfy this condition:
0x B5 B6 B7 B8 == 0x 00 00 B7 B8
(When comparing a smaller variable with a larger one, the smaller one is converted to the larger type.)
Right off the bat, we see that B5 and B6 has to be zero! Cool!
- If the second line was written, you'd have to satisfy this condition:
0x 00 00 00 00 B5 B6 B7 B8 = 0x B1 B2 B3 B4 B5 B6 B7 B8
This shows us that the first 4 bytes can be anything but not 0. Okay, cool!
- If the third line was written, you'd have to satisfy this condition:
0x B5 B6 B7 B8 = 0x 00 00 SECOND_LAST_BYTE_OF_YOUR_ADDR LAST_BYTE_OF_YOUR_ADDR
Now, compare 1 and 3. We immediately get that B7 and B8 are the last bytes of your address! And, since B1-B4 had no restrictions (other than them not being 0), they can be anything!
Therefore, this is the key: 0x ANY_BYTE ANY_BYTE ANY_BYTE ANY_BYTE 00 00 SECOND_LAST_BYTE_OF_YOUR_ADDR LAST_BYTE_OF_YOUR_ADDR
.
You can now see that you can use just your address to make the key! All you need to do, is:
- Take the last 8 bytes of your address: Do this by storing your address in a
uint64
data type. - Make the B5 and B6 zero by bit-masking it: Do an AND operation with the value you got from previous step with
0xFFFFFFFF0000FFFF
. - Now, just store this
uint64
intobytes8
, because theenter()
function needsbytes8
.
It would look like:
bytes8 key = bytes8(uint64(msg.sender) & 0xFFFFFFFF0000FFFF);
(This is meant for use in intermediate contract, so that msg.sender
here becomes tx.origin
for the GatekeeperOne contract)
Second gate
With the 1st and 3rd gate passed, all that stands is for our transaction to go through the 2nd gate.
require(gasleft().mod(8191) == 0);
Concept needed: Bruteforcing
The gasLeft()
function returns the amount of gas left AFTER the gasLeft()
call finishes.
How do we send the exact gas that can not only ensure that our transaction is executed, but also, the amount of gas left at gate 2's start is a multiple of 8191?
We can go the hard way and convert all the solidity code to their EVM opcodes, add up the gas costs for these opcodes, and estimate the total gas needed, and send an amount that's bigger than this amount that also will result in the gas left at gate 2 to be a multiple of 8191.
I tried doing this.
Please don't. I don't recommend it. Your brain is precious. (Even if your school teacher said it's not!).
There's an easier, fun way. The total amount of gas we need to send can be expressed as x + (8191 * k)
, right? Here, x
is the amount of gas that was used up before gasLeft()
was called, and the remaining is a multiple of 8191.
What if we fix k
at, say, 3? All we need to figure out, is x
now. This can be anything from 0 to a any chosen upper-limit. So, what if we try sending transactions with all possible values of x
, and see which one does not get reverted? (Because if it is reverted, it'd mean that it's the incorrect value of x
).
This technique of bombarding an endpoint with all possible values of an input, to see which one is correct, is called bruteforcing.
To use the concept
So, all we need to do, is to try sending sequentially increasing values of x
, and noting the first transaction that does not revert. (You can continue further with higher values of x
, but what's the need?)
So, you'd need to bruteforce something like this in your intermediate contract:
bool success;
uint256 gasBrute;
for(gasBrute = _lowerGasBrute; gasBrute <= _upperGasBrute; gasBrute++){
(success, ) = _gatekeeperAddr.call.gas(gasBrute + (8191 * 3))( // `x` is replaced by `gasBrute`
abi.encodeWithSignature("enter(bytes8)", key) // You have the key from the previous section now
);
if(success){
break;
}
}
require(success, "HACK FAILED");
emit Hacked(gasBrute); // -> This is the least value of `x`.
If this does not get reverted, you have found your x
.
Btw, do you now understand why I put off this gate for the last? That's because, to test if the bruteforcing was a success, you'd need the transaction to not be reverted, which includes going through the 3rd gate!
The way I did this, is to create a local hardhat project, code the intermediate contract (for hacking), use the above function in it, then ran tests on it to find out x
. Don't bother with Remix IDE, it will crash on bruteforcing over a large range.
Keep in mind though: gas requirements are different for different solidity versions. Make sure your intermediate contract uses the same solidity version as the target contract to correctly go through this stage.
Don't deploy this script before finding out x
. After finding the value, you can just rewrite the function to send the exact value instead of running the bruteforcing loop.
Then, go ahead, deploy this contract on Rinkeby, and finish the level!
Wrapping up
The key takeaways are the concepts of bit-masking and data type conversions. This level really isn't about security practices, but it's more of a CTF to force you to learn these concepts and visualize the bytes.
As a reference for you, here's the hardhat project I created to solve this level as discussed:
The test
folder contains the bruteforcing script, the contracts
folder contains the actual GatekeeperOne
contract and the intermediate HackGatekeeperOne
contract, and the scripts
folder contains a script to deploy the intermediate contract.
Until next time folks! ๐