Ethernaut 题库闯关 #23 — Puzzle Wallet

Ethernaut题库闯关连载的第23篇

今天这篇是Ethernaut 题库闯关连载的第23篇,难度等级: 较难。

欢迎大家订阅专栏:Ethernaut 题库闯关,坚持挑战下去,你的 Solidity代码能力肯定大有提高。

挑战# 23 Puzzle Wallet

通常,我们需要为DeFi 每个交易支付手续费,一群朋友发现了如何通过在一次交易中批处理来略微降低执行多次交易的成本,因此他们开发了一个智能合约来做这件事。

他们需要这个合约是可升级的,以防代码中含有错误时进行升级,而且他们还想防止其他人使用合约。为了做到这一点,他们在系统中投票并指定了两个具有特殊角色。管理员(admin),拥有更新智能合约逻辑的权力。所有者(owner),负责控制允许使用合约的地址白名单。

挑战要求劫持这个钱包,成为代理的管理员(admin)。

合约源代码如下:

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

要完成此挑战,需要了解:

  • delegatecall的工作原理,以及 "msg.sender "和 "msg.value" 在执行时的行为方式。
  • 了解代理模式以及它们处理存储变量的方式。

研究合约

拿起一杯咖啡,因为这个挑战将是相当困难。通过前面的挑战我们已经处理过代理合约、实现合约、委托调用等等,但它们仍然是复杂的,需要理解,甚至更复杂的利用:D

如果你是使用代理的新手,我强烈建议你首先阅读所有这些内容:

⚠️重要⚠️这只是一个关于代理如何工作的基本解释,如果你需要使用它们或在实际生活场景中实现,请自己做研究。

我将尝试在一个非常高的层面上进行解释,所以请忍受我。代理/实现模式背后的想法是,有两个不同的合约,它们的行为是这样的:

  • 用户与代理合约交互,所有的 "数据"都存储在这里。你可以把这个合约看成是一个前台。代理合约将所有的用户交互 "转发"到实现合约中。

代理合约的所有实现都在实现合约中实现。这使得代理合约的所有者可以在某个时候升级到实现合约的 "指针",以便修复错误或实现新的功能。

代理合约里面通常没有太多的代码(只有管理升级/授权的代码),并且有一个 fallback函数,将所有用户的交互 "转发 "到包含该函数的真正实现的实现合约。这个 "转发"操作是通过delegatecall完成的。

在这一点上,我假设你已经知道 delegatecall是如何工作的,但如果你是新手,请阅读以下内容:

ContractA通过delegatecall调用ContractB的函数implementation()时,该函数在ContractB的代码上执行,但整个上下文环境msg.sendermsg.value和合约的存储)是来自ContractA

需要记住的一个关键概念是,如果ContractB的代码在delegatecall期间更新了合约的存储,它将不修改*ContractB*存储,而是修改*ContractA*存储!

delegatecall是一个强大的工具,但是如果不正确使用,它也是非常复杂和危险的。

考虑到所有这些解释,让我们来回顾一下合约:

PuzzleProxy.sol


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

    constructor(
        address _admin,
        address _implementation,
        bytes memory _initData
    )

    public 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);
    }
}...

剩余50%的内容订阅专栏后可查看

点赞 0
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

3 条评论

请先 登录 后评论
Ethernaut CTF
Ethernaut CTF
信奉 CODE IS LAW.