WTF Solidity 合约安全:S01 重入攻击(Reentrancy Attack)原理、复现与防御
2026/9/15 12:52:27 网站建设 项目流程

WTF Solidity 合约安全:S01 重入攻击(Reentrancy Attack)原理、复现与防御

【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity

重入攻击(Reentrancy Attack)是智能合约世界中最经典、损失最惨重的漏洞类型,它曾在 2016 年击穿 The DAO 合约并直接导致以太坊分叉为 ETH 与 ETC。本篇文章以 WTF-Solidity 仓库 S01_ReentrancyAttack 的教程为骨架,结合仓库中的 ReentrancyAttack.sol 完整源码,逐步拆解漏洞成因、攻击合约的构造逻辑、Remix 环境下的复现步骤,以及检查-影响-交互(CEI)模式与重入锁(Reentrancy Guard)两种主流防御方案。读完本文,你将能够独立识别合约中的重入风险点,并写出可防御此类攻击的 Solidity 代码。

一、什么是重入攻击

重入攻击是智能合约中最常见的一类攻击方式:攻击者利用合约中的漏洞(典型如fallback/receive回调函数)在外部调用尚未结束时再次进入同一合约,循环执行提款或铸币逻辑,从而把合约中的资产搬空或铸造出大量代币。

其根本原因在于:当合约通过call向外部地址转账 ETH 时,如果接收方是合约,对方的fallback()receive()会被同步触发。若回调中再次调用原合约的关键函数,而原合约尚未更新内部状态(如余额),就会形成"转账 → 回调 → 再转账 → 再回调"的无限循环。

历史重大重入攻击事件

重入攻击并非理论威胁,而是反复造成千万美元级损失的实战漏洞:

  • 2016 年:The DAO 合约遭重入攻击,黑客盗走合约中 3,600,000 枚ETH,最终导致以太坊分叉为ETH链与ETC(以太经典)链。
  • 2019 年:合成资产平台 Synthetix 遭重入攻击,被盗 3,700,000 枚sETH
  • 2020 年:借贷平台 Lendf.me 遭重入攻击,损失约 $25,000,000。
  • 2021 年:借贷平台 CREAM FINANCE 遭重入攻击,损失约 $18,800,000。
  • 2022 年:算法稳定币项目 Fei 遭重入攻击,损失约 $80,000,000。

距离 The DAO 事件已过去数年,但每年仍会出现因重入漏洞损失千万美元级资金的项目,因此深入理解该漏洞对合约开发者至关重要。

二、"黑客 0xAA 抢银行"的故事

为便于理解,教程中用"黑客 0xAA 抢银行"的寓言来具象化重入攻击的全过程:

以太坊银行的柜员是机器人(Robot),由智能合约控制。正常用户(User)取钱时,服务流程为:

  1. 查询用户的ETH余额,若大于 0 则进行下一步;
  2. 将用户的ETH余额从银行转给用户,并询问用户是否收到;
  3. 将用户名下的余额更新为 0。

某天黑客 0xAA 走进银行,与机器人柜员展开如下对话:

  • 0xAA:我要取钱,1 ETH
  • Robot:正在查询您的余额:1 ETH。正在转账1 ETH到您的账户。您收到钱了吗?
  • 0xAA:等等,我要取钱,1 ETH
  • Robot:正在查询您的余额:1 ETH。正在转账1 ETH到您的账户。您收到钱了吗?
  • 0xAA:等等,我要取钱,1 ETH
  • Robot:(再次查询、转账、询问……)
  • 0xAA:等等,我要取钱,1 ETH
  • ……

关键点在于:银行在"转账"与"更新余额"之间没有完成状态更新,黑客抓住"是否收到钱"的询问时机不断要求再次取款,最终把银行资产搬空。仓库中的示意图 S01-1.png 用左右对比方式直观展示了"普通交互"与"重入攻击"两种流程的差异。

三、漏洞合约示例:银行合约与攻击合约

仓库 S01_ReentrancyAttack/ReentrancyAttack.sol 完整实现了漏洞合约、攻击合约及两类防御方案。下面逐一拆解。

3.1 银行合约(存在漏洞)

银行合约非常简单:包含 1 个状态变量balanceOf记录所有用户的 ETH 余额,以及 3 个函数:

  • deposit():存款函数,将ETH存入银行合约并更新用户余额;
  • withdraw():提款函数,将调用者的余额转给它。其执行步骤与故事一致——查询余额 → 转账 → 更新余额。注意:这个函数有重入漏洞!
  • getBalance():获取银行合约里的ETH余额。
