12.Fifty years
2023-06-23 20:22:15 # 01.Capturetheether CTF

Fifty years

topic

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
pragma solidity ^0.4.21;

contract FiftyYearsChallenge {
struct Contribution {
uint256 amount;
uint256 unlockTimestamp;
}
Contribution[] queue;
uint256 head;

address owner;
function FiftyYearsChallenge(address player) public payable {
require(msg.value == 1 ether);

owner = player;
queue.push(Contribution(msg.value, now + 50 years));
}

function isComplete() public view returns (bool) {
return address(this).balance == 0;
}

function upsert(uint256 index, uint256 timestamp) public payable {
require(msg.sender == owner);

if (index >= head && index < queue.length) {
// Update existing contribution amount without updating timestamp.
Contribution storage contribution = queue[index];
contribution.amount += msg.value;
} else {
// Append a new contribution. Require that each contribution unlock
// at least 1 day after the previous one.
require(timestamp >= queue[queue.length - 1].unlockTimestamp + 1 days);

contribution.amount = msg.value;
contribution.unlockTimestamp = timestamp;
queue.push(contribution);
}
}

function withdraw(uint256 index) public {
require(msg.sender == owner);
require(now >= queue[index].unlockTimestamp);

// Withdraw this and any earlier contributions.
uint256 total = 0;
for (uint256 i = head; i <= index; i++) {
total += queue[i].amount;

// Reclaim storage.
delete queue[i];
}

// Move the head of the queue forward so we don't have to loop over
// already-withdrawn contributions.
head = index + 1;

msg.sender.transfer(total);
}
}

analyses&solution

本关要求合约余额等于0,分析代码,我们知道要调用withdraw()提款来完成本题,本题考察旧版本编译器的结构体指针问题,跟11关Donate的原理差不多:upsert()中的 contribution.amountcontribution.unlockTimestamp 的赋值可以分别覆盖掉 queue数组的长度 和 head 变量的存储插槽:slot 0 和slot 1。

upsert()方法的逻辑是要么给某个contribution投票,要么自己新建一个contribution。但是新建由如下要求:新提案的解锁时间要大于上一个提案的解锁时间1天时间。但是我们发现,本题的编译器版本小于0.8.0,并且没有做任何整数溢出的防范措施,因此我们可以在这里搞一个整数溢出的漏洞:

1
require(timestamp >= queue[queue.length - 1].unlockTimestamp + 1 days);

1.新建一个提案A,解锁时间设置为:2^256 - 24 60 60 = unlockTimestamp_1:2^256即0,+1days即24 60 60。那么这样,下一个提案来的时候,至少大于的解锁时间就会变成0,那么新提案的解锁时间就可以设置为0。因此第一次调用upsert(),参数为index=1,timestamp=unlockTimestamp_1,msg.value=0 Wei

2.新建提案B,解锁时间设置为0,它可以通过require的检验:第二次调用upsert(),参数为index=2,timestamp=0,msg.value=0 Wei

3.调用 withdraw 函数, 输入 index = 2,取出合约中的钱,但是,当我们调用 withdraw 函数的时候,会发现调用失败

4.分析失败的原因:withdraw()取钱的逻辑是遍历所有的提案,获取记录的amount总额,然后发钱。但是问题出在这里:queue.length 和 amount 是占据的同一块slot的,所以当 queue.length 增加的时候 amount 的值也会增加,即当我们 index 等于 1 时,queue 数组进行了 push 操作,queue.length 增加了 1,所以 amount 也加了 1,即 1 wei,所以当我们调用 withdraw 函数时,要取出的钱大于合约中有的钱,就会报错。
我们实际的合约金额为1.000000000000000000ETH,但是我们要取的金额为1.000000000000000002 ETH,那我们怎么办呢?其中一种做法就是写一个自毁合约,来给目标合约转 2 wei 就可以了:

1
2
3
4
5
6
7
8
9
pragma solidity ^0.7.3;

contract destructAttack {

constructor (address payable target) payable {
require(msg.value > 0);
selfdestruct(target);
}
}

4.再次调用 withdraw(),输入index=2取钱

5.调用isComplete(),返回true

thinking

本道题正常的逻辑应该是:本项目可以给已有提案投票,或者新建提案,但是新建提案的解锁时间要大于上一个提案,在50年以上增加,到了解锁时间,就可以获取某个提案及其之前所有提案中的金额。但是问题出在require(timestamp >= queue[queue.length - 1].unlockTimestamp + 1 days),通过整数溢出可以将锁定时间设置为0,因此不必再等待50年时间

另外,由于结构体指针的问题,每次新建一个提案,都会修改distribution数组的长度加一,并且新建提案的amount和数组长度存放的位置是同一个插槽,因此amount会加一。因此到了最后,出现了合约余额比记录的amount少的情况,这是经常出现的问题,但是我们知道可以通过自毁合约强制发送金额的方式给合约增加余额,这样withdrew的时候就不会报错revert了