JavaScript 不可用。

我们检测到浏览器禁用了 JavaScript。请启用 JavaScript 或改用支持的浏览器来继续访问

THE DAO攻击事件的 Solidity 源码分析 [重入攻击]

作者:Anban Chu

发表日期:2018年04月23日

所属目录:黑客攻击

攻击描述

  • 被黑目标:The DAO
  • 事件描述:运行在以太坊上的 DAO 智能合约遭受智能合约重入攻击。
  • 损失金额:6000 万美元
  • 攻击方式:重入攻击
  • 最终解决: 以太坊网络分叉

2016 年 6 月 17 日,TheDAO 项目遭到了重入攻击,导致了 300 多万个以太币被从 TheDAO 资产池中分离出来,而攻击者利用 TheDAO 智能合约中的 splitDAO() 函数重复利用自己的 DAO 资产进行重入攻击,不断的从 TheDAO 项目的资产池中将 DAO 资产分离出来并转移到自己的账户中。

造成市值五千万美元的以太币被移动到只有该黑客可以控制的分身 DAO。因为程序不允许黑客立即提取这些以太币,以太坊用户有时间讨论如何处理此事,考虑的方案包括取回以太币和关闭 DAO,而 DAO 去中心化的本质也表示没有中央权力可以立即反应,而需要用户的共识。最后在 2016 年 7 月 20 日,以太坊进行硬分叉,作出一个向后不兼容的改变,让所有的以太币(包括被移动的)回归原处,而不接受此改变的区块链则成为古典以太坊(Ethereum Classic)。

THE DAO 持有近 15%的以太币总数,因此 THE DAO 这次的问题对以太坊网络及其加密币都产生了负面影响。这是第一次有主流区块链为了补偿投资人,而透过分叉来更动交易记录。

被攻击原因

  • 函数存在逻辑漏洞,变量值更新不当以及智能合约本身存在的机制联合导致
  • 攻击者递归调用 splitDAO 函数,并不断进行自我调用。
    • 在递归快要接触到 Blcok Gas Limit 的时候进行了收尾工作并将自己的 DAO 资产转移到另一个受攻击账户并在利用完漏洞后将资产再转移回来。

如此以来,黑客利用 2 个账户反复利用 Proposal 进行攻击,从而转移了 360 万个以太币(价值 6000 万美元)。

代码使用下面的逻辑:

  1. 检查用户的以太币余额
  2. 将资金发送给调用地址
  3. 将其余额重置为 0,防止用户再提取

应对重入攻击一种方法是遵循 检查-效果-交互模式 (opens in a new tab)↗

  1. 检查用户的以太币余额
  2. 将其余额重置为 0,防止用户再提取 【这一步记账先做掉】
  3. 将资金发送给调用地址

这里展示了公开的重入攻击合约: https://github.com/pcaversaccio/reentrancy-attacks

合约

攻击分析

先列出核心代码

