区块链安全

math类型ctf-06

题目:https://capturetheether.com/challenges/math/fifty-years/

1 pragma solidity ^0.4.21;
2
3 contract FiftyYearsChallenge {
4     struct Contribution {
5         uint256 amount;
6         uint256 unlockTimestamp;
7     }
8     Contribution[] queue;
9     uint256 head;
10
11    address owner;
12    function FiftyYearsChallenge(address player) public payable {
13        require(msg.value == 1 ether);
14
15        owner = player;
16        queue.push(Contribution(msg.value, now + 50 years));
17    }
18
19    function isComplete() public view returns (bool) {
20        return address(this).balance == 0;
21    }
22
23    function upsert(uint256 index, uint256 timestamp) public payable {
24        require(msg.sender == owner);
25
26        if (index >= head && index < queue.length) {
27            // Update existing contribution amount without updating timestamp.
28            Contribution storage contribution = queue[index];
29            contribution.amount += msg.value;
30        } else {
31            // Append a new contribution. Require that each contribution unlock
32            // at least 1 day after the previous one.
33            require(timestamp >= queue[queue.length - 1].unlockTimestamp + 1 days);
34
35            contribution.amount = msg.value;
36            contribution.unlockTimestamp = timestamp;
37            queue.push(contribution);
38        }
39    }
40
41    function withdraw(uint256 index) public {
42        require(msg.sender == owner);
43        require(now >= queue[index].unlockTimestamp);
44
45        // Withdraw this and any earlier contributions.
46        uint256 total = 0;
47        for (uint256 i = head; i <= index; i++) {
48            total += queue[i].amount;
49
50            // Reclaim storage.
51            delete queue[i];
52        }
53
54        // Move the head of the queue forward so we don't have to loop over
55        // already-withdrawn contributions.
56        head = index + 1;
57
58        msg.sender.transfer(total);
59    }
60 }

思路分析:

最终要完成挑战,需要实现的条件是:

19    function isComplete() public view returns (bool) {
20        return address(this).balance == 0;
21    }
  1. 挑战合约 FiftyYearsChallenge 的余额为0,于是要找一个有 transfer() 函数的方法进行调用。
  2. 发现 withdraw() 函数中存在 transfer 调用语句:msg.sender.transfer(total);
  3. 通过理解 withdraw() 函数,发现其实应该如下调用才能取出合约里面全部的钱:withdraw(queue.length-1),也就是取出最后一笔存款,会把之前的存款都加起来,一起取出。
  4. 然后查看 withdraw 里面的 require 条件。require(msg.sender == owner); 已满足;require(now >= queue[index].unlockTimestamp); 不满足,因为queue里面第一个元素的 unlockTimestamp == now+50 years(见12行,构造器函数中),后面的每一次新插入元素,queue[queue.length – 1].unlockTimestamp + 1 days(见33行),也就是说 now 至少应该大于初始 now + 50 years,很明显不满足。
  5. 所以这里应该构造溢出。这里的思路是通过 upsert() 函数构造溢出,改变 queue[head] 的值,也就是 queue[0].timestamp。
  6. 来看看 storage layout 的布局:
-----------------------------------------------------
|                    queue.length                   | <- slot 0
-----------------------------------------------------
|                        head                       | <- slot 1
-----------------------------------------------------
|                       owner                       | <- slot 2
-----------------------------------------------------
|                      ......                       | 
-----------------------------------------------------
|                      msg.value                    | <- slot hash
-----------------------------------------------------
|                    now + 50 year                  | <- slot hash + 1
-----------------------------------------------------

7. 看看如何通过 upsert() 函数改变 queque[0].timestamp。首先看看 if 分支里面,upsert(0,timestamp)不会改变 timestamp,这个分支只更新 amount;那么再看看 else 分支,33行的 require:require(timestamp >= queue[queue.length – 1].unlockTimestamp + 1 days);,如果能使得 queue[queue.length – 1].unlockTimestamp == 2**256-1,然后加上1,这里就是0了。然后下一个 timestamp 传入0,0>=0,就可以绕过 withdraw() 函数的 require 要求。

这样说来,我们需要两次 upsert():
step1:upsert(1,2**256-1) msg.value == 0.1 ether
step2:upsert(2,0) msg.value == 0.1 ether
step3:withdraw(2)
就能实现了。

