前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >分析DAO的漏洞

分析DAO的漏洞

作者头像
FesonX
发布2018-04-04 10:40:04
2K0
发布2018-04-04 10:40:04

分析DAO的漏洞

我敢肯定每个人都听说过有关DAO被一个黑客利用递归以太坊发送漏洞截获1.5亿美元的重大新闻

这篇文章将成为可能的系列文章的第一篇, 解构并解释技术层面出现的问题, 同时通过区块链提供追溯攻击者行为的时间线。第一篇文章将重点讨论攻击者如何偷取DAO中的所有资金。

多阶段攻击

DAO中的这个漏洞显然不是微不足道的; DAO易受攻击的确切编程模式不仅仅是已知的, 而且是由DAO创建者自己在早期对框架代码进行更新时修复过的。讽刺的是, 当他们撰写博客文章并宣告胜利时, 黑客正在准备和部署一个针对他们刚刚修复的相同函数的漏洞, 来卷走DAO的所有资金。

我们来看看关于攻击的概述。攻击者分析DAO.sol, 并注意到'splitDAO'(拆分DAO)函数容易受到上述的递归发送模式的攻击: 该函数在最后更新用户余额和总额, 因此如果我们可以获得任何在这之前调用的函数然后再次调用splitDAO, 我们将得到可用于移动我们想要的尽可能多的资金的无限递归(被标记为XXXXX的代码注释, 你可能需要滚动才能看到它):

function splitDAO(
 uint _proposalID,
 address _newCurator
) noEther onlyTokenholders returns (bool _success) {
  ...
 // XXXXX Move ether and assign new Tokens.  Notice how this is done first!
 // XXXXX 移动以太并分配新的代币. 请先注意这是如何做的!
 uint fundsToBeMoved =
      (balances[msg.sender] * p.splitData[0].splitBalance) /
 p.splitData[0].totalSupply;
 //下面这条是攻击者想要执行超过一次语句
 if (p.splitData[0].newDAO.createTokenProxy.value(fundsToBeMoved)(msg.sender) == false) // XXXXX This is the line the attacker wants to run more than once
 throw;
  ...
 // Burn DAO Tokens
 // 销毁DAO代币
 Transfer(msg.sender, 0, balances[msg.sender]);
 withdrawRewardFor(msg.sender); // be nice, and get his rewards 很好, 获得他的奖励
 // XXXXX Notice the preceding line is critically before the next few
 // XXXXX 注意接下来的几行前面几行很重要
 totalSupply -= balances[msg.sender]; // XXXXX AND THIS IS DONE LAST 这将在最后执行
 balances[msg.sender] = 0; // XXXXX AND THIS IS DONE LAST TOO 这也在最后执行
 paidOut[msg.sender] = 0;
 return true;
}

其基本思想是: 提议拆分。执行拆分。当DAO撤回您的奖励时, 在撤销完成之前调用该函数执行拆分。该功能将开始运行, 但不会更新您的余额, 并且我们上面标记的"攻击者想要多次运行"的代码行将运行多次。这是在做什么? 这些源代码位于TokenCreation.sol中, 它将代币从父DAO转移到子DAO。基本上攻击者都使用它来传输比他们原本能够传输的更多数量的代币来进入他们的子DAO。

DAO如何决定要移动多少代币? 当然是使用余额数组(balances array):

uint fundsToBeMoved = (balances[msg.sender] * p.splitData[0].splitBalance) / p.splitData[0].totalSupply;

因为攻击者每次调用p.splitData[0]这个函数(它是提议p的一个属性, 不是DAO的一般状态)时将会是相同的, 并且因为攻击者可以在余额数组(balances array)更新完毕之前从withdrawRewardFor中调用此函数, 攻击者可以使用所描述的攻击运行任意多次该代码, 并且每次fundToBeMoved(将被移动的资金)函数都会得出相同的值。

攻击者需要为他成功的漏洞铺路所做的第一件事是让DAO的withdraw函数实际上依旧在运行, 而DAO易受到关键的递归发送漏洞的攻击。让我们看看需要哪些代码来让它发生(来自DAO.sol):

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)) // XXXXX vulnerable 易受攻击的
 throw;
 paidOut[_account] += reward;
 return true;
}