function splitDAO(
    uint _proposalID,
    address _newCurator
) noEther onlyTokenholders returns (bool _success) {

    Proposal p = proposals[_proposalID];

    // Sanity check

    if (now < p.votingDeadline  // has the voting deadline arrived?
        //The request for a split expires XX days after the voting deadline
        || now > p.votingDeadline + splitExecutionPeriod
        // Does the new Curator address match?
        || p.recipient != _newCurator
        // Is it a new curator proposal?
        || !p.newCurator
        // Have you voted for this split?
        || !p.votedYes[msg.sender]
        // Did you already vote on another proposal?
        || (blocked[msg.sender] != _proposalID && blocked[msg.sender] != 0) )  {

        throw;
    }

    // If the new DAO doesn't exist yet, create the new DAO and store the
    // current split data
    if (address(p.splitData[0].newDAO) == 0) {
        p.splitData[0].newDAO = createNewDAO(_newCurator);
        // Call depth limit reached, etc.
        if (address(p.splitData[0].newDAO) == 0)
            throw;
        // should never happen
        if (this.balance < sumOfProposalDeposits)
            throw;
        p.splitData[0].splitBalance = actualBalance();
        p.splitData[0].rewardToken = rewardToken[address(this)];
        p.splitData[0].totalSupply = totalSupply;
        p.proposalPassed = true;
    }

    // Move ether and assign new Tokens
    /**
     * 🆚 这里是将ETH 从 the parent DAO转移到the child DAO中,攻击者利用这个来获得更多的代币并转移到child DAO中
     * 📝 fundsToBeMoved 决定了要转移的代币数量,分母是 p.splitData[0].totalSupply
     * 但是分母只有 address(p.splitData[0].newDAO) 为零的时候更新,
     * 因为每次攻击者调用这项功能时 p.splitData[0] 都是一样的(它是p的一个属性,即一个固定的值),
     * 并且 p.splitData[0].totalSupply 与 balances[msg.sender] 的值由于函数顺序问题没有被更新。
     */
    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;


    // Assign reward rights to new DAO
    uint rewardTokenToBeMoved =
        (balances[msg.sender] * p.splitData[0].rewardToken) /
        p.splitData[0].totalSupply;

    uint paidOutToBeMoved = DAOpaidOut[address(this)] * rewardTokenToBeMoved /
        rewardToken[address(this)];

    rewardToken[address(p.splitData[0].newDAO)] += rewardTokenToBeMoved;
    if (rewardToken[address(this)] < rewardTokenToBeMoved)
        throw;
    rewardToken[address(this)] -= rewardTokenToBeMoved;

    DAOpaidOut[address(p.splitData[0].newDAO)] += paidOutToBeMoved;
    if (DAOpaidOut[address(this)] < paidOutToBeMoved)
        throw;
    DAOpaidOut[address(this)] -= paidOutToBeMoved;

    // Burn DAO Tokens
    Transfer(msg.sender, 0, balances[msg.sender]);

    // ❌ 注意看 withdrawRewardFor 函数 和 下面记账的顺序。先转钱,再记账。
    // ❌ 由于记账代码在函数后面,所以并没有更新变量的值,也就为循环提款奠定基础了
    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];

    // ❌ 注意这里的 payOut 是先发生,然后再记录 reward 的
    if (!rewardAccount.payOut(_account, reward))
        throw;
    paidOut[_account] += reward;
    return true;
}

paidOut[_account] += reward 在问题代码里面放在 payOut 函数调用之后,再看 payOut 函数调用。 payOut 是在 ManagedAccount.sol 内的

 function payOut(address _recipient, uint _amount) returns (bool) {
    if (msg.sender != owner || msg.value > 0 || (payOwnerOnly && _recipient != owner))
        throw;
    if (_recipient.call.value(_amount)()) { // 会调用攻击者合约内的 fallback 函数
        PayOut(_recipient, _amount);
        return true;
    } else {
        return false;
    }
}

payOut 内对 _recipient 发出 call 调用,转账 _amount 个 Wei 的值。注意 call 调用默认会使用当前剩余的所有 gas。

  • 前置条件: 攻击者创建了自己的合约,利用系统的匿名 fallback 函数通过递归触发 DAOsplitDAO 函数的多次调用。
    • 需要含有 fallback 函数。根据 solidity 的规范,fallback 函数将在收到 Ether(不带 data)时自动执行。
    • 之后根据 fallback 函数我们会进行递归除法对 splitDAO 函数的多次调用。
  • 第一次执行: DAO.splitDAO()
  • 第一次执行: DAO.createTokenProxy()
  • 第一次执行: DAO.withdrawRewardFor()
  • 第一次执行: ManagedAccount.payOut()
  • 第一次执行: 攻击合约.fallback()
  • 攻击合约.fallback() 内部判断是否得到预期深度,如果没有,则继续调用 DAO.splitDAO()- 第一次执行: DAO.splitDAO()
  • ❌ 第 2 次执行: DAO.createTokenProxy()
  • ❌ 第 2 次执行: DAO.withdrawRewardFor()
  • ❌ 第 2 次执行: ManagedAccount.payOut()
  • ❌ 第 2 次执行: 攻击合约.fallback()

因为记账代码在转账代码之后,所以合约会认为攻击合约一直有钱(因为提取钱后直接在 fallback 又继续执行了,并没有执行到记账代码),并且 gas 的作用同样没有发挥。