第一次尝试

  • step2:upsert(2,0)
  • step3:withdraw(2)

在 remix 上面使用地址 0xF69053571e0cDb6b7b48493FC599c7f9f11A8270 部署挑战合约:

然后第一次调用 upsert():

成功,交易见:0xf71298c60fef287cf9a9f739168c3ab6b90b8d3202aa21f4cda2bb35040376b3
然后开始第二次调用:

但是却调用失败了。Debug 发现是 queue.push(contribution); 时候失败的,也就是 else 分支里面。检查发现问题错在:
33 require(timestamp >= queue[queue.length – 1].unlockTimestamp + 1 days);
这一行,这里不是1,是1year,这里的单位应该是s秒,所以 1 day == 24*60*60 == 86400s。
所以重新来构造溢出:timestamp >= queue[queue.length – 1].unlockTimestamp + 1 days)
x + 86400 = 2**256 => x = 115792089237316195423570985008687907853269984665640564039457584007913129553536

再加上这个数很大,应该是比第一个元素的 timestamp 大的,应该可以满足条件:

第二次尝试

  • step2:upsert(2,0)
  • step3:withdraw(2)

在 remix 上面使用地址 0x18a94c4C8f9383612892e0A69C390AE8A85F70dB 部署挑战合约
第一次 upsert() 成功:https://ropsten.etherscan.io/tx/0xf8e9bfd681b2d26ee377a9786252f0d41cd406bfa42d0ac073afffcb7bf70b2d

第二次 upsert() 成功:https://ropsten.etherscan.io/tx/0x6cc019f7ed5a495ccca73cd0071db01c533b72051764f5250ff95157585d1a0d
注:这里一不小心把 msg.value 传成了 0 wei,但其实没关系,因为没有对于 amout ==0 的校验。也就是可以存入0wei的。

这次 withdraw(2) 失败了:

Debug 发现错在第 43 行,操作码指令显示 INVALID,也就是下标非法。

41    function withdraw(uint256 index) public {
42        require(msg.sender == owner);
43        require(now >= queue[index].unlockTimestamp);
44
45        // Withdraw this and any earlier contributions.
46        uint256 total = 0;
47        for (uint256 i = head; i <= index; i++) {
48            total += queue[i].amount;
49
50            // Reclaim storage.
51            delete queue[i];
52        }
53
54        // Move the head of the queue forward so we don't have to loop over
55        // already-withdrawn contributions.
56        head = index + 1;
57
58        msg.sender.transfer(total);
59    }
60 }

这种在链上的操作是无法显示 storage 的,于是在本地部署进行尝试:

调试发现:

  • step1:upsert(1,115792089237316195423570985008687907853269984665640564039457584007913129553536) 成功,msg.value 传入 0 wei
  • step2:upsert(2,0) 成功,msg.value 传入 0 wei
  • step3:withdraw(2) 失败,哪怕是已经注释掉了 58 行的 transfer() 函数
  • 尝试调用 withdraw(1) 失败
  • 尝试调用 withdraw(0) 成功

所以问题应该是出在
43 require(now >= queue[index].unlockTimestamp);
这一行的下标,下标0符合,下标1和2超出范围。但是我明明通过 upsert() 函数插入了两个元素的。
查看 storage layout:

可以看到两处有错误的地方:

  1. slot 0 处,queue.length 应该为3,实际为1
  2. slot hash 处,value==msg.value,应该为0,实际为1

从 storage 覆盖的角度,现在可以猜测到二者共用一块存储。从 upsert() 函数可以看出,35 行,msg.value 的值刚好位于 slot0,覆盖了 queue.length。为什么 queue.length == msg.value +1呢?应该是 37 行的 push 操作,会把 queque.length +1,所以这里 queque.length 实际上是 msg.value + 1。而因为二者共用一块存储,所以 msg.value 实际位置的值也会被加1。

23    function upsert(uint256 index, uint256 timestamp) public payable {
24        require(msg.sender == owner);
25
26        if (index >= head && index < queue.length) {
27            // Update existing contribution amount without updating timestamp.
28            Contribution storage contribution = queue[index];
29            contribution.amount += msg.value;
30        } else {
31            // Append a new contribution. Require that each contribution unlock
32            // at least 1 day after the previous one.
33            require(timestamp >= queue[queue.length - 1].unlockTimestamp + 1 days);
34
35            contribution.amount = msg.value;
36            contribution.unlockTimestamp = timestamp;
37            queue.push(contribution);
38        }
39    }

