作者:Pinging(先知社区)

一、前言

在分析过众多的基础漏洞后,我将漏洞的利用投向真实合约环境中。而最近“币圈”的点点滴滴吸引着我,所以我阅读了一些代币合约并分析与代币系统相关的合约漏洞。

本文针对一种部署在以太坊上的博彩合约进行分析,包括代码分析以及漏洞测试,最后针对真实以太坊环境进行分析。

二、庞氏代币介绍

对于刚刚接触区块链的同学来说,应该会经常听到人们谈论“币圈”。而“币圈”也是推动区块链成为家喻户晓产品的一个重要的因素。而在形形色色的区块链金融产品中,我们或多或少会看到“庞氏”骗局的影子。

所谓“庞氏”骗局就是:他们向投资者承诺,如果你向某合约投资一笔以太坊,它就会以一个高回报率回赠你更多的以太币,然而高回报只能从后续的投资者那里源源不断地吸取资金以反馈给前面的投资者。

例如某些基于EOS开发的博彩类产品,所采用的的投注方式就是“玩家使用EOS代币与项目方发行的代币MEV进行替换”,然后利用代币再进行博彩。除了在智能合约中“埋雷”,发行代币“血洗”玩家,开发者其实还有个“杀手锏式”的方法让参与者的钱乖乖进入他的口袋,那就是开发“庞氏骗局”智能合约。常见的是一种“庞氏骗局合约”,该合约是这样设定的:如果你向某合约投资一笔以太币,它就会以高回报率回赠更多的以太币,高回报的背后是从后续投资者那里源源不断地吸取资金以反馈给前面的投资者。

对于区块链来说,它的底层机制能够降低信任风险。区块链技术具有开源、透明的特性,系统的参与者能够知晓系统的运行规则,验证账本内容和账本构造历史的真实性和完整性,确保交易历史是可靠的、没有被篡改的,相当于提高了系统的可追责性,降低了系统的信任风险。 所以这也就是为什么有如此多的用户选择将资金投入到区块链平台中。

三、合约分析

1 合约介绍

在本文中,我们来介绍ETHX代币合约。ETHX是一个典型的庞氏代币合约。该合约可以看成虚拟币交易所,但只有ETH和ETHX (ERC20 token)交易对,每次交易,都有部分的token分配给整个平台的已有的token持有者,因此token持有者在持币期间,将会直接赚取新购买者和旧抛售者的手续费。所以这也不断激励着用户将自己以太币投入到合约中,以便自己占有更多的股份来获取到更多的利润。

与我上次分析的Fomo3D合约类似,此合约当你投入了一定以太币后,就需要等相当长的时间才能够把本金赚足。而在这个过程中又不断有新用户参与到合约中,而源源不断的自己最后的获益者也就是合约创建者本人了。

下面我们就针对这个合约进行详细的分析,来看看合约创建者是以何思路来设计这个合约的。

2 代码分析

以太坊合约详情如下:https://etherscan.io/address/0x1c98eea5fe5e15d77feeabc0dfcfad32314fd481

代码如下:

pragma solidity ^0.4.19;

// If you wanna escape this contract REALLY FAST
// 1. open MEW/METAMASK
// 2. Put this as data: 0xb1e35242
// 3. send 150000+ gas
// That calls the getMeOutOfHere() method

