[Web3安全]Ethernaut-PuzzleWallet

admin 2026-01-21 01:08:11 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 文档解析了Ethernaut关卡PuzzleWallet的通关过程,重点在于利用代理模式下的存储槽冲突及multicall逻辑漏洞。攻击者通过proposeNewAdmin获取Owner权限并加入白名单,利用嵌套multicall复用msg.value增加余额并提取合约资金,最后调用setMaxBalance利用存储重叠将代理合约admin权限覆盖为攻击者地址,成功夺取合约控制权。 综合评分: 92 文章分类: CTF,区块链安全,漏洞分析


cover_image

[Web3安全]Ethernaut-Puzzle Wallet

原创

fatmo fatmo

赛博安全狗

2026年1月20日 10:05 广西

刚才一直在琢磨Puzzle Wallet这一关,终于做出来了,本来晚上没睡挺困的,一下子整个人神清气爽,特地写篇文章记录过程。

题目传送门:

https://ethernaut.openzeppelin.com/level/24

题目分析

首先看智能合约的代码:

// SPDX-License-Identifier: MITpragma solidity ^0.8.0;import&nbsp;"../helpers/UpgradeableProxy-08.sol";contract&nbsp;PuzzleProxy&nbsp;is&nbsp;UpgradeableProxy&nbsp;{&nbsp; &nbsp; address public pendingAdmin;&nbsp; &nbsp; address public admin;&nbsp; &nbsp;&nbsp;constructor(address _admin, address _implementation, bytes memory _initData)&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;UpgradeableProxy(_implementation, _initData)&nbsp; &nbsp; {&nbsp; &nbsp; &nbsp; &nbsp; admin = _admin;&nbsp; &nbsp; }&nbsp; &nbsp; modifier&nbsp;onlyAdmin() {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;require(msg.sender&nbsp;== admin,&nbsp;"Caller is not the admin");&nbsp; &nbsp; &nbsp; &nbsp; _;&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;function&nbsp;proposeNewAdmin(address _newAdmin) external {&nbsp; &nbsp; &nbsp; &nbsp; pendingAdmin = _newAdmin;&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;function&nbsp;approveNewAdmin(address _expectedAdmin) external onlyAdmin {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;require(pendingAdmin == _expectedAdmin,&nbsp;"Expected new admin by the current admin is not the pending admin");&nbsp; &nbsp; &nbsp; &nbsp; admin = pendingAdmin;&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;function&nbsp;upgradeTo(address _newImplementation) external onlyAdmin {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;_upgradeTo(_newImplementation);&nbsp; &nbsp; }}contract&nbsp;PuzzleWallet&nbsp;{&nbsp; &nbsp; address public owner;&nbsp; &nbsp; uint256 public maxBalance;&nbsp; &nbsp;&nbsp;mapping(address&nbsp;=>&nbsp;bool) public whitelisted;&nbsp; &nbsp;&nbsp;mapping(address&nbsp;=>&nbsp;uint256) public balances;&nbsp; &nbsp;&nbsp;function&nbsp;init(uint256 _maxBalance) public {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;require(maxBalance ==&nbsp;0,&nbsp;"Already initialized");&nbsp; &nbsp; &nbsp; &nbsp; maxBalance = _maxBalance;&nbsp; &nbsp; &nbsp; &nbsp; owner = msg.sender;&nbsp; &nbsp; }&nbsp; &nbsp; modifier&nbsp;onlyWhitelisted() {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;require(whitelisted[msg.sender],&nbsp;"Not whitelisted");&nbsp; &nbsp; &nbsp; &nbsp; _;&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;function&nbsp;setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;require(address(this).balance&nbsp;==&nbsp;0,&nbsp;"Contract balance is not 0");&nbsp; &nbsp; &nbsp; &nbsp; maxBalance = _maxBalance;&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;function&nbsp;addToWhitelist(address addr) external {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;require(msg.sender&nbsp;== owner,&nbsp;"Not the owner");&nbsp; &nbsp; &nbsp; &nbsp; whitelisted[addr] =&nbsp;true;&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;function&nbsp;deposit() external payable onlyWhitelisted {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;require(address(this).balance&nbsp;<= maxBalance,&nbsp;"Max balance reached");&nbsp; &nbsp; &nbsp; &nbsp; balances[msg.sender] += msg.value;&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;function&nbsp;execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;require(balances[msg.sender] >= value,&nbsp;"Insufficient balance");&nbsp; &nbsp; &nbsp; &nbsp; balances[msg.sender] -= value;&nbsp; &nbsp; &nbsp; &nbsp; (bool success,) = to.call{value: value}(data);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;require(success,&nbsp;"Execution failed");&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;function&nbsp;multicall(bytes[] calldata data) external payable onlyWhitelisted {&nbsp; &nbsp; &nbsp; &nbsp; bool depositCalled =&nbsp;false;&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;(uint256 i =&nbsp;0; i < data.length; i++) {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; bytes memory _data = data[i];&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; bytes4 selector;&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; assembly {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; selector :=&nbsp;mload(add(_data,&nbsp;32))&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(selector ==&nbsp;this.deposit.selector) {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;require(!depositCalled,&nbsp;"Deposit can only be called once");&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Protect against reusing msg.value&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; depositCalled =&nbsp;true;&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; (bool success,) =&nbsp;address(this).delegatecall(data[i]);&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;require(success,&nbsp;"Error while delegating call");&nbsp; &nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; }}

