以太坊TheDAO攻击

Catalogue
  1. 1. 重入问题(Re-Entrancy)
  2. 2. 重入预防
  3. 3. The DAO攻击事件
  4. 4. The DAO项目介绍
  5. 5. The DAO攻击流程
  6. 6. The DAO攻击解决方案
  7. 7. 参考资料

重入问题(Re-Entrancy)

以太坊智能合约能够调用和利用其他外部合约的代码。合约通常也处理以太币,因此可以将以太币发送到各种外部用户地址。调用外部合约或将以太币发送到地址的操作要求合约提交外部调用。这些外部调用可以被攻击者劫持,从而迫使合约执行更多的代码(即通过 fallback 回退函数),包括回调原合约本身。所以,合约代码执行过程中将可以“重入”该合约,有点像编程语言里面的间接递归函数调用。

当合约将以太币发送到未知地址时,可能会发生此攻击。攻击者可以在外部地址小心地构建合约,该地址包含回退函数中的恶意代码。因此,当合约把以太币发送到此地址时,将激活恶意代码。在恶意代码中,外部恶意合约再调用被攻击合约上的一个函数,从而构成重入。因为被合约的程序员可能没有预料到合约代码可以被重入,因此合约会出现不可预知的行为。

为了说明这一点,考虑以下简单易受攻击的合约,该合约充当以太坊金库,允许存款人每周仅提取最多1个以太币。

EtherStore.sol:

1
todo

该合约有两个公共函数:depositFunds() 和 withdrawFunds() 。depositFunds() 函数只是累计发送者余额。withdrawFunds() 函数允许发送者指定要提取的以太币数量(wei为单位)。只有当要求提取的金额小于或等于1个以太币、并且在一周内没有发生提取时,它才会成功。真的是这样吗?

漏洞出现在第17行,我们向用户发送他们要求的以太数量。考虑一个恶意攻击者创建以下合约:

Attack.sol:

1
todo

我们来看看这个恶意合约如何利用 EtherStore 合约。攻击者将使用 EtherStore 的合约地址作为构造函数参数创建上述合约(假设恶意地址是0x0 … 123)。这将初始化并将公共变量 etherStore ,使其指向希望攻击的合约地址。然后攻击者将调用 pwnEtherStore() 函数,并使用一些以太币(大于或等于1),例如1个以太币。在这个例子中,我们假设许多其他用户已将以太币存入此合约,这样它的当前余额为10个以太币。然后会发生以下情况:

  1. Attack.sol - 第15行- EtherStore 合约的 depositFunds() 函数将被调用,其中msg.value 为1 Ether(以及大量的Gas)。发件人(msg.sender)将是我们的恶意合约(0x0 … 123)。因此,balances[0x0..123] = 1Ether。
  2. Attack.sol - 第17行- 然后恶意合约将使用1 ether的参数调用EtherStore合约的withdrawFunds()函数。这将通过所有require语句(EtherStore合约的第[12] - [16]行),因为我们之前没有提取过。
  3. EtherStore.sol - 第17行- 然后合约将1以太币发回恶意合约。
  4. Attack.sol - 第25行- 发送给恶意合约的以太币将执行回退函数。
  5. Attack.sol - 第26行- EtherStore 合约的总余额为10个以太币,现在为9个以太币,因此if语句通过。
  6. Attack.sol - 第27行– 回退函数再次调用 EtherStore 的 withdrawFunds() 函数并“重新进入” EtherStore 合约。
  7. EtherStore.sol - 第11行- 在第二次调用 withdrawFunds() 时,我们的余额仍为1以太,因为第18行尚未执行。因此,我们仍然有 balances[0x0..123] = 1 Ether。lastWithdrawTime 变量也是如此。我们再次通过了所有要求。
  8. EtherStore.sol - 第17行- 我们提取另外1个以太币。
  9. 步骤4-8将重复- 直到 EtherStore.balance<= 1,如 Attack.sol 中的第26行所示。
  10. Attack.sol - 第26行 - 一旦 EtherStore 合约中剩下不多于1(或更少)的ether,则此if语句将失败。然后,这将允许执行 EtherStore 合约的第18和19行(对于withdrawFunds()函数的每次调用)。
  11. EtherStore.sol – 第18和19行- 将设置 balances 和 lastWithdrawTime 映射,执行将结束。