如果黑客可以让第一个if语句求得的值为false, 那么标记为易受攻击的语句将运行。当那些语句运行时, 看起来与此相似的代码将被调用:

function payOut(address _recipient, uint _amount) returns (bool) {
 if (msg.sender != owner || msg.value > 0 || (payOwnerOnly && _recipient != owner))
 throw;
 if (_recipient.call.value(_amount)()) { // XXXXX vulnerable 易受攻击的
 PayOut(_recipient, _amount);
 return true;
  } else {
 return false;
}

请注意标记行是如何成为我们的链接漏洞的描述中提到的易受攻击的代码!

该行随后将从DAO的合同中发送一条消息给"_recipient"(攻击者)。"_recipient"当然会包含一个默认函数, 该函数将与攻击者初次调用时使用一样的参数再次调用splitDAO。请记住, 因为这全部都是从splitDAO内部的withdrawFor函数发生的, 在splitDAO中的更新余额的代码尚未运行。所以该拆分会向子DAO发送更多的代币, 然后要求奖励再次撤回。哪个会尝试再次发送代币给"_recipient", 哪个就会在更新balances数组之前再次调用split DAO。

事实正是如此:

  1. 建议拆分, 等到投票期(voting period)结束。(DAO.sol, createProposal)
  2. 执行拆分。(DAO.sol, splitDAO)
  3. 让DAO将新的DAO发送给它的代币。(splitDAO - > TokenCreation.sol, createTokenProxy)
  4. 确定DAO在更新你的余额之前尝试发送给你奖励, 但需在完成(3)之后。(splitDAO - > withdrawRewardFor - > ManagedAccount.sol, payOut)
  5. 当DAO执行(4)时, 让它再次运行splitDAO, 其参数与(2)中的相同(payOut - > _recipient.call.value - > _recipient())
  6. DAO现在会向您发送更多子代币, 并在更新余额前提现您的奖励。(DAO.sol, splitDAO)
  7. 回到(5)!
  8. 让DAO更新你的余额。因为它从来不会从(7)回到(5) :-)。 (注意: 以太坊的gas 技术并不能在这里拯救我们. 与发送函数不同的是, call.value默认传递一个事务处理的所有气体. 所以只要攻击者付钱, 代码就会运行, 考虑到这是一个无限期的便宜的漏洞)

有了这个, 我们可以提供DAO是如何清空的一步一步的重新追踪。

第1步: 提出拆分

正如我们所提到的, 向上述所有方面迈出的第一步就是简单地提出一个定期拆分。

攻击者在DAO的提议#59中的blockchain 这里做了这些, 提议标题是"孤独, 多么孤独(Lonely, so Lonely)"。

由于这一行代码:

// The minimum debate period that a split proposal can have
// 拆分提议可能的最小争议期限
uint constant minSplitDebatePeriod = 1 weeks;

他必须为该提议等待一周的时间才能看到批准。无论如何, 这只是与其他提议类似的协议! 没有人会仔细看它, 对吧?

第2步: 获得奖励

正如slock.it之前关于此事的一篇文章中所解释的那样, DAO还没有给出奖励!(因为没有产生奖励)。

如同我们在概述中提到的, 这里需要运行的关键代码行是:

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

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

如果黑客可以运行第一行标记代码, 那么第二行标记的代码将运行他所选择的默认函数(即如前所述回调splitDAO)。

让我们来解构第一个if语句:

if ((balanceOf(_account) * rewardAccount.accumulatedInput()) / totalSupply < paidOut[_account])

balanceOf函数在Token.sol中定义, 当然也正是这样的:

return balances[_owner];

rewardAccount.accumulatedInput()的那行代码由ManagedAccount.sol中的代码进行计算:

// The sum of ether (in wei) which has been sent to this contract
// 发送给此合约的以太总和(在wei中)
uint public accumulatedInput;

幸运的是acumulativeInput(累计输入)函数操作起来非常简单。只需使用奖励帐户的默认函数即可!

function() {
 accumulatedInput += msg.value;
}

