一、DAO项目介绍

DAO(Decentralized Autonomous Organization)是一种通过智能合约将个体与个体、个人与组织、或组织与组织联系在一起的新型组织形式。The DAO项目于2016年4月30日开始,融资窗口开放了28天。The DAO项目就这么火起来了,截止5月15日这个项目筹得了超过一亿美元,而到整个融资期结束,共有超过11,000位热情的成员参与进来,筹得1.5亿美元,成为历史上最大的众筹项目。The DAO所集资的钱远远超过其创建者的预期。

总结来说,DAO项目的运作方式如下:

在介绍完成DAO的基本概念后,我们介绍下16年的这次DAO的重大攻击。在2016年6月17日,运行在以太坊公链上的The DAO智能合约受到攻击。黑客利用合约中存在的递归调用不断转账“刷钱”,导致合约合约筹集的公共款不断的被转移到其子合约中。简单来说,此次攻击是由于以下两个方面的不当导致的

①函数存在逻辑漏洞,变量值更新不当以及智能合约本身存在的机制联合导致、②攻击者递归调用splitDAO函数,并不断进行自我调用。在递归快要接触到Blcok Gas Limit的时候进行了收尾工作并将自己的DAO资产转移到另一个受攻击账户并在利用完漏洞后将资产再转移回来。

如此以来,黑客利用2个账户反复利用Proposal进行攻击,从而转移了360万个以太币(价值6000万美元)。

二、DAO事件中的Race-To-Empty

Race-To-Empty情况我在之前的文章中有所简单的介绍,详细参考区块链的那些事—论激励机制与激励层中的Race-To-Empty攻击。不同于上一篇文章,这次的攻击更有针对性,专门针对DAO事件的源码进行的分析。

由对Race-To-Empty的了解,我们知道该攻击在DAO事件中是由于不断递归而进行的无限制转账问题,由此我们可以看下面代码:

function withdrawBalance() {  
  amountToWithdraw = userBalances[msg.sender];
  if (!(msg.sender.call.value(amountToWithdraw)())) { throw; }
  userBalances[msg.sender] = 0;
}

我们可以简单解释下。

userBalances[ msg.sender ]为我们需要转账的金额,并将值赋给amountToWithdraw。之后我们看msg.sender.call.value(amountToWithdraw)()),msg使用了call.value()方法,这个方法的含义为:发送余额,发送不成功时抛出异常,本次调用不成功。而在以太坊中,call.value()方法需要与gas结合,我们后面会介绍。之后调用userBalances[msg.sender] = 0修改余额为0。

而我们知道以太坊中的部分函数是附加有默认函数伴随执行的,例如上面的msg.sender.call.value(amountToWithdraw)())。会伴随执行下面函数。

function () {  

  vulnerableContract v;
  uint times;
  if (times == 0 && attackModeIsOn) {
    times = 1;
    v.withdrawBalance();
   } 

else { times = 0; }

}

当调用msg.sener.call.value()时,就会调用到默认函数,而默认函数又调用了withdraw造成了递归调用。如果我们将withdrawBalance()函数标记为函数W,将默认函数function标记为函数F。则有以下的堆栈情况:

先执行withdrawBalance,之后调用call,然后调用默认函数function,之后function中又执行了withdrawBalance......

第一次调用 userBalances[msg.sender] 有当前余额值
第二次调用 由于 userBalances[msg.sender] = 0 还没有调用到,因此 userBalances[msg.sender] 还是原来的值,因此会造成重复支付。

大家如果对上述内容能理解,那么就对DAO攻击理解了差不多了,下面看具体的攻击事件源码分析。

三、DAO攻击代码剖析

1 Solidity中的fallback函数(回退函数)

每一个合约有且仅有一个没有名字的函数。这个函数无参数,也无返回值。如果调用合约时,没有匹配上任何一个函数(或者没有传哪怕一点数据),就会调用默认的回退函数。

在以太坊中,当合约收到了ether时(没有任何其它数据),这个函数也会被执行。一般仅有少量的gas剩余,用于执行这个函数(准确的说,还剩2300 gas)。所以应该尽量保证回退函数使用少的gas。而在我们部署自己的智能合约函数时,我们需要对自己的回退函数进行测试,来保证函数的花费在2300 gas之内。

2 以太坊中send与call函数

我们由上面的对Race to empty的分析中可以知道,当调用call函数的时候我们会执行默认的函数。我们这里称为fallback函数(标题1中的解释)。而在以太坊中,我们对于gas的使用共有两个函数,send与call。那么他们有什么区别呢?

fallback 函数可以做尽量多的计算至到 gas 耗尽。有两种方法可以触发 fallback 函数:recipient.send() , recipient.call.value()

方法1: 有2300 gas限制
如果调用 recipient.send 函数的话,被 send 唤起的 fallback 函数最多只能消耗 2300 gas。

方法2: 会使用尽量多的gas,所以要注意安全问题 recipient.call.value(...)会使用尽量多的gas,另外两个函数callcode和delegatecall,也是如此。

