文章总结: 文档解析了Ethernaut关卡PuzzleWallet的通关过程,重点在于利用代理模式下的存储槽冲突及multicall逻辑漏洞。攻击者通过proposeNewAdmin获取Owner权限并加入白名单,利用嵌套multicall复用msg.value增加余额并提取合约资金,最后调用setMaxBalance利用存储重叠将代理合约admin权限覆盖为攻击者地址,成功夺取合约控制权。 综合评分: 92 文章分类: CTF,区块链安全,漏洞分析
[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 "../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"); } }}
这个系统由两部分组成:
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 Proxy:slot 0: pendingAdminslot 1: adminPuzzle Wallet:slot 0: ownerslot 1: maxBalance
因此,只要我们通过Puzzle Proxy delegate call Puzzle Wallet 的function修改maxBalance,我们就能成为Puzzle Proxy的admin。
Step 1: 成为Puzzle Wallet’s Owner
看看如何设置maxBalance:
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted { require(address(this).balance == 0, "Contract balance is not 0"); maxBalance = _maxBalance; }
这个函数有个限制,需要我们在白名单里:
modifier onlyWhitelisted() { require(whitelisted[msg.sender], "Not whitelisted"); _; }
添加白名单的函数,需要我们是Puzzle Wallet的owner:
function addToWhitelist(address addr) external { require(msg.sender == owner, "Not the owner"); whitelisted[addr] = true; }
但是我们知道,在delegate call的场景下,Puzzle Wallet的owner实际上是Puzzle Proxy的pendingAdmin,所以我们直接通过这个函数,把pendingAdmin设置为我们的Address:
function proposeNewAdmin(address _newAdmin) external { pendingAdmin = _newAdmin; }
Step 2: 加入白名单
现在我们是Puzzle Wallet的owner了,我们把自己加入白名单,注意这里的msg.sender就是我们的address,不是Puzzle Proxy的
Step 3: 逻辑漏洞转出所有的余额
我们再看这个函数:
function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted { require(address(this).balance == 0, "Contract balance is not 0"); maxBalance = _maxBalance; }
这个函数还有一个约束,需要当前合约的总余额为0,但是根据正常逻辑,我们只能转出我们自己的余额,不能转出合约其他部分余额:
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"); } }
这个函数允许我们传入调用序列,进行顺序调用,本来会存在一个逻辑漏洞,因为我们调用multicall只需要支付一次,我们在multicall进行多次deposit,然后调用execute就能把合约里的余额掏空,但是这里已经补上了这个漏洞:
if (selector == this.deposit.selector) { require(!depositCalled, "Deposit can only be called once"); // Protect against reusing msg.value depositCalled = true; }
但是大方向是对的,就是我们只支付一次,让我们增加的余额超过我们实际支付的数量,就可以提取出合约剩下的余额,那么我们发现,支付一次后,在整个multicall生命周期里,mag.value是不变的,所以我们的call data数组的一项可以传入一个multicall + deposit的序列,就能实现一次支付多次deposit,然后再调用execute把合约余额全部转出来。
因为 depositCalled 只是该函数栈内的局部变量,嵌套调用的 multicall 会开启一个新的函数作用域,里面的 depositCalled 依然是初始值 false。
Step 4: 成为admin
最后调用函数setMaxBalance,成为admin,通关。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:赛博安全狗 fatmo fatmo《[Web3安全]Ethernaut-Puzzle Wallet》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。


![[Web3安全]Ethernaut-PuzzleWallet](/images/random/titlepic/3.jpg)






评论