DAO中的Race-To-Empty攻击分析 2023-3-28

291 阅读10分钟

激励层中的Race-To-Empty攻击

Race-To-Empty顾名思义,就是以极快的动作导致目标对象为空的攻击手段,通过快速地调用某个代码导致被攻击者的账户中有用资产为空的情况。

function getBalance(address user) constant returns(uint) {
  return userBalances[user];
}

function addToBalance() {
  userBalances[msg.sender] += msg.amount;
}

function withdrawBalance() {
  amountToWithdraw = userBalances[msg.sender];
  if (!(msg.sender.call.value(amountToWithdraw)())) { throw; }
  userBalances[msg.sender] = 0;
}

我们可以暂时规定msg.sender.call.value()函数被调用的时候,系统会默认执行一个默认函数Function。我们将其定义如下:

function () {
      vulnerableContract v;
      uint times;
      if (times == 0 && attackModeIsOn) {
        times = 1;
        v.withdraw();
       } else { times = 0; 
    }
}

withdrawBalance ()方法被执行的时候,我们里面会执行msg.sender.call.value(amountToWithdraw)()),此时我们会默认执行function ()方法。加入我们function方法中定义了withdraw()方法(即我进行了回调)。此时就类似于递归的感觉了,这也是攻击成功的原因之一。

函数入栈情况如下:

withdraw run 1st
      attacker function run 1st
        withdraw run 2rd
          attacker function run 2rd
              .........(后面会无限执行这两个方法,这也就是为什么叫race的原因)

THE DAO攻击事件源码分析

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智能合约受到攻击。黑客利用合约中存在的递归调用不断转账“刷钱”,导致合约合约筹集的公共款不断的被转移到其子合约中。简单来说,此次攻击是由于以下两个方面的不当导致的:

  1. 函数存在逻辑漏洞,变量值更新不当以及智能合约本身存在的机制联合导致。

  2. 攻击者递归调用splitDAO函数,并不断进行自我调用。在递归快要接触到Blcok Gas Limit的时候进行了收尾工作并将自己的DAO资产转移到另一个受攻击账户并在利用完漏洞后将资产再转移回来。(狡猾得很)

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

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攻击代码剖析

fallback函数

fallback函数在以下情况下会被调用:

  1. 当你调用了一个不存在的函数时,Solidity会自动调用fallback函数。

  2. 当你向合约发送以太币但没有指定任何函数时,Solidity会自动调用fallback函数。

  3. 当你向合约发送以太币并且指定了一个不存在的函数时,Solidity会自动调用fallback函数。

在Solidity中,receive()函数和fallback函数都可以用来处理未知资金发送请求。它们的主要区别在于:

  1. 函数声明方式不同:fallback函数可以使用两种方式定义,一种是使用空括号来定义,即function () {};另一种是使用关键字fallback来定义,即function fallback() {}。而receive()函数只能使用关键字receive来定义,即function receive() payable {}。

  2. 函数调用方式不同:fallback函数会在合约接收到无法匹配任何其他函数调用时自动执行。而receive()函数只会在合约接收到以太币时自动执行。

  3. 可支付性不同:fallback函数默认情况下是非payable的,需要显式添加payable关键字才能接收以太币。而receive()函数默认情况下是payable的,并且不能显式添加payable关键字。

graph TD
fallback --> 调用了一个不存在的函数
fallback --> 向合约发送以太币但没有指定任何函数
fallback --> 向合约发送以太币并且指定了一个不存在的函数
fallbackreceive
可以是空function()定义也可以叫做fallback只能用receive定义
以上图可以调用fallback只会在合约接收以太币时执行
fallback必须显式声明payable默认是paybale

因此,在Solidity 0.6.0版本之后,推荐优先使用receive()函数来处理未知资金发送请求。同时,在新版本中也建议将fallback函数声明为payable external函数,以确保其能够正常处理未知资金发送请求,并且只能从合约外部调用。

以太坊中的send与call函数

  • fallback函数可以做尽量多的计算直到gas耗尽。
  • recipient.send()函数被调用时,被send唤起的fallback函数最多只能消耗2300 gas。
  • recipient.call.value(...)会使用尽量多的gas,因此需要注意安全问题。
  • callcode和delegatecall这两个函数也会使用尽量多的gas。
  1. 在以太坊中,当调用call函数时,会执行默认的函数,也就是fallback函数。
  2. 除了call和send之外,还有两个类似的函数:callcode和delegatecall。
  3. 如果想要达到和send方法相同的安全效果,则需要将调用者指定的gas limit设置为0,并使用recipient.call.gas(0).value(...)来进行调用。

除了call以外,还有delegatecall和create等方式也可以实现重入攻击。其中delegatecall是一种特殊类型的调用方式,在调用过程中会将当前合约状态传递给被调用合约。攻击者可以利用这个特性,在被调用合约中修改当前合约状态,并在此过程中再次调用原来的函数,从而实现重入攻击。create方式则是通过创建新合约来实现重入攻击。

攻击流程概述

本次漏洞出现在应用层,是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的作用同样没有发挥。

防范

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

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

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

我们可以先更新再调用。

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

  • 首先要区别send和call的区别,并理解以太坊的gas机制

//如果异常会转账失败,抛出异常(等价于require(send()))(合约地址转账) // 有gas限制,最大2300

.transfer(uint256 amount)

//如果异常会转账失败,仅会返回false,不会终止执行(合约地址转账) // 有gas限制,最大2300

.send(uint256 amount) returns (bool)

如果异常会转账失败,仅会返回false,不会终止执行(调用合约的方法并转账) // 没有gas限制

.call(bytes memory) returns (bool, bytes memory)
  • 使用正确的顺序或者采用加锁的方式
  • 转换发送模式为提款模式,使收款方控制以太币转移,减少其他逻辑和提款逻辑的耦合
  • 防范调用栈攻击,判断调用外部合约的结果
  • 去掉循环处理,或者限制循环防范gas限制攻击或者让合约调用者控制循环