不仅如此, 还因为没有任何逻辑可以在任何地方减少acumulativeInput(它跟踪从以前的所有交易中获得的账户的输入), 所有攻击者需要做的是发送几个Wei到奖励账户, 我们的原始状态将不仅会评估为假, 而且每次调用它时, 它的成分值都会评估为相同的结果:

if ((balanceOf(_account) * rewardAccount.accumulatedInput()) / totalSupply < paidOut_account)

请记住, 因为balanceOf是参照永不更新的余额, 且由于splitDAO中的代码从未实际执行, 因此paidOut和totalSupply也永远不会更新, 攻击击者可以轻松地获得他们微小的奖励份额。而且因为他们可以要求他们获得奖励的份额, 他们可以运行默认函数并重新回到splitDAO.Whoopsie。

但他们是否真的需要包括奖励? 让我们再看一下这行代码:

if ((balanceOf(_account) * rewardAccount.accumulatedInput()) / totalSupply < paidOut[_account])

如果奖励账户余额为0, 该怎么办? 然后我们得到

if (0 < paidOut_account)

如果没有任何支付行为, 将永远评估为假, 并且永远不会抛出错误! 为什么? 原始代码行等价于从两侧减去paidOut之后的这行代码:

if ((balanceOf(_account) * rewardAccount.accumulatedInput()) / totalSupply - paidOut_account < 0)

第一部分实际上是支付了多少钱。所以该检查实际上是:

if (amountToBePaid < 0)

但是, 如果amountToBePaid(需要支付的金额)为0, 则DAO无论如何都会支付您的费用。对我来说这没有多大意义 - 为什么要以这种方式浪费gas(译者注: gas可以理解为以太坊中的交易小费)? 我想这就是为何很多人假设攻击者需要在奖励账户中有一笔余额来执行攻击,但实际上他们并不需要这样做。这个攻击可以与一个空的奖励用户同样运作就像一个完整的账户一样!

我们来看看DAO的奖励地址。Slockit的DAO会计文件将此地址固定为0xd2e16a20dd7b1ae54fb0312209784478d069c7b0检查该账户的交易, 你会看到一个模式: 200页的.00000002 ETH(以太)交易记录到0xf835a0247b0063c04ef22006ebe57c5f11977cc40xc0ee9db1a9e07ca63e4ff0d5fb6f86bf68d47b89, 这是攻击者的两个恶意合同(我们稍后会介绍)。这是我们上面描述的withdrawRewardFor的每个递归调用的一个交易。所以在这种情况下, 奖励账户中实际上存在一笔余额, 攻击者可以从中收集到一些利益。

第3步: 重大事故(The Big Short)

社交媒体上的一些完全没有根据的指控指出, 袭击发生前不久, Bitfinex就发生了3美元的以太坊事故, 声称这笔事故接近100万美元利润。

任何构造或分析此攻击的人都很清楚, DAO的某些属性(特别是任何拆分必须运行与原始DAO相同的代码)需要攻击者在提现任何硬币之前的恶意拆分等待其子DAO的创建期(27天)。这让社区有时间对盗窃做出反应, 通过软分叉(soft fork)冻结攻击者资金或硬分叉(hard fork)完全回滚妥协。

任何经济上有动机的攻击者在试验网(testnet)上尝试这些漏洞, 都会有动力确保利润, 不管潜在的回滚或通过减少潜在的代币来分叉(fork)。在触发恶意拆分的智能合约几分钟内导致的惊人下降中提供了极好的获利机会, 虽然没有证据表明攻击者利用了获利机会, 但我们至少可以得出结论认为, 经过这一番努力, 他们本来可以愚蠢地选择不要获利。

#步骤3a: 防止退出(阻力无效)

攻击者需要考虑的另一个偶然事件是在攻击者可以完成清空DAO之前出现DAO拆分的情况。在这种情况下, 用另一个用户作为唯一的管理者, 攻击者将无法使用DAO资金。

