Ethernaut 13: Gatekeeper 2

After solving the first gatekeeper problem, there is a second one waiting. The problem is here

https://ethernaut.openzeppelin.com/level/0xdCeA38B2ce1768E1F409B6C65344E81F16bEc38d

Investigation

The contract for this problem looks quite similar to the first one. There are multiple gates which must be passed in order to call the enter function.

contract GatekeeperTwo {

  address public entrant;

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

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

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

  function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
    entrant = tx.origin;
    return true;
  }
}

Solution

To pass gateOne, we can use a similar approach to the previous challenge. To ensure that msg.sender != tx.origin, the contract functions can be called through a relayer contract.

Passing gateTwo is a bit more tricky. Here we see our first example of assembly code being used in a contract. Specifically here we have the use of inline assembly. This allows contract developers to write logic that is not supported by the solidity compiler. It is thus extremely useful for library creators to have access to these low level features of the EVM. Specifically, the function being used here is the extcodesize function.

After doing some research, I found that extcodesize is used to determine the size of a contract at a given address. Some people use this to ensure that functions are being called by non-contracts. One thing to note is that this call returns 0 if the contract target has not been initialized, ie. the constructor has not completed running. Therefore, it is possible to bypass this by making calls through the constructor of my relayer contract.

Finally, gateThree has some binary operation logic which must pass in order for it to work. The full operation is as follows

uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == uint64(0) - 1

First, we can take a look at the right-hand side of the operation. uint64(0) - 1 causes the unsigned integer to underflow. Thus, this value is the largest possible uint64 which is 0xffffffffffffffff. Now, we can try to match up the left side of the operation with this.

The ^ operation is an XOR, which sets each bit to 1 if both inputs are 0. eg. binary 101 ^ 011 = 110. Thus, to get the output that we want, _gateKey just needs to be the exact flipped bits of bytes8(keccak256(abi.encodePacked(msg.sender)). We can get this by doing an XOR on bytes8(keccak256(abi.encodePacked(msg.sender))with 0xffffffffffffffff. My full relayer contract code looks like this

constructor (address _gatekeeperAddress) {
        bytes8 negatedGateKey = bytes8(keccak256(abi.encodePacked(address(this))));
        bytes8 gateKey = negatedGateKey ^ 0xffffffffffffffff;

        (bool success, ) = _gatekeeperAddress.call(abi.encodeWithSignature("enter(bytes8)", gateKey));

        require(success);
    }
}

Thus successfully cracks the problem