预防技术整理

第一种技术是(在可能的情况下)将 ether 发送到外部合约时使用内置的 transfer() 函数。transfer() 函数仅发送 2300 Gas 给外部调用,这不足以使目的地址合约调用另一个合约(即重入原合约)。(transfer/send 一样的)

第二种,是确保所有改变状态变量的逻辑,都发生在以太币被发送出合约(或任何外部调用)之前。这在以太坊文档中称为 检查 - 效果 - 交互(checks-effects-interactions)模式。

第三种是引入互斥锁。也就是说,添加一个状态变量,在代码执行期间锁定合约,防止重入调用。

第四种是使用 OpenZeppelin 官方库,OpenZeppelin 官方库中有一个专门针对重入攻击的安全合约:https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/security/ReentrancyGuard.sol

第五种是使用 0.8.0 版本的合约,这里做了修复。

资金操作建议方式

检查 - 效果 - 交互 的模式非常推荐

  • 从合约地址向用户地址转账: 先记账,再执行转帐操作
  • 从用户地址向合约地址转账: 先执行转帐操作,再进行记账

重入攻击的简单梳理

先用一个 bad code 作为例子

// bad code
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)()),这里使用了 call.value() 方法发送余额,发送不成功时抛出异常,本次调用不成功。
  • 在以太坊中 call.value() 方法需要与 gas 结合,否则默认使用所有 gas。
  • 转账之后记账 userBalances[msg.sender] = 0 修改余额为 0。

攻击合约先执行 withdrawBalance ,之后合约内调用 call,然后调用攻击合约的默认 fallback 函数;在攻击合约 fallback 内中又执行了 withdrawBalance,如此反反复复。

第一次调用 userBalances[msg.sender] 有当前余额值,第二次调用 由于 userBalances[msg.sender] = 0 还没有调用到,因此 userBalances[msg.sender] 还是原来的值,因此会造成重复支付。这个就是重入攻击的核心。如果上面 DAO 的攻击看不懂,只需要懂这个原理就可以了。

send 与 call 那么他们有什么区别呢?

fallback 函数可以做尽量多的计算至到 gas 耗尽。有两种方法可以触发 fallback 函数:recipient.send() , recipient.call.value()

  • send 方法: 有 2300 gas 限制,被 send 唤起的 fallback 函数最多只能消耗 2300 gas。
  • call 方法: 会使用尽量多的 gas,所以要注意安全问题 recipient.call.value(...) 会使用尽量多的 gas,另外两个函数 callcodedelegatecall ,也是如此。

如果想要和 send 方法达到同样的安全效果,调用者必须指定 gas limit 为 0,recipient.call.gas(0).value(...)

以太坊对此事的解决方案

V 神公布的解决方案是,在程序中植入转移合约以太币代码,让矿工选择是否支持分叉。 在分叉点到达时则将 The DAO 和其子合约中的以太币转移到一个新的安全的可取款合约中。 全部转移后,原投资者则可以直接从取款合约中快速的拿回以太币。

取款合约在讨论方案时,已经部署到主网。合约地址是 0xbf4ed7b27f1d666546e30d74d50d173d20bca754

/**
 *Submitted for verification at Etherscan.io on 2016-07-14
*/

contract DAO {
    function balanceOf(address addr) returns (uint);
    function transferFrom(address from, address to, uint balance) returns (bool);
    uint public totalSupply;
}

contract WithdrawDAO {
    DAO constant public mainDAO = DAO(0xbb9bc244d798123fde783fcc1c72d3bb8c189413);
    address public trustee = 0xda4a4626d3e16e094de3225a751aab7128e96526;

    function withdraw(){
        uint balance = mainDAO.balanceOf(msg.sender);

        if (!mainDAO.transferFrom(msg.sender, this, balance) || !msg.sender.send(balance))
            throw;
    }

    function trusteeWithdraw() {
        trustee.send((this.balance + mainDAO.balanceOf(this)) - mainDAO.totalSupply());
    }
}

相关阅读





以上就是本篇文章的全部内容了,希望对你有帮助。

>> 本站不提供评论服务,技术交流请在 Twitter 上找我 @anbang_account