如果想要和send方法达到同样的安全效果,调用者必须指定 gas limit为0,recipient.call.gas(0).value(...)

3 攻击流程概述

本次漏洞出现在应用层,是Solidity编程语言的智能合约代码漏洞。此攻击成功由于以下两个方面:一是DAO余额扣除与转账顺序有误。应该先进行扣除费用再进行转账;而问题代码中顺序恰好相反。二是未知代码被无限制的使用了,导致了攻击持续进行。

本次攻击攻击者创建了自己的合约,利用系统的匿名fallback函数通过递归触发DAO的splitDAO函数的多次调用。

我们来具体分析下源码:

一、首先是下面的源码,此函数表示对 msg.sender持有的dao token余额是否大于0作了检查。

// Modifier that allows only shareholders to vote and create new proposals
    modifier onlyTokenholders {
        if (balanceOf(msg.sender) == 0) throw;
    }

二、在DAO.sol中,我们在function splitDAO中可以找到向childDAO打款(Ether)的语句。源代码在TokenCreation.sol中,它会将代币从the parent DAO转移到the child DAO中。基本上攻击者就是利用这个来获得更多的代币并转移到child DAO中。

// Move ether and assign new Tokens
        uint fundsToBeMoved =
            (balances[msg.sender] * p.splitData[0].splitBalance) /
            p.splitData[0].totalSupply;
        if (p.splitData[0].newDAO.createTokenProxy.value(fundsToBeMoved)(msg.sender) == false)
            throw;

而平衡数组uint fundsToBeMoved = (balances[msg.sender] * p.splitData[0].splitBalance) / p.splitData[0].totalSupply决定了要转移的代币数量。因为每次攻击者调用这项功能时p.splitData[0]都是一样的(它是p的一个属性,即一个固定的值),并且p.splitData[0].totalSupplybalances[msg.sender]的值由于函数顺序问题没有被更新。如下:

// Burn DAO Tokens
        Transfer(msg.sender, 0, balances[msg.sender]);
        withdrawRewardFor(msg.sender); // be nice, and get his rewards
        totalSupply -= balances[msg.sender];
        balances[msg.sender] = 0;
        paidOut[msg.sender] = 0;
        return true;

所以我们想要实现不断的打款操作,必须依靠其他手段的帮助。根据上面的代码,合约中,为msg.sender记录的dao币余额归零、扣减dao币总量totalSupply等等都发生在将发回msg.sender之后。下面看withdrawRewardFor)()函数。

function withdrawRewardFor(address _account) noEther internal returns (bool _success) {
        if ((balanceOf(_account) * rewardAccount.accumulatedInput()) / totalSupply < paidOut[_account])
            throw;

        uint reward =
            (balanceOf(_account) * rewardAccount.accumulatedInput()) / totalSupply - paidOut[_account];
        if (!rewardAccount.payOut(_account, reward))
            throw;
        paidOut[_account] += reward;
        return true;
    }

paidOut[_account] += reward在问题代码里面放在payOut函数调用之后,再看payOut函数调用。

function payOut(address _recipient, uint _amount) returns (bool) {
        if (msg.sender != owner || msg.value > 0 || (payOwnerOnly && _recipient != owner))
            throw;

        if (_recipient.call.value(_amount)()) {    //注意这一行

            PayOut(_recipient, _amount);
            return true;
        } else {
            return false;
        }
    }

对_recipient发出call调用,转账_amount个Wei,call调用默认会使用当前剩余的所有gas。

以上内容就是我们黑客攻击所使用到的所有源码。

首先,黑客需要提前进行准备。黑客创建自己的黑客合约,合约同样会创建一个匿名的fallback函数。(根据solidity的规范,fallback函数将在HC收到Ether(不带data)时自动执行。)之后根据fallback函数我们会进行递归除法对splitDAO函数的多次调用。

之后,黑客开始进行攻击。我们用图的方式便于理解攻击流程。

由以上手段,系统会认为黑客的账户中一直有钱(因为提取钱后并没有更新金额),并且gas的作用同样没有发挥。

4 总结防范

总结以上的内容,我们可以简单说明如何更改代码来防范相关攻击。首先我们要将金额更新代码放至合理的位置。例如:

function withdrawBalance() {  
  amountToWithdraw = userBalances[msg.sender];
    userBalances[msg.sender] = 0;

    if( amountToWithdraw > 0 ) {
     if (!(msg.sender.call.value(amountToWithdraw)())) { throw; }
    }
}

我们可以先更新再调用。

除此之外,我们应该如何编写安全合理的智能合约呢?

希望大家在阅读后能有所收获,本篇也是作者在大量阅读文章后根据自己的思考完成的源码分析。希望为区块链安全爱好者能带来帮助。欢迎大家留言,安全并不是一蹴而就,里面仍有许多内容可以深度分析的。大家多多交流!

四、参考引用

本稿为原创稿件,转载请标明出处。谢谢。

源链接

Hacking more

...