不幸的是, 攻击者个聪明人: 有证据表明攻击者已经对他自己的所有拆分提案投了赞成票, 确保在任何DAO拆分的情况下他会持有一些代币。因为我们将在文章后面讨论的DAO的某个属性, 这些拆分的DAO很容易受到我们在此描述的同样的清空攻击。所有攻击者所要做的就是在创建期间, 向奖励账户发送一些以太, 并且自己提出并执行一次拆分, 使其脱离这个新的DAO。如果他可以在这个新DAO管理者更新代码以消除这个漏洞之前执行, 他将设法压制所有想从DAO中获得不属于他自己的以太的企图。

通过这里的时间戳注意到, 攻击者在开始恶意拆分的时候做到了这一点, 这几乎是一个事后的想法。我认为这更像是一次对DAO竖起不必要的中指, 而不是一次财务上可行的攻击: 已经清空了几乎所有的DAO, 通过这项努力来获取可能留在桌面上的任何利润可能是企图让持有人陷入混乱而不采取措施。许多人得出结论, 我也同意, 这暗示了攻击者的动机更像是对DAO的完全破坏而超越了为获取利益。虽然我们都不知道这里的真相, 但我建议你应用自己的判断。

有趣的是, 由EminGünSirer描述的攻击已经出现在区块链领域出现过了, 但这是在公众注意到之前。

第4步: 执行拆分

我们精心描述了这次攻击的所有无聊的技术方面。让我们来看看有趣的部分, 即: 执行恶意拆分动作。执行拆分背后后的事务的帐户是0xf35e2cc8e6523d683ed44870f5b7cc785051a77d

他们发送资金的子DAO是0x304a554a310c7e546dfe434669c62820b7d83490。该提案是由帐户0xb656b2a9c3b2416437a811e07466ca712f5a5b5a创建和发起的(你可以在区链历史记录中看到对createProposal的调用)。

解构创建该子DAO的构造函数参数将引导我们至位于0xda4a4626d3e16e094de3225a751aab7128e96526的管理者处。这个智能合约只是一个常规的多重签名钱包, 其大部分过去的事务是增加/删除所有者和其他钱包管理任务。没啥有趣的。

Johannes Pfeffer 在 Medium上对涉及恶意Child DAO的交易进行一次卓越的基于区块链的重构。我不会花太多时间在这种区块链分析上, 因为他已经做得很好。我非常鼓励任何对此有兴趣的人可以从那篇文章开始看起。

在本系列的下一篇文章中, 我们将看看恶意合约本身的代码(包含实际发起递归攻击的漏洞)。为了方便发布, 我们还没有完成这样的分析。

##步骤4a: 扩展拆分

这一步是对原始更新的更新, 并涵盖了攻击者如何能够将一个大约30倍的放大攻击(由于以太坊堆栈的最大大小限制在128)转变为一个几乎可以无限提现的帐户。

上述精明的读者可能会注意到, 即使在压倒堆栈并执行比所需更多的恶意拆分之后, 黑客也会在splitDAO结束时将其余额清零:

function splitDAO(
  ....

 withdrawRewardFor(msg.sender); // be nice, and get his rewards

 // 很棒, 获得了他的奖励

 totalSupply -= balancesmsg.sender;

 balancesmsg.sender = 0;

 paidOutmsg.sender = 0;

 return true;

}

那么攻击者是如何解决这个问题的? 由于有DAO代币转账的能力, 他并非真的需要! 他所要做的就是从DAO的恶意功能中调用DAO的有用转账函数:

