干货 | 从技术角度剖析针对THE DAO的攻击手法

叶问   |     |   4580 次阅读

上交所技术公司 朱立
lzhu@sse.com.cn

本文内容并非原创,而是来自对于以下两篇博文的翻译和解读。
Blog 1: http://vessenes.com/more-ethereum-attacks-race-to-empty-is-the-real-deal/
Blog 2: http://vessenes.com/deconstructing-thedao-attack-a-brief-code-tour/

一、攻击手法解密

由于攻击者是在创建childDAO并将Ether持续转入其中,这是目前唯一可行的提取Ether的机制,所以关注点从splitDAO函数开始。
splitDAO会创建childDAO(如果不存在的话),将分裂者拥有的Ether转入childDAO中,支付任何滋生的Reward(目前应该是0)然后返回。
根据白皮书的设计,splitDAO的本意是要保护投票中处于弱势地位的少数派防止他们被多数派通过投票的方式合法剥削。通过分裂出一个小规模的DAO,给予他们一个用脚投票的机制,同时仍然确保他们可以获取分裂前进行的对外资助产生的可能收益。
但通往地狱的道路就这样用鲜花铺就了。

实际被执行的向childDAO打款(Ether)的语句在function splitDAO 中,看这里(DAO.sol)

// 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;

在此语句执行之前,splitDAO函数有个modifier onlyTokenholders,其中对 msg.sender持有的dao token余额是否大于0作了检查:
modifier onlyTokenholders {
if (balanceOf(msg.sender) == 0) throw;
_
}

而在splitDAO的最后部分有将 balances[msg.sender]归0的代码:
totalSupply -= balances[msg.sender];
balances[msg.sender] = 0;
paidOut[msg.sender] = 0;
return true;

因此要想使得打款代码被反复执行,还得依靠其他手段才行,看下去:

根据BLOG 2,在DAO.sol中,function splitDAO函数有这样一行:


合约中,为msg.sender记录的dao币余额归零、扣减dao币总量totalSupply等等都发生在将发回msg.sender之后,这里是博主在Blog 1中指出的一个典型“反模式”。
接下来看withdrawRewardFor函数。目前github中所见最新代码并非部署于Ethereum livenet的代码,出问题的代码原来的样子博主说是这样:

paidOut[_account] += reward 在问题代码里面放在payOut函数调用之后,最新github代码中已经被移到了payOut之前。
再看payOut函数调用。rewardAccount的类型是ManagedAccount,在ManagedAccount.sol中可以看到:

注意标深的这一行:对_recipient发出call调用,转账_amount个Wei(其实转账的主力倒不在这里,因为目前并没什么Reward可以分配),call调用默认会使用当前剩余的所有gas,此时call调用所执行的代码步骤数可能很多,基本只受攻击者所发消息的可用的gas限制。
把这些拼起来,黑客的攻击手法就浮现了:

1)准备工作
黑客创建自己的黑客合约HC,该合约带有一个匿名的fallback函数。根据solidity的规范,fallback函数将在HC收到Ether(不带data)时自动执行。此fallback函数将通过递归触发对THE DAO的splitDAO函数的多次调用(但不会次数太多以避免gas不够),过程中还应该需要记录当前调用深度以控制堆栈使用情况。

2)开始攻击
黑客向THE DAO多次提交split proposal,并将每个proposal的recipient地址都设定为HC地址,同时设定适当的gasLimit。调用栈就会这样:

提交split proposal
   ---> splitDAO函数(No. 1)
        ---> createTokenProxy (No. 1, 向 HC发送以太第一次)
      ---> withdrawRewardFor函数 (No. 1,黑客的dao余额和dao总量此时没变!)
        ---> papOut 函数(No. 1)
           ---> HC的fallback函数 (No. 1)
              ---->如果递归未达预设深度:调用split DAO函数(No. 2)
                               ---> createTokenProxy (No. 2, 向 HC发送以太第二次)
                 ---> withdrawRewardFor函数(No. 2, 黑客dao余额等仍然没变!)
                    ---> payOut函数(No. 2,)
                      ---> HC的fallback函数 (No. 2)
                         ---> (继续递归)

由于堆栈深度和gas的限制,黑客可能需要预先准备多个不同账户分散持有dao,因为一次这样的攻击中,只有自上而下再逐层返回这样一轮机会可以利用,返回后账户持有的dao就被清空无法再用。

3)转款走人
转入childDAO的钱在一定时间后根据原合约可以提取,黑客收割韭菜的时候到了。

二、防范和思考

从代码看,本次攻击得逞的因素有二:一是dao余额扣减和Ether转账这两步操作的顺序有误,二是不受限制地执行未知代码。
应用代码顺序方面,应先扣减dao的余额再转账Ether,因为dao的余额检查作为转账Ether的先决条件,要求dao的余额状况必须能够及时反映最新状况。在问题代码实现中,尽管最深的递归返回并成功扣减黑客的dao余额,但此时对黑客dao余额的扣减已经无济于事,因为其上各层递归调用中余额检查都已成功告终,已经不会再有机会判断最新余额了。
不受限制地执行未知代码方面,虽然黑客当前是利用了solidity提供的匿名fallback函数,但这种对未知代码的执行原则上可以发生在更多场景下,因为合约之间的消息传递完全类似于面向对象程序开发中的方法调用,而提供接口等待回调是设计模式中常见的手法,所以完全有可能执行一个未知的普通函数。
类似黑客事件不限于以太坊,很可能未来会在rootstock等平台上重演。这是因为本次漏洞属于应用层面,并不是以太坊本身的问题,甚至都不能归咎于“图灵完备”,因为这种攻击即使在一个非图灵完备的平台上也可以奏效。总的来说,应用越复杂,应用出现安全问题的概率就越高
本次黑客事件带给我们很多思考:在TRUST MACHINE上是否应该信任未知代码?去中心化系统是否应该在设计之初就加入应急干预代码以备“临时停牌”?出现问题后在舆情掌控、信息公开等方面是否应该存在预案?项目在上线之前是否应该经过更长时间的社区审视和测试?
本次黑客事件不意味着以太坊乃至去中心化、区块链的终结。虽然教训深刻,但如果能够汲取教训,那么从中获益的不仅仅是THE DAO, 以太坊,整个区块链社区都将从中获益。

 
6 人喜欢