那怎么去绕过这一点呢,首先第二次 upsert 的 msg.value 应该为 1,这样一会儿 queue.length 才是 msg.value + 1 = 2,符合我们要取的实际下标,这样 43 行的操作才能合法。其次 msg.value 这里实际上也是2,所以只要第一次跟第二次 upsert 的 msg.value 都是 1 wei,那么最终:

  • 合约余额: 1ether + 2 wei
  • 合约下标 queue.length ==2,index 就应该为 1
  • transfer 这里:1 ether+ msg.value+1 = 1 ether + 2 wei

验证一下:

  • step1:upsert(1,115792089237316195423570985008687907853269984665640564039457584007913129553536),msg.value = 1 wei

  • step2:upsert(2,0),msg.value = 1 wei
  • 所以最终 withdraw(1) 就行,因为下标最大是1。

    第三次尝试

    • step2:upsert(2,0),msg.value = 1 wei
    • step3:withdraw(1)

    在 remix 上面使用地址 0x46cfc87cea9cb568e97B14395aB0C63e8aD4F082 部署挑战合约
    第一次 upsert() 成功:
    https://ropsten.etherscan.io/tx/0xe3513147174858b685599c90007cbebcbecdbaba789a5d62b967936fa50c7d2e

    第二次 upsert() 成功:
    https://ropsten.etherscan.io/tx/0x81b634838a02ae54282bef66770800814d898367e32fb35f38910c2b34102709

    withdraw(1):
    https://ropsten.etherscan.io/tx/0xff6adf1339382afd108be2667f99982f4a97b16e9e66859b2f7bba74c74298f5

    此时挑战合约余额被转出, this.balance == 0:

    通关成功:

    后记

    看到一篇文章的这样讨论这个题目:

    我觉得首先是不用自毁的,之前有个自毁的题目:http://snowming.me/2022/01/07/math-ctf-03/

    但是那是为了给合约转入一种名为 SET 的 ERC20 代币,这个题目里面是 Ether,我就是直接用 EOA 也可以给这个合约转钱,用不着自毁。

    其次假设两次 upsert 的 msg.value 都是 0,最终我应该 withdraw(0)。是不是也可以成功完成呢?

  • 合约余额: 1ether
  • 合约下标 queue.length ==0+1,index 就应该为 0
  • transfer 这里:1 ether
  • 但实际操作:https://ropsten.etherscan.io/address/0x363da22A0c9bbCC4556B33429e8f518656c369c4

    明明应该转出去 1ether,实际上转出了 1wei,这是怎么回事呢。在本地调试一下:

    部署合约:

    第一次:

    第二次:

    withdraw(0):

    发现第二次 upsert 时候,看 slot 0x…63 的位置,已经被改写为 1 了,这是 msg.value;slot 0x…64 的位置,是0,这是上溢之后的 timestamp 结果。
    继续往上看,第一次 upsert 的时候,就把 slot 0x…63 的位置,改写为 1 wei 了。

    原来实际上,upsert() 里面的 push 操作,这里不是接着 queue[0] 之后去增加元素,而是覆盖了 storage 里面 queue[0] 的元素,也就是 slot 0x…63 的位置:

    30        } else {
    31            // Append a new contribution. Require that each contribution unlock
    32            // at least 1 day after the previous one.
    33            require(timestamp >= queue[queue.length - 1].unlockTimestamp + 1 days);
    34
    35            contribution.amount = msg.value;
    36            contribution.unlockTimestamp = timestamp;
    37            queue.push(contribution);
    38        }
    39    }

    将 queue[0].msg.value 覆盖为了 queue[1].msg.value,也就是 0+1 wei。

    总之这样是行不通的。并且要往合约里面转钱的话,同时受限下标,transfer 金额这两个条件,不太好弄。不过仔细调试应该是可以的吧。

    这一列文章写完了,看了不少 https://www.anquanke.com/post/id/154104 里面的 tips,感谢作者。