// Wacky version, 0-1 tokens takes 10eth (should be avg 200% gains), 1-2 takes another 30eth (avg 100% gains), and beyond that who the fuck knows but it's 50% gains
// 10% fees, price goes up crazy fast ETHCONNNNNNNNNNNECT! www.ethconnectx.online
contract EthConnectPonzi {
    uint256 constant PRECISION = 0x10000000000000000;  // 2^64
    // CRR = 80 %
    int constant CRRN = 1;
    int constant CRRD = 2;
    // The price coefficient. Chosen such that at 1 token total supply
    // the reserve is 0.8 ether and price 1 ether/token.
    int constant LOGC = -0x296ABF784A358468C;

    string constant public name = "ETHCONNECTx";
    string constant public symbol = "ETHX";
    uint8 constant public decimals = 18;
    uint256 public totalSupply;
    // amount of shares for each address (scaled number)
    mapping(address => uint256) public balanceOfOld;
    // allowance map, see erc20
    mapping(address => mapping(address => uint256)) public allowance;
    // amount payed out for each address (scaled number)
    mapping(address => int256) payouts;
    // sum of all payouts (scaled number)
    int256 totalPayouts;
    // amount earned for each share (scaled number)
    uint256 earningsPerShare;

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

    //address owner;

    function EthConnectPonzi() public {
        //owner = msg.sender;
    }

    // These are functions solely created to appease the frontend
    function balanceOf(address _owner) public constant returns (uint256 balance) {
        return balanceOfOld[_owner];
    }

    function withdraw(uint tokenCount) // the parameter is ignored, yes
      public
      returns (bool)
    {
        var balance = dividends(msg.sender);
        payouts[msg.sender] += (int256) (balance * PRECISION);
        totalPayouts += (int256) (balance * PRECISION);
        msg.sender.transfer(balance);
        return true;
    }

    function sellMyTokensDaddy() public {
        var balance = balanceOf(msg.sender);
        transferTokens(msg.sender, address(this),  balance); // this triggers the internal sell function
    }

    function getMeOutOfHere() public {
        sellMyTokensDaddy();
        withdraw(1); // parameter is ignored
    }

    function fund()
      public
      payable 
      returns (bool)
    {
      if (msg.value > 0.000001 ether)
            buy();
        else
            return false;

      return true;
    }

    function buyPrice() public constant returns (uint) {
        return getTokensForEther(1 finney);
    }

    function sellPrice() public constant returns (uint) {
        return getEtherForTokens(1 finney);
    }

    // End of useless functions

    // Invariants
    // totalPayout/Supply correct:
    //   totalPayouts = \sum_{addr:address} payouts(addr)
    //   totalSupply  = \sum_{addr:address} balanceOfOld(addr)
    // dividends not negative:
    //   \forall addr:address. payouts[addr] <= earningsPerShare * balanceOfOld[addr]
    // supply/reserve correlation:
    //   totalSupply ~= exp(LOGC + CRRN/CRRD*log(reserve())
    //   i.e. totalSupply = C * reserve()**CRR
    // reserve equals balance minus payouts
    //   reserve() = this.balance - \sum_{addr:address} dividends(addr)

    function transferTokens(address _from, address _to, uint256 _value) internal {
        if (balanceOfOld[_from] < _value)
            revert();
        if (_to == address(this)) {
            sell(_value);
        } else {
            int256 payoutDiff = (int256) (earningsPerShare * _value);
            balanceOfOld[_from] -= _value;
            balanceOfOld[_to] += _value;
            payouts[_from] -= payoutDiff;
            payouts[_to] += payoutDiff;
        }
        Transfer(_from, _to, _value);
    }

    function transfer(address _to, uint256 _value) public {
        transferTokens(msg.sender, _to,  _value);
    }

    function transferFrom(address _from, address _to, uint256 _value) public {
        var _allowance = allowance[_from][msg.sender];
        if (_allowance < _value)
            revert();
        allowance[_from][msg.sender] = _allowance - _value;
        transferTokens(_from, _to, _value);
    }

    function approve(address _spender, uint256 _value) public {
        // To change the approve amount you first have to reduce the addresses`
        //  allowance to zero by calling `approve(_spender, 0)` if it is not
        //  already 0 to mitigate the race condition described here:
        //  https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
        if ((_value != 0) && (allowance[msg.sender][_spender] != 0)) revert();
        allowance[msg.sender][_spender] = _value;
        Approval(msg.sender, _spender, _value);
    }

    function dividends(address _owner) public constant returns (uint256 amount) {
        return (uint256) ((int256)(earningsPerShare * balanceOfOld[_owner]) - payouts[_owner]) / PRECISION;
    }

    function withdrawOld(address to) public {
        var balance = dividends(msg.sender);
        payouts[msg.sender] += (int256) (balance * PRECISION);
        totalPayouts += (int256) (balance * PRECISION);
        to.transfer(balance);
    }

    function balance() internal constant returns (uint256 amount) {
        return this.balance - msg.value;
    }
    function reserve() public constant returns (uint256 amount) {
        return balance()
            - ((uint256) ((int256) (earningsPerShare * totalSupply) - totalPayouts) / PRECISION) - 1;
    }

    function buy() internal {
        if (msg.value < 0.000001 ether || msg.value > 1000000 ether)
            revert();
        var sender = msg.sender;
        // 5 % of the amount is used to pay holders.
        var fee = (uint)(msg.value / 10);

        // compute number of bought tokens
        var numEther = msg.value - fee;
        var numTokens = getTokensForEther(numEther);

        var buyerfee = fee * PRECISION;
        if (totalSupply > 0) {
            // compute how the fee distributed to previous holders and buyer.
            // The buyer already gets a part of the fee as if he would buy each token separately.
            var holderreward =
                (PRECISION - (reserve() + numEther) * numTokens * PRECISION / (totalSupply + numTokens) / numEther)
                * (uint)(CRRD) / (uint)(CRRD-CRRN);
            var holderfee = fee * holderreward;
            buyerfee -= holderfee;

            // Fee is distributed to all existing tokens before buying
            var feePerShare = holderfee / totalSupply;
            earningsPerShare += feePerShare;
        }
        // add numTokens to total supply
        totalSupply += numTokens;
        // add numTokens to balance
        balanceOfOld[sender] += numTokens;
        // fix payouts so that sender doesn't get old earnings for the new tokens.
        // also add its buyerfee
        var payoutDiff = (int256) ((earningsPerShare * numTokens) - buyerfee);
        payouts[sender] += payoutDiff;
        totalPayouts += payoutDiff;
    }

    function sell(uint256 amount) internal {
        var numEthers = getEtherForTokens(amount);
        // remove tokens
        totalSupply -= amount;
        balanceOfOld[msg.sender] -= amount;

        // fix payouts and put the ethers in payout
        var payoutDiff = (int256) (earningsPerShare * amount + (numEthers * PRECISION));
        payouts[msg.sender] -= payoutDiff;
        totalPayouts -= payoutDiff;
    }

    function getTokensForEther(uint256 ethervalue) public constant returns (uint256 tokens) {
        return fixedExp(fixedLog(reserve() + ethervalue)*CRRN/CRRD + LOGC) - totalSupply;
    }

    function getEtherForTokens(uint256 tokens) public constant returns (uint256 ethervalue) {
        if (tokens == totalSupply)
            return reserve();
        return reserve() - fixedExp((fixedLog(totalSupply - tokens) - LOGC) * CRRD/CRRN);
    }

    int256 constant one       = 0x10000000000000000;
    uint256 constant sqrt2    = 0x16a09e667f3bcc908;
    uint256 constant sqrtdot5 = 0x0b504f333f9de6484;
    int256 constant ln2       = 0x0b17217f7d1cf79ac;
    int256 constant ln2_64dot5= 0x2cb53f09f05cc627c8;
    int256 constant c1        = 0x1ffffffffff9dac9b;
    int256 constant c3        = 0x0aaaaaaac16877908;
    int256 constant c5        = 0x0666664e5e9fa0c99;
    int256 constant c7        = 0x049254026a7630acf;
    int256 constant c9        = 0x038bd75ed37753d68;
    int256 constant c11       = 0x03284a0c14610924f;

    function fixedLog(uint256 a) internal pure returns (int256 log) {
        int32 scale = 0;
        while (a > sqrt2) {
            a /= 2;
            scale++;
        }
        while (a <= sqrtdot5) {
            a *= 2;
            scale--;
        }
        int256 s = (((int256)(a) - one) * one) / ((int256)(a) + one);
        // The polynomial R = c1*x + c3*x^3 + ... + c11 * x^11
        // approximates the function log(1+x)-log(1-x)
        // Hence R(s) = log((1+s)/(1-s)) = log(a)
        var z = (s*s) / one;
        return scale * ln2 +
            (s*(c1 + (z*(c3 + (z*(c5 + (z*(c7 + (z*(c9 + (z*c11/one))
                /one))/one))/one))/one))/one);
    }

    int256 constant c2 =  0x02aaaaaaaaa015db0;
    int256 constant c4 = -0x000b60b60808399d1;
    int256 constant c6 =  0x0000455956bccdd06;
    int256 constant c8 = -0x000001b893ad04b3a;
    function fixedExp(int256 a) internal pure returns (uint256 exp) {
        int256 scale = (a + (ln2_64dot5)) / ln2 - 64;
        a -= scale*ln2;
        // The polynomial R = 2 + c2*x^2 + c4*x^4 + ...
        // approximates the function x*(exp(x)+1)/(exp(x)-1)
        // Hence exp(x) = (R(x)+x)/(R(x)-x)
        int256 z = (a*a) / one;
        int256 R = ((int256)(2) * one) +
            (z*(c2 + (z*(c4 + (z*(c6 + (z*c8/one))/one))/one))/one);
        exp = (uint256) (((R + a) * one) / (R - a));
        if (scale >= 0)
            exp <<= scale;
        else
            exp >>= -scale;
        return exp;
    }

    /*function destroy() external {
        selfdestruct(owner);
    }*/

    function () payable public {
        if (msg.value > 0)
            buy();
        else
            withdrawOld(msg.sender);
    }
}