contract Bank { mapping (address => uint256) public balanceOf; // 余额mapping // 存入ether,并更新余额 function deposit() external payable { balanceOf[msg.sender] += msg.value; } // 提取msg.sender的全部ether function withdraw() external { uint256 balance = balanceOf[msg.sender]; // 获取余额 require(balance > 0, "Insufficient balance"); // 转账 ether !!! 可能激活恶意合约的fallback/receive函数,有重入风险! (bool success, ) = msg.sender.call{value: balance}(""); require(success, "Failed to send Ether"); // 更新余额 balanceOf[msg.sender] = 0; } // 获取银行合约的余额 function getBalance() external view returns (uint256) { return address(this).balance; } }

3.2 漏洞根因:call转账触发回调

重入攻击的攻击点正是合约转账ETH的地方:

(bool success, ) = msg.sender.call{value: balance}("");

call是最底层的转账方式,不会限制调用者的gas。如果转账目标地址是合约,会同步触发对方的fallbackreceive函数。若对该机制不熟悉,可先阅读仓库中 19_Fallback 关于接收 ETH 与回退函数的讲解。由于Bank合约在withdraw()中是先转账、后更新余额,恶意合约的回调就有机会在余额清零之前再次调用withdraw(),形成循环提款。

3.3 攻击合约

攻击合约的逻辑非常简单:通过receive()回调函数循环调用Bank合约的withdraw()函数。它包含 1 个状态变量bank记录Bank合约地址,以及 4 个函数:

  • 构造函数:初始化Bank合约地址;
  • receive():回调函数,在接收ETH时被触发,并再次调用Bank合约的withdraw()函数,循环提款;
  • attack():攻击函数,先调用Bank合约的deposit()存款,再调用withdraw()发起第一次提款,此后Bank.withdraw()Attack.receive()会循环互调,直到把Bank合约的ETH提空;
  • getBalance():获取攻击合约里的ETH余额。
contract Attack { Bank public bank; // Bank合约地址 // 初始化Bank合约地址 constructor(Bank _bank) { bank = _bank; } // 回调函数,用于重入攻击Bank合约,反复的调用目标的withdraw函数 receive() external payable { if (bank.getBalance() >= 1 ether) { bank.withdraw(); } } // 攻击函数,调用时 msg.value 设为 1 ether function attack() external payable { require(msg.value == 1 ether, "Require 1 Ether to attack"); bank.deposit{value: 1 ether}(); bank.withdraw(); } // 获取本合约的余额 function getBalance() external view returns (uint256) { return address(this).balance; } }

攻击时序可以总结为:

  1. attack()先向Bank存入1 ETH,使balanceOf[攻击者] = 1 ETH
  2. 紧接着调用withdraw()Bank查询余额为1 ETH,开始向攻击合约转账;
  3. 转账触发Attack.receive(),回调中判断Bank合约仍有足够余额后再次调用withdraw()
  4. 此时Bank尚未执行balanceOf[msg.sender] = 0,攻击者的余额仍是1 ETH,于是再次通过校验并再次转账;
  5. 循环往复,直到Bank合约余额不足(receive()中的if (bank.getBalance() >= 1 ether)条件终止循环)。

注意Attack合约中bankpublic状态变量,会隐式生成同名 getter,因而attack()中可直接使用bank.deposit/bank.withdraw调用;receive()bank.getBalance()也可等价地写成address(bank).balance

四、Remix 复现演示

在 Remix IDE 中可完整复现上述攻击,具体步骤如下:

  1. 部署Bank合约,调用deposit()函数,转入20 ETH
  2. 切换到攻击者钱包,部署Attack合约(构造函数传入Bank合约地址);
  3. 调用Attack合约的attack()函数发动攻击,调用时需附带转账1 ETH
  4. 调用Bank合约的getBalance()函数,发现余额已被提空(变为 0);
  5. 调用Attack合约的getBalance()函数,可以看到余额变为21 ETH(原 20 ETH + 攻击者投入的 1 ETH),重入攻击成功。

不止 ETH 转账:更广泛的触发面

需要强调的是,重入攻击并不局限于ETH转账:

  • ERC721ERC1155safeTransfer()/safeTransferFrom()安全转账函数会回调接收方合约的onERC721Received/onERC1155Received接口;
  • ERC777tokensReceived回调函数同样可能被利用。

因此重入本质上是一个宏观的设计问题(先交互、后更新状态),而不仅限于 ETH 转账本身。

五、防御方案一:检查-影响-交互模式(Checks-Effects-Interactions)

检查-影响-交互模式强调编写函数时的顺序纪律:先检查状态变量是否符合要求,紧接着更新状态变量(例如余额),最后再与其他合约交互。若将Bank合约withdraw()中的余额更新提前到转账ETH之前,即可修复漏洞:

function withdraw() external { uint256 balance = balanceOf[msg.sender]; require(balance > 0, "Insufficient balance"); // 检查-效果-交互模式(checks-effect-interaction):先更新余额变化,再发送ETH // 重入攻击的时候,balanceOf[msg.sender]已经被更新为0了,不能通过上面的检查。 balanceOf[msg.sender] = 0; (bool success, ) = msg.sender.call{value: balance}(""); require(success, "Failed to send Ether"); }

修复后的执行顺序变为"校验余额 → 余额清零 → 转账"。当攻击合约的回调再次调用withdraw()时,require(balance > 0)会因余额已为 0 而直接失败,循环被切断。仓库中 ReentrancyAttack.sol 的GoodBank合约即为此方案的完整实现。

六、防御方案二:重入锁(Reentrancy Guard)

重入锁是一种用于防止重入调用的修饰器(modifier),它包含一个默认为0的状态变量_status。被nonReentrant修饰的函数,在第一次调用时会检查_status是否为0,紧接着将_status改为1,调用结束后再恢复为0。这样,当攻击合约在调用结束前发起第二次调用时,require(_status == 0)会直接报错,重入攻击失败。如果对修饰器不熟悉,可阅读仓库中 11_Modifier 的讲解。

uint256 private _status; // 重入锁 // 重入锁 modifier nonReentrant() { // 在第一次调用 nonReentrant 时,_status 将是 0 require(_status == 0, "ReentrancyGuard: reentrant call"); // 在此之后对 nonReentrant 的任何调用都将失败 _status = 1; _; // 调用结束,将 _status 恢复为0 _status = 0; }

只需用nonReentrant重入锁修饰withdraw()函数,即可预防重入攻击:

// 用重入锁保护有漏洞的函数 function withdraw() external nonReentrant{ uint256 balance = balanceOf[msg.sender]; require(balance > 0, "Insufficient balance"); (bool success, ) = msg.sender.call{value: balance}(""); require(success, "Failed to send Ether"); balanceOf[msg.sender] = 0; }

仓库中 ReentrancyAttack.sol 的ProtectedBank合约即为此方案的完整实现。

从源码看 OpenZeppelin 的工程化实现

仓库依赖的 OpenZeppelin 库提供了工业级实现,路径为 lib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol(v5.5.0)。与教程中的教学简化版相比,它做了几处关键强化:

  • 使用NOT_ENTERED = 1ENTERED = 2两个常量代替0/1:初始状态为1,进入保护区域置为2,退出恢复1。这样可避免一些利用"初始值即锁定位"的边界绕过;
  • 通过固定哈希槽位REENTRANCY_GUARD_STORAGE定位存储,避免与代理合约升级场景下的存储布局冲突;
  • nonReentrant()修饰器中调用_nonReentrantBefore()_nonReentrantAfter(),进入时校验并置位、退出时复位,_nonReentrantAfter()的写回还会因 EIP-2200 触发 gas 退款;
  • 额外提供只读的nonReentrantView()修饰器,用于拦截在重入过程中读取不一致状态的 view 函数;
  • 声明自定义错误ReentrancyGuardReentrantCall()替代字符串 revert,降低 gas 开销。

OpenZeppelin 官方还提示:如果部署链支持 EIP-1153 瞬态存储(transient storage),可考虑使用ReentrancyGuardTransient变体;同时注意,同一合约中被nonReentrant修饰的函数之间不可互相调用(会误报重入),需要拆分private内部实现与external入口来规避。

七、防御方案三:拉取支付模式(PullPayment)

除上述两种方案外,OpenZeppelin 还提倡遵循**拉取支付(PullPayment)**模式来从设计上规避重入风险。其原理是引入第三方托管(escrow),将原先的"主动转账"分解为"转账者发起转账"加上"接受者主动拉取"两个阶段:

  • 当想要发起一笔转账时,通过_asyncTransfer(address dest, uint256 amount)将待转账金额存储到第三方合约中,从而避免因重入导致的自身资产损失;
  • 当接受者想要接收转账时,需要主动调用withdrawPayments(address payable payee)进行资产的主动获取。

由于接收方只有在主动调用withdrawPayments时才真正收到资产,而该调用本身发生在状态更新之后(或由外部交易触发),重入窗口被大幅压缩。这种"拉取而非推送"的思想也是许多安全代币、保险库与支付分配合约的通用设计范式,仓库中 42_PaymentSplit 等主题也体现了类似的资金管理模式。

八、总结与实战建议

本讲介绍了以太坊上最常见的一类攻击——重入攻击,并用"0xAA 抢银行"的故事直观呈现了漏洞成因:合约在转账与状态更新之间存在时间差,攻击者通过receive()/fallback()回调循环调用提款逻辑,最终搬空资产。我们随后给出了两种主流防御方案:

  1. 检查-影响-交互模式(CEI):先校验、再更新状态、最后外部交互,从函数写法上杜绝漏洞;
  2. 重入锁(Reentrancy Guard):用nonReentrant修饰器保证同一时刻函数只能进入一次,实现简单、覆盖面广,OpenZeppelin 的工业级实现还兼顾了存储布局、gas 优化与瞬态存储等工程细节。

另外需要注意,ERC721/ERC1155safeTransfer系列函数以及ERC777的回调机制都可能成为重入载体,防御时应从宏观设计层面审视状态更新的时机。

给新手的建议:用重入锁保护所有可能改变合约状态的external函数,虽然会消耗更多gas,但相比可能造成的资产损失,这笔开销是完全值得的。同时将 CEI 模式作为编写函数的默认纪律,双管齐下才能最大限度降低重入风险。

【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程,供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询