攻击描述
- 被黑目标: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 万美元)。
代码使用下面的逻辑:
- 检查用户的以太币余额
- 将资金发送给调用地址
- 将其余额重置为 0,防止用户再提取
应对重入攻击一种方法是遵循 检查-效果-交互模式 (opens in a new tab)↗
- 检查用户的以太币余额
- 将其余额重置为 0,防止用户再提取 【这一步记账先做掉】
- 将资金发送给调用地址
这里展示了公开的重入攻击合约: https://github.com/pcaversaccio/reentrancy-attacks
合约
- The DAO: https://en.wikipedia.org/wiki/The_DAO_(organization)
- THE DAO 合约地址: 0xbb9bc244d798123fde783fcc1c72d3bb8c189413
- 代码: https://github.com/TheDAO/DAO-1.0
攻击分析
先列出核心代码
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
函数通过递归触发DAO
的splitDAO
函数的多次调用。- 需要含有
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,另外两个函数callcode
和delegatecall
,也是如此。
如果想要和 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());
}
}