下面我们针对这个合约进行关键函数的分析。

对于代币合约,我们将从代币购买、股份奖励、利息提取等进行依次分析。

首先看合约变量:

uint256 public totalSupply;
    // amount of shares for each address (scaled number)
    mapping(address => uint256) public balanceOfOld;
    // allowance map, see erc20
    mapping(address => mapping(address => uint256)) public allowance;
    // amount payed out for each address (scaled number)
    mapping(address => int256) payouts;
    // sum of all payouts (scaled number)
    int256 totalPayouts;
    // amount earned for each share (scaled number)
    uint256 earningsPerShare;

这些变量以此代表:合约中代币总和、用户的代币余额、津贴数量(ERC20变量)、用户分红金额、总分红金额、每股获得的收益。

下面我们来看用户如何购买代币:

function fund()
      public
      payable 
      returns (bool)
    {
      if (msg.value > 0.000001 ether)
            buy();
        else
            return false;

      return true;
    }

首先用户可以调用fund()函数。在函数中要求用户传入msg.value > 0.000001,之后进入buy()函数。

function buy() internal {
        if (msg.value < 0.000001 ether || msg.value > 1000000 ether)
            revert();
        var sender = msg.sender;
        // 5 % of the amount is used to pay holders.
        var fee = (uint)(msg.value / 10);

        // compute number of bought tokens
        var numEther = msg.value - fee;

        // 计算用户购买金额对应的股份
        var numTokens = getTokensForEther(numEther);

        var buyerfee = fee * PRECISION;
        if (totalSupply > 0) {
            // compute how the fee distributed to previous holders and buyer.
            // 计算持有股份的人获得的金额
            // The buyer already gets a part of the fee as if he would buy each token separately.
            var holderreward =
                (PRECISION - (reserve() + numEther) * numTokens * PRECISION / (totalSupply + numTokens) / numEther)
                * (uint)(CRRD) / (uint)(CRRD-CRRN);
            var holderfee = fee * holderreward;
            buyerfee -= holderfee;

            // Fee is distributed to all existing tokens before buying
            var feePerShare = holderfee / totalSupply;
            earningsPerShare += feePerShare;
        }
        // add numTokens to total supply
        totalSupply += numTokens;
        // add numTokens to balance
        balanceOfOld[sender] += numTokens;
        // fix payouts so that sender doesn't get old earnings for the new tokens.
        // also add its buyerfee
        var payoutDiff = (int256) ((earningsPerShare * numTokens) - buyerfee);
        payouts[sender] += payoutDiff;
        totalPayouts += payoutDiff;
    }