这个系统由两部分组成:

PuzzleProxy: 负责存储状态和管理合约升级的代理合约。

PuzzleWallet: 包含具体业务逻辑的实现合约(逻辑合约)。

PuzzleProxy是实际的入口,关键点是将所有未识别的调用通过 delegatecall 转发给 implementation (PuzzleWallet)。

题目的要求是,我们成为PuzzleProxy的admin,也就是覆盖PuzzleProxy.admin为我们的address。

delegatecall有一个很关键的特点:比如Address A –delegatecall–>Address B,虽然用的是Address B里的函数逻辑,但是其实存储空间是A的存储空间!

也就是说,如果我们的PuzzleProxy delegatecall了PuzzleWallet的函数,实际上操作的是PuzzleProxy的存储空间,这个存储空间是直接通过slot index对齐的,那么,我们分析一下:

Puzzle&nbsp;Proxy:slot&nbsp;0: pendingAdminslot&nbsp;1: adminPuzzle&nbsp;Wallet:slot&nbsp;0: ownerslot&nbsp;1: maxBalance

因此,只要我们通过Puzzle Proxy delegate call Puzzle Wallet 的function修改maxBalance,我们就能成为Puzzle Proxy的admin。

Step 1: 成为Puzzle Wallet’s Owner

看看如何设置maxBalance:

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

这个函数有个限制,需要我们在白名单里:

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

添加白名单的函数,需要我们是Puzzle Wallet的owner:

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

但是我们知道,在delegate call的场景下,Puzzle Wallet的owner实际上是Puzzle Proxy的pendingAdmin,所以我们直接通过这个函数,把pendingAdmin设置为我们的Address:

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

Step 2: 加入白名单

现在我们是Puzzle Wallet的owner了,我们把自己加入白名单,注意这里的msg.sender就是我们的address,不是Puzzle Proxy的

Step 3: 逻辑漏洞转出所有的余额

我们再看这个函数:

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

这个函数还有一个约束,需要当前合约的总余额为0,但是根据正常逻辑,我们只能转出我们自己的余额,不能转出合约其他部分余额:

&nbsp; &nbsp;&nbsp;function&nbsp;deposit() external payable onlyWhitelisted {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;require(address(this).balance&nbsp;<= maxBalance,&nbsp;"Max balance reached");&nbsp; &nbsp; &nbsp; &nbsp; balances[msg.sender] += msg.value;&nbsp; &nbsp; }&nbsp; &nbsp;&nbsp;function&nbsp;execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;require(balances[msg.sender] >= value,&nbsp;"Insufficient balance");&nbsp; &nbsp; &nbsp; &nbsp; balances[msg.sender] -= value;&nbsp; &nbsp; &nbsp; &nbsp; (bool success,) = to.call{value: value}(data);&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;require(success,&nbsp;"Execution failed");&nbsp; &nbsp; }

我们把目光转向这个函数:

&nbsp; &nbsp; function multicall(bytes[] calldata&nbsp;data)&nbsp;external&nbsp;payable onlyWhitelisted {&nbsp; &nbsp; &nbsp; &nbsp; bool depositCalled =&nbsp;false;&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;(uint256 i =&nbsp;0; i <&nbsp;data.length; i++) {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; bytes memory _data =&nbsp;data[i];&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; bytes4 selector;&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; assembly {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; selector := mload(add(_data,&nbsp;32))&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(selector ==&nbsp;this.deposit.selector) {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; require(!depositCalled,&nbsp;"Deposit can only be called once");&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Protect against reusing msg.value&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; depositCalled =&nbsp;true;&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; (bool success,) = address(this).delegatecall(data[i]);&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; require(success,&nbsp;"Error while delegating call");&nbsp; &nbsp; &nbsp; &nbsp; }&nbsp; &nbsp; }

这个函数允许我们传入调用序列,进行顺序调用,本来会存在一个逻辑漏洞,因为我们调用multicall只需要支付一次,我们在multicall进行多次deposit,然后调用execute就能把合约里的余额掏空,但是这里已经补上了这个漏洞:

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;(selector ==&nbsp;this.deposit.selector) {&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;require(!depositCalled,&nbsp;"Deposit can only be called once");&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// Protect against reusing msg.value&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; depositCalled =&nbsp;true;&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }

但是大方向是对的,就是我们只支付一次,让我们增加的余额超过我们实际支付的数量,就可以提取出合约剩下的余额,那么我们发现,支付一次后,在整个multicall生命周期里,mag.value是不变的,所以我们的call data数组的一项可以传入一个multicall + deposit的序列,就能实现一次支付多次deposit,然后再调用execute把合约余额全部转出来。

因为 depositCalled 只是该函数栈内的局部变量,嵌套调用的 multicall 会开启一个新的函数作用域,里面的 depositCalled 依然是初始值 false。

Step 4: 成为admin

最后调用函数setMaxBalance,成为admin,通关。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:赛博安全狗 fatmo fatmo《[Web3安全]Ethernaut-Puzzle Wallet》

评论:0   参与:  0