最终的结果是,攻击者通过这笔交易,立即从 EtherStore 合约中提取了所有以太币(只留下不超过1个以太币)。

重入预防

程序员写合约时需要留个心眼,提防合约重入的可能性,采用一些技术避免智能合约中潜在的重入漏洞。常用如下:

  • 在可能的情况下,将 ether 发送到外部合约时使用内置的 transfer() 函数。transfer() 函数仅发送 2300 Gas 给外部调用,这不足以使目的地址合约调用另一个合约(即重入原合约)。
  • 确保所有改变状态变量的逻辑,都发生在以太币被发送出合约(或任何外部调用)之前。在 EtherStore 示例中,EtherStore.sol 的第18和19行应放在第17行之前。最好将对未知地址的外部调用,作为本地函数或代码的最后一个操作。这在以太坊文档中称为检查-效果- 交互(checks-effects-interactions)模式。
  • 引入互斥锁。也就是说,添加一个状态变量,在代码执行期间锁定合约,防止重入调用。

将所有这些技术(不是全部都需要,但是为了演示目的都使用了)应用于EtherStore.sol,得到以下无重入漏洞的合约:

1

The DAO攻击事件

  • 2016年4月30日,The DAO的初创团队开始在以太坊上通过智能合约进行ICO众筹。28天时间,筹得1.5亿美元,成为历史上最大的众筹项目。
  • 2016年6月12日,The DAO创始人之一Stephan TualTual宣布,他们发现了软件中存在的“递归调用漏洞”问题。 不幸的是,在程序员修复这一漏洞及其他问题的期间,一个不知名的黑客开始利用这一途径收集The DAO代币销售中所得的以太币。
  • 2016年6月18日,黑客成功获利超过360万个以太币。
  • 2016年6月17日,以太坊基金会的Vitalik Buterin更新一项重要报告,他表示,DAO正在遭到攻击,最终因为社区的不同意见,最终以太坊分裂出支持继续维持原状的以太经典 ETC,同意硬分叉解决方案的在以太坊当前网络实施。

The DAO持有近15%的以太币总数,因此THE DAO这次的问题对以太坊网络及其加密币都产生了负面影响。

The DAO项目介绍

DAO全称是Decentralized Autonomous Organization,即“去中心化的自治组织”,可理解为完全由计算机代码控制运作的类似公司的实体,在人类历史上还是首次。看到这里,请你闭上眼睛(但不要睡着),想象一个完全由代码控制的公司里面,看不到founder,没有CEO,没有CTO,没有人事财务研发市场销售部,根除了攀比、懒散、官僚、倾轧等种种让公司效率下降的人为因素,剩下的只是吃苦耐劳的机器在默默地精确地不受任何影响(包括断气断水断粮)运作,这绝对是历史性的变革模式。总体上说,The DAO有如下几个特点:

  1. The DAO本质上是个VC(风险投资基金),通过以太坊筹集到的资金会锁定在智能合约中,没有哪个人能够单独动用这笔钱。更重要的是,该组织只存在于虚拟的数字世界中,不受任何政府监管约束、无国界,资金是加密数字货币的以太币(Ether)形式,其行为由智能合约中的代码来主导(因此称做“自治”)。
  2. 每个参与众筹的人按照出资数额,获得相应的DAO代币(token),具有审查项目和投票表决的权利。从这点说,DAO代币有点象股票,众筹参与者是股东。代币还有另一个作用,就是持有人有权提出投资项目的议案,供The DAO审核。
  3. 投资议案由全体代币持有人投票表决,每个代币一票。如果议案得到需要的票数支持,相应的款项会划给该投资项目。在传统基金中,投资策略是由经验丰富的基金经理等专业人士制定的。在The DAO中,决策来自于“众智”(the wisdom of the crowd)。众智的概念最早可以溯源到亚里士多德关于政治的论述,其原理是:综合许多人的智慧,可以作出比某个专家更好的结论。现实生活中例子很多,如陪审团、维基百科的编制、《百万富翁》游戏中的询问观众等等。当然,这里抛开了纯粹的“众智”,而加入了出资额的权重。
  4. 投资项目的收益会按照一定规则回馈众筹参与人(股东)。当然,并不是每个项目都有收益,或者能够盈利,而且一般来说会是风险较高的项目,可以靠分散投资来降低风险。

