什么是重入攻击 Reentrancy?合约还没记账,钱为什么能被反复取走

重入攻击利用外部调用期间的回调,在合约状态尚未更新时重复执行旧条件。本文用大白话讲清原理、常见类型、CEI 与重入锁。

什么是重入攻击 Reentrancy?合约还没记账,钱为什么能被反复取走

一份提款合约看起来很合理:先把钱转给用户,再把用户余额改成零。现实里却可能出现这样的情况——收款方不是普通钱包,而是一段合约代码;它收到钱时立刻“回头”再次提款。原合约还没来得及清零,于是同一份余额被重复使用。

这就是重入攻击最经典的画面。它不是交易真的穿越时间,而是合约在一次执行尚未收尾时,把控制权交给外部合约,对方又重新进入原来的函数或相关函数。

重入的危险点不只是“转账”,而是任何可能执行外部代码的调用。只要本地状态尚未形成安全结果,回调就可能利用旧状态。

一笔调用为什么能绕回来

智能合约调用另一个合约时,接收方可以执行自己的代码。以提款为例:合约 A 检查用户余额后调用合约 B 并发送资产;B 的 receive、fallback 或被调用函数运行时,可以再次调用 A。若 A 的余额记录仍是旧值,第二次检查可能继续通过。

合约进行外部调用时会暂时交出控制权,对方可能在状态更新前回调
合约进行外部调用时会暂时交出控制权,对方可能在状态更新前回调
  1. 攻击合约在目标合约里拥有一份可提款余额。
  2. 攻击合约调用提款函数。
  3. 目标先向攻击合约发送资产,尚未扣减内部余额。
  4. 攻击合约收到资产后,从回调再次调用提款。
  5. 目标仍读到旧余额,重复发送;循环直到条件变化、余额不足或交易失败。

并非每次回调都会成功,具体结果取决于代码、Gas、资产标准和事务是否整体回滚。但设计审查不能依靠“攻击可能跑不完”来获得安全。

重入不只发生在 ETH 提款

Solidity 官方安全文档指出,任何对其他合约的函数调用都可能带来重入,还要考虑多合约情形。代币转账钩子、NFT 安全转账回调、闪电贷回调、外部协议调用和可编程账户都可能让陌生代码获得执行机会。

类型表现容易漏看的点
单函数重入反复进入同一个提款函数余额在外部调用后才更新
跨函数重入从回调进入共享同一状态的另一函数只给提款函数加锁,另一入口仍能读旧状态
跨合约重入通过系统内其他合约改变依赖状态单个文件看似安全,组合后出现窗口
只读重入回调期间读取暂时不一致的价格或份额view 函数不改状态,却可能向别处提供错误依据

第一道防线:Checks-Effects-Interactions

常见的 CEI 模式按“检查、状态效果、外部交互”排序:先验证权限、参数和余额;再把余额扣除、订单标记完成;最后调用外部合约。即使对方回调,原合约看到的也应是更新后的状态。

防重入应优先完成检查和状态更新,再执行不可避免的外部交互
防重入应优先完成检查和状态更新,再执行不可避免的外部交互

这不是机械地把某一行上移。开发者要识别同一不变量涉及的全部状态和入口,确保外部调用前系统已经处于可安全重入的状态。复杂流程中若状态更新不完整,表面符合 CEI 仍可能留下跨函数或跨合约窗口。

第二道防线:重入锁

OpenZeppelin ReentrancyGuard 提供 nonReentrant 修饰器,在受保护调用执行期间阻止再次进入。它适合给提款、兑换、领取等关键入口增加明确保护。但锁不是替代良好状态设计的万能贴纸。

  • 要覆盖所有共享敏感状态的外部入口,而不是只保护最显眼的函数。
  • 同一个 ReentrancyGuard 下,标记 nonReentrant 的函数通常不能直接互相调用;常见做法是外部入口调用私有内部函数。
  • 升级合约要使用与其存储和初始化模式匹配的实现,不能随意复制普通合约写法。
  • 只读重入和跨合约依赖可能需要额外的不变量检查、价格保护或系统级锁定。

提款模式与失败隔离

相比在一笔主流程里主动把钱发给许多接收方,让用户分别领取通常更容易隔离失败。某个接收方拒收或消耗大量 Gas 时,不应阻塞所有人的结算。不过“改成提款”本身并不自动防重入,提款函数仍要正确更新状态并处理外部调用结果。

审计时应该怎样找

  1. 列出所有外部调用:call、代币与 NFT 转账、回调接口、未知合约方法、代理与钩子。
  2. 标记调用前后的状态:余额、份额、订单状态、价格缓存、债务和权限是否已形成安全结果。
  3. 寻找其他入口:回调能否进入另一个函数读取或修改同一组状态。
  4. 检查调用目标:即使地址已知,它是否可升级,或会继续调用未知地址?
  5. 编写攻击合约测试:模拟单函数、跨函数、多次回调和失败回滚。
  6. 验证系统不变量:总资产、总份额、债务和账户余额在任何回调位置都应保持约束。

普通用户能看什么

普通用户很难仅靠页面判断协议是否防住重入,但可以确认合约是否开源验证、是否有可信审计、关键修复是否公开、代理是否升级过,以及异常时是否有暂停机制。审计报告也不是永久保证:新版本、参数变化和外部依赖都可能改变风险。

遇到陌生协议时,先小额测试并核对授权范围,不要因为“合约运行很久”就忽略代码风险。重入只是智能合约攻击面之一,权限、预言机、价格操纵、升级与业务逻辑仍需一起评估。

常见误区

  • “不用 call 就不会重入”:其他合约调用和资产标准回调同样可能执行外部代码。
  • “加 nonReentrant 就彻底安全”:漏保护入口、跨合约与只读状态仍可能出问题。
  • “view 函数不会造成损失”:它可能在中间状态返回错误数据,被其他协议用于定价或结算。
  • “接收方是知名合约就可信”:已知合约可能升级,也可能继续调用其他组件。

最后记住三句话

  • 外部调用意味着控制权暂时交给别人。
  • 在交出控制权前,先让本地状态达到安全、完整的结果。
  • CEI、重入锁、提款模式、攻击测试和审计应互相补位,而不是只选一个。

风险提示:链上协议存在合约、组合性与升级风险。参与交互前请核对地址、权限、审计与实际交易内容,并控制风险敞口。本文仅作技术科普,不构成任何投资建议。


本文为链上指南原创内容。技术依据参考 Solidity 官方安全注意事项与OpenZeppelin ReentrancyGuard 文档。