在这个函数中,首先要求用户传入的金额有一定的范围。msg.value < 0.000001 ether || msg.value > 1000000 ether

之后合约提取了用户的百分之十的金额来作为手续费(这里的注释中写的是5%,但是这里的代码是 /10,而其他地方同样没有减少这个值,所以我认为应该还是百分之十)。

var fee = (uint)(msg.value / 10);

之后合约将剩下的钱传入getTokensForEther()进行处理(这里是合约自行设计的转换函数,将固定数量的以太币转换为相对应的股份),得到numTokens

var numEther = msg.value - fee;

        // 计算用户购买金额对应的股份
        var numTokens = getTokensForEther(numEther);

if (totalSupply > 0)。如果这不是第一个用户参与合约,那么进入下面的处理。(如果是第一个用户,那么totalSupply += numTokens;直接在总代币上进行相加。)

if (totalSupply > 0) {
            // compute how the fee distributed to previous holders and buyer.
            // 计算持有股份的人获得的金额
            // The buyer already gets a part of the fee as if he would buy each token separately.
            var holderreward =
                (PRECISION - (reserve() + numEther) * numTokens * PRECISION / (totalSupply + numTokens) / numEther)
                * (uint)(CRRD) / (uint)(CRRD-CRRN);
            var holderfee = fee * holderreward;
            buyerfee -= holderfee;

            // Fee is distributed to all existing tokens before buying
            var feePerShare = holderfee / totalSupply;
            earningsPerShare += feePerShare;
        }