The DAO的代码主要由Slock.it公司的成员编写。Slock.it原先希望从以太坊平台筹措资金,后来发现他们的DAO框架可以被其他项目重用,于是他们创建了The DAO,称为”themother of all DAOs”(DAO之母)。The DAO和其他区块链项目一样是开源的,因此Slock.it即使是The DAO代码的缔造者,也无法从规则或控制权上获得先天优势。The DAO一旦启动运作,就由铁面无私的机器和预设的程序代码不忘初心地一路前行。The DAO的代码定义了整个组织运作的规则,如发售DAO代币(众筹)、投票、分拆、投资、获得回报(分红)等功能。从编程语言的角度看,就是清晰地实现了这些事件的处理逻辑。

The DAO合约地址:0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413

The DAO攻击流程

2016年5月出现了一个致力于众筹投资的“The DAO”项目,“The DAO”本质是运行在以太坊区块链上的一个智能合约,工作原理类似于众筹投资基金,但是资金来源于区块链上的众筹。通过发送以太币给该智能合约,然后换取代币,代币作为投票的权重,投票决定投资哪个项目。投资的收益也是按照合约中的制度来进行分配。“The DAO”项目发起众筹后,以前从来没有这样的民主的投资基金,当时被称为伟大的尝试,受到了很大的关注度。1个月内就筹到了1.5亿的以太币,众筹的速度和规模前所未有。但是最终“The DAO”仅仅存活了3个月就结束了。问题出在了哪里呢?

根据白皮书的设计,splitDAO的本意是要保护投票中处于弱势地位的少数派防止他们被多数派通过投票的方式合法剥削。通过分裂出一个小规模的DAO,给予他们一个用脚投票的机制,同时仍然确保他们可以获取分裂前进行的对外资助产生的可能收益,但通往地狱的道路就这样用鲜花铺就了。通过拆分(split DAO)的方式,独立建立子基金(child DAO),投资者的代币也会被换成ETH转到子基金里。单个投资者也可以成立子基金,然后将所有钱投票给自己,这是投资者取回收益的唯一途径。“The DAO”还规定只能在成立子基金的28天以后才能取回资金。

拆分子基金的理念本身没有错,但是在代码实现上出现了一些漏洞,由于攻击者是在创建childDAO并将Ether持续转入其中,这是目前唯一可行的提取Ether的机制,所以关注点从splitDAO函数开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function splitDAO(
uint _proposalID,
address _newCurator
) noEther onlyTokenholders returns (bool _success) {
......

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

如上splitDAO中,msg.sender记录的dao代币余额归零、扣减dao代币总量totalSupply等等都发生在withdrawRewardFor函数之后,进一步查看该函数:

1
2
3
4
5
6
7
8
9
10
11
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)) // payOut调用
throw;
paidOut[_account] += reward;
return true;
}

再看payOut函数:

1
2
3
4
5
6
7
8
9
10
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,此时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) —> withdrawRewardFor函数 (No. 1,黑客的dao余额和dao总量此时没变!) —> papOut 函数(No. 1,向HC发送以太第一次) —> HC的fallback函数 (No. 1) —->如果递归未达预设深度:调用split DAO函数(No. 2) —> withdrawRewardFor函数(No. 2, 黑客dao余额等仍然没变!) —> payOut函数(No. 2, 向HC发送以太第二次) —> HC的fallback函数 (No. 2) —> (继续递归)
  3. 转款走人: 转入childDAO的钱在一定时间后根据原合约可以提取,黑客收割韭菜的时候到了。

The DAO攻击解决方案

因为投资者已经将以太币投入了 The DAO 合约或者其子合约中,在攻击后无法立刻撤回。需要让投资者快速撤回投资,且能封锁黑客转移资产。V 神公布的解决方案是,在程序中植入转移合约以太币代码,让矿工选择是否支持分叉。在分叉点到达时则将 The DAO 和其子合约中的以太币转移到一个新的安全的可取款合约中。全部转移后,原投资者则可以直接从取款合约中快速的拿回以太币。取款合约地址是0xbf4ed7b27f1d666546e30d74d50d173d20bca754

