区块链安全

math类型ctf-01

题目:https://capturetheether.com/challenges/math/token-sale/

题目中定义了一个虚拟的代币,并未为代币实现任何方法,只是通过一个 mapping 来追踪地址的代币数额,而代币的功能仅仅是作为转出 ether 的计量工具。

分析一下几个方法:

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

TokenSaleChallenge() 方法是和合约同名的构造器函数,要求合约的初始余额为 1 ether。

所以我们来看看合约的初始状态:

初始 balance 为 1 ether,初始我的钱包对应的 token 数量为 0。

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

isComplete() 函数定义了检查通关成功的条件,也就是当前合约的余额小于 1 ether。所以我们得找到一个方法,将当前合约中的 ether 一些转出来。

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

    balanceOf[msg.sender] += numTokens;
}

buy() 函数定义了买入 token 的方法。这是一个 payable 的方法,也就是往合约里面转钱的方法。
传入参数为想要购买的 token 数量,如果转入了 token 数量*PRICE_PER_TOKEN 这么多的 ether 数量,则更新 mapping。

我们目前暂时不需要调用这个方法,因为我们要做到的是从合约里面提钱,而不是将钱转入合约。

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

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

sell() 函数定义了卖出 token 的方法。传入想要卖出的 token 数量,如果 mapping 里面 msg.sender 这个 address 对应的 token 数量足够卖,则更新 msg.sender 对应的 token 数量,然后开始转出钱。转出的是 token 数量*PRICE_PER_TOKEN 这么多的 ether。

这就是我们想要的方法了,也就是转出 ether 的方法。转出的 ether == numTokens * PRICE_PER_TOKEN,因为 PRICE_PER_TOKEN 我们无法改变,能改变的就是 numTokens。这个值必须小于或等于 mapping 中地址对应的 token 数量。但是目前我们还没有 token。所以自然想到先使用 buy() 方法购买一些。

最终要转出的合约里面的这个 1ether 不是属于我们的,要转出不属于我们的 1 ether,光靠 buy() 和 sell() 是不行的,因为那是守恒的。你买了多少就能卖出多少。
所以如果想要能转出比买入更多的,就要求在买入的时候上溢。这也对应了这个合约全部没有用 SafeMath,有溢出漏洞。

所以要求你申请的 numTokens 比你实际花费的 msg.value 要少。
注意buy() 函数里面的这个判断 require(msg.value == numTokens * PRICE_PER_TOKEN);
msg.value 要足够小,numTokens 要足够大,而 PRICE_PER_TOKEN ==1 ether=10^18 wei。所以来构造上溢出。
numTokens*10^18 >= 2^256 时候会上溢出,又因为 msg.value == numTokens * PRICE_PER_TOKEN,为了让 msg.value 足够小,所以构造刚好造成上溢的 numTokens = 2^256/(10^18)+1= 115792089237316195423570985008687907853269984665640564039458
注:如果不加1,实测算了 msg.value 会变大。
此时 msg.value = numTokens*10^18%(2^256) = 415992086870360064 wei

然后为了能够以 wei 为单位发送 ether,我们不能从区块链浏览器上面 write contract 了。而是将这套源码通过 Remix 指明地址进行部署。然后调用:

部署好之后开始调用 buy 方法。根据上面的计算,msg.value == 415992086870360064 wei, numTokens 参数为 115792089237316195423570985008687907853269984665640564039458。

调用成功之后发现 msg.sender 对应的 token 数量变为了一个很大的数。

而合约余额也变了(上图没有刷新),加入了我们刚刚转入的钱:

总之相当于以相对较小的 msg.value 以小搏大凭空获取了巨量的 token。而这些 token 在 sell() 函数中是可以真正以 1 ether 的兑换率被转出的。所以现在就有足够的 gas 转出 1 ether 了。所以就相当于充了一些 gas,套现了 1 ether。

因为 numTokens 为 uint256,而合约余额约为 1.4 ether,所以这里只能写1,但是这样转出了 1ether,就满足合约余额小于1ether 的条件了。验证成功:

总结:

  1. 要套出合约余额要求必须构造溢出。
  2. 未使用 SafeMath 库说明可以构造溢出。
  3. 构造溢出时候注意要尽量满足 msg.value 较小。这样成本比较低。

最后贴上一个比较方便的字符串转hex数组(byte32)的网站:
https://web3-type-converter.onbrn.com/