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万美元)。
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攻击理解了差不多了,下面看具体的攻击事件源码分析。
每一个合约有且仅有一个没有名字的函数。这个函数无参数,也无返回值。如果调用合约时,没有匹配上任何一个函数(或者没有传哪怕一点数据),就会调用默认的回退函数。
在以太坊中,当合约收到了ether时(没有任何其它数据),这个函数也会被执行。一般仅有少量的gas剩余,用于执行这个函数(准确的说,还剩2300 gas)。所以应该尽量保证回退函数使用少的gas。而在我们部署自己的智能合约函数时,我们需要对自己的回退函数进行测试,来保证函数的花费在2300 gas之内。
我们由上面的对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(...)
。
本次漏洞出现在应用层,是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].totalSupply
与balances[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的作用同样没有发挥。
总结以上的内容,我们可以简单说明如何更改代码来防范相关攻击。首先我们要将金额更新代码放至合理的位置。例如:
function withdrawBalance() {
amountToWithdraw = userBalances[msg.sender];
userBalances[msg.sender] = 0;
if( amountToWithdraw > 0 ) {
if (!(msg.sender.call.value(amountToWithdraw)())) { throw; }
}
}
我们可以先更新再调用。
除此之外,我们应该如何编写安全合理的智能合约呢?
希望大家在阅读后能有所收获,本篇也是作者在大量阅读文章后根据自己的思考完成的源码分析。希望为区块链安全爱好者能带来帮助。欢迎大家留言,安全并不是一蹴而就,里面仍有许多内容可以深度分析的。大家多多交流!
本稿为原创稿件,转载请标明出处。谢谢。