首先,为照顾两个阵营,软件提供硬分叉开关,选择权则交给社区。如果矿工支持分叉,则需要在从高度 192000 到 192009,在区块头 extradata 写入指定信息 0x64616f2d686172642d666f726b (“dao-hard-fork”的十六进制数),以表示支持硬分叉,同时,所有节点在校验区块头时,必须安全地校验特殊字段信息,校验区块是否属于正确的分叉上。从分叉点开始,如果连续 10 个区块均有硬分叉投票,则表示硬分叉成功。但是在当前版本中,社区已完成硬分叉,所以已移除开关类代码。当前,主网已默认配置支持 DAO 分叉,并设定了开始硬分叉高度 1920000,代码如下:

1
2
3
4
5
// params/config.go:38
MainnetChainConfig = &ChainConfig{
DAOForkBlock: big.NewInt(1920000),
DAOForkSupport: true,
}

这种 config.DAOForkBlock 开关,类似于互联网公司产品新功能灰度上线的功能开关。在区块链上,可以先实现功能代码逻辑。至于何时启用,则可以在社区、开发者讨论后,确定最终的开启时间。当然区块链上区块高度等价于时间戳,比如 DAO 分叉点 1920000 也是讨论后敲定。

校验区块头规则如下:

  • 在校验区块头时增加 DAO 区块头识别校验。
  • 如果节点未设置分叉点,则不校验。
  • 确保只需在 DAO 分叉点的 10 个区块上校验。
  • 如果节点允许分叉,则要求区块头 Extra 必须符合要求。
  • 当然,如果节点不允许分叉,则也不能在区块头中加入非分叉链的 Extra 特殊信息。

如何分离网络?

如果分叉后不能快速地分离网络,会导致节点出现奇奇怪怪的问题。长远来说,为针对以后可能出现的分叉,应设计一种通用解决方案,已降低代码噪音。否则,你会发现代码中到处充斥着一些各种梗。但时间又非常紧急,这次的 The DAO 分叉处理是通过特定代码拦截实现。在我看来,区块链项目不同于其他传统软件,一旦发现严重 BUG 是非常致命的。在上线后的代码修改,应保持尽可能少和充分测试。非常同意 the dao 的代码处理方式。不必为以后可能的分叉,而做出觉得“很棒”的功能,务实地解决问题才是正道。

不应该让节点同时成为两个阵营的中继点,应分离出两个网络,以让其互不干预。The DAO 硬分叉的处理方式是:节点连接握手后,向对方请求分叉区块头信息。在 15 秒必须响应,否则断开连接。代码实现是在 eth/handler.go 文件中,在消息层进行拦截处理。节点握手后,开始 15 秒倒计时,一旦倒计时结束,则断开连接。在倒计时前,需要向对方索要区块头信息,以进行分叉校验。此时,对方在接收到请求时,如果存在此区块头则返回,否则忽略。

这样,有几种情况出现。根据不同情况分别处理:

  • 有返回区块头:如果返回的区块头不一致,则校验不通过,等待倒计时结束。如果区块头一致,则根据前面提到的校验分叉区块方式检查。校验失败,此直接断开连接,说明已经属于不同分叉。校验通过,则关闭倒计时,完成校验。
  • 没有返回区块头:如果自己也没有到达分叉高度,则不校验,假定双方在同一个网络。但我自己已经到达分叉高度,则考虑对方的 TD 是否高于我的分叉块。如果是,则包容,暂时认为属于同一网络。否则,则校验失败。

上述所做的一切均为安全、稳定的硬分叉,隔离两个网络。硬分叉的目的是,以人为介入的方式拦截攻击者资产。一旦到达分叉点,则立即激活资产转移操作。首先,矿工在挖到分叉点时,需执行转移操作。其次,任何节点在接收区块,进行本地处理校验时同样需要在分叉点执行。转移资金也是通过取款合约处理。将 The DAO 合约包括子合约的资金,全部转移到新合约中。

Dao硬分叉Eips: https://eips.ethereum.org/EIPS/eip-779

参考资料