Re-Entrancy 攻击模拟——The DAO 事件复现与分析
本文最后更新于 2023年3月3日 下午
对区块链经典安全事件——The DAO 事件的 Re-Entrancy 攻击模拟
Re-Entrancy 攻击模拟——The DAO 事件复现与分析
实验概述
攻击背景
- The DAO 成立于 2016 年 5 月,是一个基于以太坊网络、以众筹为目的的去中心化自治组织。The DAO 众筹最大的特征是速度快,交易规模和速度远远快于一般的互联网众筹,在成立之初的短短 1 个月之内就筹集了超过 1.5 亿个以太币。因此,The DAO 是当时最大的众筹项目。
- 然而,The DAO 的智能合约代码存在递归调用漏洞的问题,因此,黑客可以借此发动 Re-Entrancy 攻击。黑客不停地从The DAO 资金池里分离资产并在调用结束前,把盗来的The DAO资产转移到了其他账户,避免了被销毁。黑客利用这两个漏洞,进行了两百多次攻击,总共盗走了 360 万的以太币,超过了该项目筹集的以太币总数目的三分之一。
- 由于参与 The DAO 的 ETH 总额占 ETH 总流通量的 30% 以上,ETH 社区不得不采取比中心化机构还更加“中心化”的方式对用户金额进行补救,也就是将 ETH 硬分叉为新的 ETH 和 ETC。这一事件史称为 The DAO 事件。
攻击原理
-
Gas
:一经创建,每笔交易都收取一定数量的gas
,目的是限制执行交易所需要的工作量和为交易支付手续费。EVM
执行交易时,gas
将按特定规则逐渐耗尽。gas price
是交易发送者设置的一个值,发送者账户需要预付的手续费。如果交易执行后还有剩余,gas
会原路返还。无论执行到什么位置,一旦
gas
被耗尽,将会触发一个out-of-gas
异常。当前调用帧所做的所有状态修改都将被回滚。 -
转 ETH 操作
-
call
- 原型:
<address>.call(...) returns (bool)
- 以
address
(被调用合约)的身份 调用address
内的函数,默认情况下将所有可用的gas
传输过去,gas
传输量可调。执行失败时返回false
- 原型:
-
send
-
原型:
<address>.send(uint256 amount) returns (bool)
-
向
address
发送amount
数量的Wei
,如果执行失败返回false
。发送的同时传输2300gas
,gas
数量不可调整
-
-
transfer
- 原型:
<address>.transfer(uint256 amount)
- 向
address
发送amount
数量的Wei
,如果执行失败则throw
。发送的同时传输2300gas
,gas
数量不可调整
- 原型:
-
-
fallback function
回退函数:每一个合约有且仅有一个没有名字的函数。这个函数无参数,也无返回值。如果调用合约时,没有匹配上任何一个函数(或者没有传哪怕一点数据),就会调用默认的回退函数。此外,当合约收到ether
时(没有任何其它数据),这个函数也会被执行。 -
以太坊智能合约能够调用和利用其他外部合约的代码。合约通常也处理以太币,因此将以太币发送到各种外部用户地址。调用外部合约或将以太币发送到地址的操作要求合约提交外部调用。这些外部调用可以被攻击者劫持,从而迫使合约执行更多的代码(即通过
fallback function
回退函数),包括回调原合约本身。
攻击流程
- 对于一个能够正常存钱和取钱的合约
Victim
,利用其地址构建相应攻击合约Attacker
Attacker
合约调用其攻击函数Attack
,该函数需要附上一定的ether
作为存入资金存入到Vitcim
中Attack
函数申请取出存入的ether
,Vitcim
向Attack
函数发送相应的ether
,但是此时仍然在Attack
函数中,Victim
还没有改变存款记录- 由于
Attacker
收到了ether
,所以会调用fallback function
,继续申请取出存入的ether
,Vitcim
向Attack
函数发送相应的ether
,同样Victim
还没有改变存款记录 - 而上述步骤会一直进行下去,直到达到
Attacker
提前设置好的攻击次数 - 攻击者提出储存在
Attacker
中的ether
即可完成攻击
攻击效果
- 在正常情况下,用户最多只能够取出自己在
Victim
上记录的ether
数目;如果发送的取出数目大于记录数目,那么无法取出任何ether
- 而攻击者利用
Attacker
,可以不断地取出ether
,直到整个Vitcim
合约上的ether
数目为 0
实验内容
实验条件
- 本实验基于 Remix Ethereum IDE,采用的编译版本是 0.4.26+commit.4563c3fc,测试环境是 Javascript VM
实验设置
- Vitcim 合约如下
1 |
|
- A 账号部署 Vitctim 合约
- A 账号存入 Victim 合约 50 ether
- A 账号提取 51 ether 失败
- A 账号提取 1 ether 成功
- 这是正常工作状态下的 Victim 合约,可以看到,此时合约能够进行正常的存取和记账
实验过程
- 攻击 payload 如下
1 |
|
- B 账号根据 Victim 合约地址部署 Attacker 合约
- B 账号发动攻击,附上 5 ether
- B 账号提取出攻击得到的币,注意 B 账号变成了 144.9999999 ether
- 攻击后,B 账户取出了不属于 B 账户的钱
实验结论
- 对比攻击前后,账户从仅能取出账面记录的钱变成了可以无限制取出合约的钱,说明攻击成功
实验分析
-
从上述实现现象中,可以看出 Re-Entrancy 攻击的可怕之处,下面逆向思考如何来修补漏洞,防止此类攻击
-
将
ether
发送到外部合约时使用内置的transfer
函数。transfer
函数仅发送2300 Gas
给外部调用,这不足以使目的地址合约调用另一个合约(即重入原合约)1
msg.sender.call.value(amount)();
变成
1
msg.sender.transfer(amount);
-
Victim
账户是先转钱给Attack
然后修改记录,而 Re-Entrancy 攻击使得上述步骤仅仅进行到转钱而不修改记录,因此一种可行的修改方法是,先记录,再转账1
2msg.sender.call.value(amount)();
balances[msg.sender]-=amount;修改成
1
2balances[msg.sender]-=amount;
msg.sender.call.value(amount)();但是,这里仍然存在一个新问题:如果提现失败,交易并不会回滚,此时
balances[msg.sender]
已清空所以,应该采用 checks-effects-interactions 模式,使用
require
语句进行检查 -
引入互斥锁,保证仅仅在必须要在记账完成之后才能再次取钱