function transfer(address _to, uint256 _amount) noEther returns (bool success) {
  if (balances[msg.sender] >= _amount && _amount > 0) {
    balances[msg.sender] -= _amount;
    balances[_to] += _amount;
    ...

通过将代币转移到代理账户, 原始账户将在splitDAO结束时被正确清零(请注意, 如果A将所有资金转移到B, A的账户在可以通过splitDAO清零之前已通过转账清零)。攻击者然后可以从代理账户中将钱退回到原始账户并重新开始整个过程。即使更新到splitDAO中的totalSupply被忽略了, 因为 p.totalSupply0 用于计算原始提议的属性中的支付额, 并且在攻击发生之前仅实例化一次。因此, 尽管每次迭代中DAO中可用的以太较少, 但攻击大小仍保持不变。

有两个恶意合同在区块链中调用withdrawRewardFor的证据表明, 攻击者的代理帐户也是一种攻击启用合同, 只是将攻击者替换了原始合同。这种优化可以在攻击周期内为攻击者节省一笔交易, 但是除此之外似乎没有必要。

1.1版本是容易受攻击的吗?

因为这个漏洞在withdrawRewardFor中, 所以要问的一个自然问题是具有更新函数的DAO 1.1是否仍然容易受到类似攻击的影响。答案是肯定的

查看更新后的功能(特别是标记的代码行):

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

  reward = rewardAccount.balance < reward ? rewardAccount.balance : reward;

  paidOut[_account] += reward; // XXXXX
  if (!rewardAccount.payOut(_account, reward)) // XXXXX
    throw;

  return true;
}

请注意, 现在在实际支付之前如何更新paidOut。那么这如何影响我们的漏洞? 第二次getRewardFor被调用, 从邪恶的第二次调用splitDAO, 在这一行代码中:

uint reward =
 (balanceOf(_account) * rewardAccount.accumulatedInput()) / totalSupply - paidOut[_account];

将出现0, payOut调用然后将调用_recipient.call.value(0)(), 这是该函数的默认值, 使其等同于调用

_recipient.call()

由于攻击者在发送恶意拆分事务时支付了大量gas, 所以递归攻击可以继续进行复仇。

在代码被设计为安全的多年的情况下, 意识到他们需要在1.1版本6天后的1.2版本,可能是为什么DAO的木偶大师称它退出了的原因。

一份重要的收获(Takeaway)

我认为1.1版本对这种攻击的敏感性非常有趣: 尽管withdrawReward(撤回奖励)本身并不脆弱, 即使splitDAO在没有withdrawRewardFor的情况下也不容易受到攻击, 但这个组合却证明是致命的。这可能是为什么这个漏洞在很多时候被许多不同的人忽略: 审阅者倾向于一次检查一个函数, 并且假定保护子例程的调用将按照预期安全运行。

就以太坊来说, 即使是涉及发送资金的安全函数也可能使你原有的功能容易受到重入影响。无论它们是默认的Solidity库中的函数, 还是你为安全性而编写的函数。在对以太坊代码进行检验时需要特别注意, 以确保在任何状态更新之后发生的任何函数移动值, 否则这些状态值将必然易于重入。

下一步是什么?

我不会在这里涵盖关于分叉(fork)的争议或以太坊和DAO的下一步的内容。这个话题被所有可以想象得到的任何形式的社交媒体殴打致死。

对于我们的系列文章, 下一步是使用DAO 1.0代码重构TestNet(试验网)上的漏洞, 并演示漏洞背后的代码和攻击机制。请注意, 如果有人攻击我达到这些目标, 我保留将系列文章限制在一个长度的权利。

更多信息

本文提供的信息仅用于提供攻击的一个广泛概述和时间线, 以及分析的起点。

如果你有可能与此处描述的主题相关区块链数据或分析, 或者合同源代码或二进制分析, 请通过邮件发送给phil@linux.com分享。我很乐意将其添加到帖子中, 并承诺努力创建一个过去24小时内的全面重构(截至撰写本文时为止)。

致谢

感谢MartinKöppelmann在推特和评论中指出这些额外的细节, 并纠正我对单一恶意智能合约的区块链分析。

衷心感谢安德鲁米勒回顾这篇文章, Zikai Alex Wen花了几个小时跟我一起追溯反编译的以太坊合同(结果还没有出版), EminGünSirer在我发布前使我对这个攻击产生兴趣, 在周五晚上熬夜让我的文章能用Markdown编写, 并尽早发布。Gün, 我们太封闭了 - 抱歉这次不够时间了:)。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 分析DAO的漏洞
    • 多阶段攻击
      • 第1步: 提出拆分
      • 第2步: 获得奖励
      • 第3步: 重大事故(The Big Short)
      • 第4步: 执行拆分
        • 1.1版本是容易受攻击的吗?
          • 一份重要的收获(Takeaway)
            • 下一步是什么?
              • 更多信息
                • 致谢
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档