上述函数中主要是针对earningsPerShare进行更新。每次有新用户加入合约并传入一定以太币均会调用此函数,之后var feePerShare = holderfee / totalSupply; earningsPerShare += feePerShare;

而每次earningsPerShare变量更新后,老用户均可以提升自己的利息。

下面我们来看提取分红的函数。

function withdraw(uint tokenCount) // the parameter is ignored, yes
      public
      returns (bool)
    {
        var balance = dividends(msg.sender);
        payouts[msg.sender] += (int256) (balance * PRECISION);
        totalPayouts += (int256) (balance * PRECISION);
        msg.sender.transfer(balance);
        return true;
    }

函数中的balance赋值为dividends(msg.sender)。而这是分红函数:

function dividends(address _owner) public constant returns (uint256 amount) {
        return (uint256) ((int256)( earningsPerShare * balanceOfOld[_owner]) - payouts[_owner]) / PRECISION;
    }

其中具体的值不用我们深究,其分工的具体的金额与股份的单价、用户的余额以及已获得分红有关。

我们继续回到withdraw()函数。在计算完成分红金额后,合约将分红转给用户msg.sender.transfer(balance)

而这里的transfer()函数同样是封装好的函数:

function transfer(address _to, uint256 _value) public {
        transferTokens(msg.sender, _to,  _value);
    }
function transferTokens(address _from, address _to, uint256 _value) internal {
        if (balanceOfOld[_from] < _value)
            revert();
        if (_to == address(this)) {
            sell(_value);
        } else {
            int256 payoutDiff = (int256) (earningsPerShare * _value);
            balanceOfOld[_from] -= _value;
            balanceOfOld[_to] += _value;
            payouts[_from] -= payoutDiff;
            payouts[_to] += payoutDiff;
        }
        Transfer(_from, _to, _value);
    }

这里首先判断用户的余额是否足够,之后判断_to是否是合约地址,如果不是则说明是转账给其他用户。这时对转账双方均进行余额变量处理。

倘若_to是合约地址:则调用sell(_value)函数。

function sell(uint256 amount) internal {
        var numEthers = getEtherForTokens(amount);
        // remove tokens
        totalSupply -= amount;
        balanceOfOld[msg.sender] -= amount;

        // fix payouts and put the ethers in payout
        var payoutDiff = (int256) (earningsPerShare * amount + (numEthers * PRECISION));
        payouts[msg.sender] -= payoutDiff;
        totalPayouts -= payoutDiff;
    }

函数开始计算amount对应的股份numEthers。之后由于这时用户进行的转账,所以转账方的余额记录应该被修改。这里调用了:

totalSupply -= amount;
balanceOfOld[msg.sender] -= amount;

然而我们其实可以发现这里的问题,当我们顺着函数思路到达这里后,我们应该能发现这里减少的是msg.sender的余额,然而我们此次的转账者仅仅只是msg.sender吗?这里我们要打一个问号。具体的情况我们在下一章具体来看。

取出分红除了上述函数外,还有下面这个函数。

function withdrawOld(address to) public {
        var balance = dividends(msg.sender);
        payouts[msg.sender] += (int256) (balance * PRECISION);
        totalPayouts += (int256) (balance * PRECISION);
        to.transfer(balance);
    }

这个函数能够传入参数 to,并帮助其他用户获取分红。

