Token sale
目标合约
1 | pragma solidity ^0.4.21; |
通关条件
使得合约中剩余 ETH 数量小于 1
题目分析
既然要求的是无中生有,那就先从溢出的角度看。代码中的 1 ether 具有一定的迷惑性,如果将 1 ether 视为 1,则代码没问题。但是合约中 ETH 的数量单位其实是 wei,1 ether = 10**18 wei,这么看来 buy 函数中的 numTokens * PRICE_PER_TOKEN 就很有问题了。我们只需要选择一个数让 numTokens * PRICE_PER_TOKEN 溢出,就能够获得超量代币。
uint256 为 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff,也就是:
a = 115792089237316195423570985008687907853269984665640564039457584007913129639935
我们需要他在乘以 10**18 后溢出,简单的处理就是将其最后 18 位截去,再将最后一位加上 1,得到 numTokens:
b = 115792089237316195423570985008687907853269984665640564039458
相应的 msg.value 就是:
b - a - 1 = 415992086870360064
调用 buy 函数后我们就获得了天量代币,卖出 1 个,就能够获得 1 ETH,成功将合约 ETH 余额降低到 1 以下。
调用代码如下:
1 | contract("TokenSale", function (accounts) { |
Token whale
目标合约
1 | pragma solidity ^0.4.21; |
通关条件
获得超过 1000000 个 token
题目分析
还是从溢出的角度看。注意到 transferFrom 调用了 _transfer,而 _transfer 扣除的是 msg.sender 的余额,这可能导致溢出。原因在于,在调用 transferFrom 的时候,msg.sender 很可能只是一个没有持币的代理地址,也就是说 msg.sender 的余额可能为 0,此时被扣除一个正数就会发生溢出。
调用代码如下:
1 | contract("TokenWhale", function (accounts) { |
Retirement fund
目标合约
1 | pragma solidity ^0.4.21; |
通关条件
取走合约中的所有 ETH
题目分析
注意到作为 beneficiary 从合约中取款的必要条件是 startBalance - address(this).balance > 0
,startBalance 和合约目前的 balance 都是 1 ETH。我们只需要让合约的 balance 大于 1 ETH,startBalance - address(this).balance
就会溢出从而使得条件满足。虽然合约中并没有充值函数,也没有 payable fallback 函数,但是我们可以使用 selfdestruct 强制向合约中转入 ETH。所以解题步骤就是:
- 部署攻击合约并向合约中充入一点 ETH
- 攻击合约自毁,自毁后将 ETH 转入目标合约
- 调用目标合约的 collectPenalty 函数
Mapping
目标合约
1 | pragma solidity ^0.4.21; |
通关条件
把目标合约的 isComplete 改成 true
题目分析
需要我们利用 slot 溢出来修改 Storage 中的数据。阅读代码可知,isComplete 存储于 slot 0 中,map 定义在 slot 1 中,所以 map 中的元素从 slot keccak256(1)(0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6)开始存储。Storage 中一共有 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff 个 slot,所以如果 map 中的元素超过 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff - keccak256(1) 就会发生 slot 溢出。我们需要将 slot 0 设置为 1,所以调用 set 函数,参数 key 为 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff - keccak256(1) + 1,value 为 1 即可。
操作代码如下:
1 | contract("Mapping", function (/* accounts */) { |
Donation
目标合约
1 | pragma solidity ^0.4.21; |
通关条件
将合约中的 ETH 全部取走
题目分析
单纯看代码的话:
- donate 函数中的 scale 计算逻辑是错误的,合约收取的 ETH 数量将是其记录的金额的 1 / 10**36。
Donation donation
实际上声明了一个未定义指向目标的 Storage pointer,其默认指向位置为 slot 0。这种写法在 solidity 0.5.0 之后将会抛出 error。具体可以参考这篇文章和 Solidity 的文档。
这个合约的 slot 0 存放的是 donations 的长度,slot 1 存放的是 owner。在 donations.push(donation)
执行后,donation.timestamp+1 会覆盖 slot 0 的数据(要 +1 是因为 push 会增加 donations 的长度,所以会让 slot 0 的数据 +1),etherAmount 会覆盖 slot 1 的数据。所以解法就比较明显了–通过 etherAmount 来修改存放在 slot 1 中的 owner。具体来说就是,将自己的地址转换为 uint256 作为 etherAmount,将 etherAmount / 10**36
作为 msg.value 调用 donate 方法即可成为 owner,之后直接调用 withdraw 提现就可通关。
Fifty years
目标合约
1 | pragma solidity ^0.4.21; |
通关条件
将合约中的 ETH 全部取走
题目分析
蛮复杂的题。这个合约在 solidity 0.5.0 及以上版本中是编译不过的。非常明显的,upsert 函数中 else 代码块的 contribution 根本就没有声明……(佩服在 0.5.0 之前写 solidity 的大佬们)这个 contribution 同样是个未初始化的 Storage pointer,指向 slot 0。
观察合约,注意以下事实:
- queue.length 存放于 slot 0
- head 存放于 slot 1
- else 代码块中的 contribution 会用 amount 覆盖 slot 0,修改 queue.length;会用 timestamp 覆盖 slot 1,修改 head。
- else 代码块中的 contribution 会被 push 到 queue 中去
- else 代码块中的 require 语句存在不安全的加法运算,timestamp + 1 days(即 24 * 60 * 60)有溢出的可能
我们需要做的事有:
- 构造特定的 contribution,其 unlockTimestamp 足够小,使得我们能通过 withdraw 函数的校验
- 令 head 为 0,使得调用 withdraw 函数时可以将所有 contribution 资金都取出
- 需要注意的是,contribution.timestamp 会覆盖 head,将 1 和 2 结合起来,我们的目的就是插入 timestamp 为 0 的 contribution
初始化合约之后, queue.length 为 1, head 为 0,queue[0] 的 amount 为 1 ether,queue[0] 的 unlockTimestamp 为 now + 50 years,合约 ETH 余额为 1 ether。
既然我们需要构建出一个 unlockTimestamp 为 0 的 contribution,在 upsert 函数中就显然不能走 if 代码块,而应该走 else 代码块。这就要求我们设法通过 else 代码块的 require 检验。结合上面的事实 5,考虑构建 contribution1,其 unlockTimestamp + 1 day 会溢出为 0。所以第一个调用为:
1 | // index 在 else 代码块中用不到,可以随意设置 |
还需要注意到,contribution 插入后,queue.length 应该为 2。我们的操作会导致存放 queue.length 的 slot 0 被覆盖,所以我们需要选择合适的 msg.value 使得 queue.length 为正确数值。我们转入的 msg.value 为 1 wei,由于执行 queue.push 的时候 slot 0 的数值会被加上 1,所以 slot 0 会变为 1 + 1 = 2,也就是说 queue.length 仍为 2。 但是这是又会发生一个问题:queue.push 之后,slot 0 和 slot 1 的数值作为 contribution 的 amount 和 timestamp 被复制到 queue[1] 中去了,所以 queue[1].amount 为 2 wei,比我们实际存入的要多了 1 wei。
接下来我们再次调用 upsert 构建 contribution2:
1 | // timestamp 设置为 0,等于 contribution1.unlockTimestamp + 1 days,可以通过 require 检查 |
这样我们就得到了一个 timestamp 为 0 的 contribution(也就是说 head 为 0),此时已经可以通过 withdraw 来取款了。现在考虑 msg.value。如果我们转入 2 wei,则 queue.length 为正确数值 3,queue[2].amount 为 3 wei。我们总共转入 1 ether + 3 wei,而 queue 中三个 contribution 的 amount 分别为:
- queue[0].amount == 1 ether
- queue[1].amount == 2 wei
- queue[2].amount == 3 wei
这样我们是无法将资金取干净的。
但是如果我们转入 1 wei 的话,存入总金额为 1 ether + 2 wei,contribution 的情况如下:
- queue[0].amount == 1 ether
- queue[1].amount == 2 wei
- queue[2].amount == 2 wei(queue.length 为 2,所以无法正常访问到)
此时我们只需要调用 contract.withdraw(1)
就可以取出合约内所有资金!这样操作的意义等同于我们将第二次的 1 wei 用来填补上次合约多增加的 1 wei。