区块链的那些事—THE DAO攻击事件源码分析

本稿件为原创,首次发布在先知社区中,原稿件为:
https://xz.aliyun.com/t/2905

一、DAO项目介绍

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

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

  • 1 首先要拥有其自己的团队来编写运行的智能合约代码
  • 2 有初始融资阶段,在这一阶段人们添加资金来购买代币,来代表其所有权——这个过程叫做众销,或者首次代币发行(ICO)——为其提供所需资源。
  • 3 当融资结束后,DAO项目就可以开始利用融到的钱真正开始运作。
  • 4 人们开始像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......
image.png

第一次调用 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函数的多次调用。

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

image.png

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

4 总结防范

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

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

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

我们可以先更新再调用。

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

  • 首先要区别send和call的区别,并理解以太坊的gas机制
  • 使用正确的顺序或者采用加锁的方式
  • 转换发送模式为提款模式,使收款方控制以太币转移,减少其他逻辑和提款逻辑的耦合
  • 防范调用栈攻击,判断调用外部合约的结果
  • 去掉循环处理,或者限制循环防范gas限制攻击或者让合约调用者控制循环

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

四、参考引用

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,496评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,407评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,632评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,180评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,198评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,165评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,052评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,910评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,324评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,542评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,711评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,424评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,017评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,668评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,823评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,722评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,611评论 2 353