由于合约属于ERC20的延伸合约,所以同样里面拥有ERC20的影子。

function approve(address _spender, uint256 _value) public {
        // To change the approve amount you first have to reduce the addresses`
        //  allowance to zero by calling `approve(_spender, 0)` if it is not
        //  already 0 to mitigate the race condition described here:
        //  https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
        if ((_value != 0) && (allowance[msg.sender][_spender] != 0)) revert();
        allowance[msg.sender][_spender] = _value;
        Approval(msg.sender, _spender, _value);
    }

这个函数能够授权其他用户帮助自己进行股权转让。其中比较经典的变量是:allowance

之后用户可以调用一下函数进行股权转让。

function transferFrom(address _from, address _to, uint256 _value) public {
        var _allowance = allowance[_from][msg.sender];
        if (_allowance < _value)
            revert();
        allowance[_from][msg.sender] = _allowance - _value;
        transferTokens(_from, _to, _value);
    }

上述内容就是这个庞氏代币的关键流程。而在我们分析之后,我发现我们在转入相应的代币后,唯一获利的方法就是不断的从分红中获得利润,而这个分红的金额是十分有限的,所以想要通过这个方法回本那就需要不断发展新的用户进入合约。

四、漏洞利用

1 漏洞分析

而讲述了这么多合约代码,下面我们要针对合约进行漏洞分析。

我们刚才对合约漏洞稍微有点介绍,下面详细的分析下。

我们知道问题出在这里:

function transferTokens(address _from, address _to, uint256 _value) internal {
        if (balanceOfOld[_from] < _value)
            revert();
        if (_to == address(this)) {
            sell(_value);
        } else {
            int256 payoutDiff = (int256) (earningsPerShare * _value);
            balanceOfOld[_from] -= _value;
            balanceOfOld[_to] += _value;
            payouts[_from] -= payoutDiff;
            payouts[_to] += payoutDiff;
        }
        Transfer(_from, _to, _value);
    }

我们传入_from_to,当_to==address(this)时,进入了sell(_value)

而在这个函数中:

function sell(uint256 amount) internal {
        var numEthers = getEtherForTokens(amount);
        // remove tokens
        totalSupply -= amount;
        balanceOfOld[msg.sender] -= amount;

        // fix payouts and put the ethers in payout
        var payoutDiff = (int256) (earningsPerShare * amount + (numEthers * PRECISION));
        payouts[msg.sender] -= payoutDiff;
        totalPayouts -= payoutDiff;
    }

按照正常的思维来考虑,合约应该对_from进行余额的调整,然而这里却是:balanceOfOld[msg.sender] -= amount(对msg.sender)。

假设我们这里拥有A B 两个用户,A授权B进行转账1 个股份,然而B将A的股份转还给了合约地址,那么我们进入了sell()函数。此时然而B此时躺枪,明明是A将股份转给了合约,然而你为啥转我B的余额(因为B是msg.sender)???此时就导致了bug存在。

2 漏洞演示

下面我们演示漏洞的利用。

我们在0xca35b7d915458ef540ade6068dfe2f44e8fa733c地址处部署合约。合约地址为:0xbbf289d846208c16edc8474705c748aff07732db

之后A用户为:0x14723a09acff6d2a60dcdf7aa4aff308fddc160c
B用户为:0x4b0897b0513fdc7c541b6d9d7e929c4e5364d2db

A授权B 1股权的转账能力。

之后我们领A加入合约的股东行列,向合约转账5 ether。

之后我们切换到B账户。

为了比较,我们先查找B的余额。为0,是肯定的。

之后调用transferFrom()

参数为:transferFrom(A的钱包地址,合约地址,1)。【"0x14723a09acff6d2a60dcdf7aa4aff308fddc160c","0xbbf289d846208c16edc8474705c748aff07732db",1】

之后我再查看B的余额。

发现果然溢出了emmmm。

具体的理论为:

B的余额本来为0,而调用了sell后-1,所以溢出到达最大值。从而出现了重大隐患。

而由于合约出现了严重漏洞,所以这个博彩类合约也就没有人玩了。正如图中的合约余额,只剩下了1 wei

五、参考资料

源链接

Hacking more

...