什么是重入攻击 Reentrancy?合约还没记账,钱为什么能被反复取走
重入攻击利用外部调用期间的回调,在合约状态尚未更新时重复执行旧条件。本文用大白话讲清原理、常见类型、CEI 与重入锁。
一份提款合约看起来很合理:先把钱转给用户,再把用户余额改成零。现实里却可能出现这样的情况——收款方不是普通钱包,而是一段合约代码;它收到钱时立刻“回头”再次提款。原合约还没来得及清零,于是同一份余额被重复使用。
这就是重入攻击最经典的画面。它不是交易真的穿越时间,而是合约在一次执行尚未收尾时,把控制权交给外部合约,对方又重新进入原来的函数或相关函数。
重入的危险点不只是“转账”,而是任何可能执行外部代码的调用。只要本地状态尚未形成安全结果,回调就可能利用旧状态。
一笔调用为什么能绕回来
智能合约调用另一个合约时,接收方可以执行自己的代码。以提款为例:合约 A 检查用户余额后调用合约 B 并发送资产;B 的 receive、fallback 或被调用函数运行时,可以再次调用 A。若 A 的余额记录仍是旧值,第二次检查可能继续通过。

- 攻击合约在目标合约里拥有一份可提款余额。
- 攻击合约调用提款函数。
- 目标先向攻击合约发送资产,尚未扣减内部余额。
- 攻击合约收到资产后,从回调再次调用提款。
- 目标仍读到旧余额,重复发送;循环直到条件变化、余额不足或交易失败。
并非每次回调都会成功,具体结果取决于代码、Gas、资产标准和事务是否整体回滚。但设计审查不能依靠“攻击可能跑不完”来获得安全。
重入不只发生在 ETH 提款
Solidity 官方安全文档指出,任何对其他合约的函数调用都可能带来重入,还要考虑多合约情形。代币转账钩子、NFT 安全转账回调、闪电贷回调、外部协议调用和可编程账户都可能让陌生代码获得执行机会。
| 类型 | 表现 | 容易漏看的点 |
|---|---|---|
| 单函数重入 | 反复进入同一个提款函数 | 余额在外部调用后才更新 |
| 跨函数重入 | 从回调进入共享同一状态的另一函数 | 只给提款函数加锁,另一入口仍能读旧状态 |
| 跨合约重入 | 通过系统内其他合约改变依赖状态 | 单个文件看似安全,组合后出现窗口 |
| 只读重入 | 回调期间读取暂时不一致的价格或份额 | view 函数不改状态,却可能向别处提供错误依据 |
第一道防线:Checks-Effects-Interactions
常见的 CEI 模式按“检查、状态效果、外部交互”排序:先验证权限、参数和余额;再把余额扣除、订单标记完成;最后调用外部合约。即使对方回调,原合约看到的也应是更新后的状态。

这不是机械地把某一行上移。开发者要识别同一不变量涉及的全部状态和入口,确保外部调用前系统已经处于可安全重入的状态。复杂流程中若状态更新不完整,表面符合 CEI 仍可能留下跨函数或跨合约窗口。
第二道防线:重入锁
OpenZeppelin ReentrancyGuard 提供 nonReentrant 修饰器,在受保护调用执行期间阻止再次进入。它适合给提款、兑换、领取等关键入口增加明确保护。但锁不是替代良好状态设计的万能贴纸。
- 要覆盖所有共享敏感状态的外部入口,而不是只保护最显眼的函数。
- 同一个 ReentrancyGuard 下,标记 nonReentrant 的函数通常不能直接互相调用;常见做法是外部入口调用私有内部函数。
- 升级合约要使用与其存储和初始化模式匹配的实现,不能随意复制普通合约写法。
- 只读重入和跨合约依赖可能需要额外的不变量检查、价格保护或系统级锁定。
提款模式与失败隔离
相比在一笔主流程里主动把钱发给许多接收方,让用户分别领取通常更容易隔离失败。某个接收方拒收或消耗大量 Gas 时,不应阻塞所有人的结算。不过“改成提款”本身并不自动防重入,提款函数仍要正确更新状态并处理外部调用结果。
审计时应该怎样找
- 列出所有外部调用:call、代币与 NFT 转账、回调接口、未知合约方法、代理与钩子。
- 标记调用前后的状态:余额、份额、订单状态、价格缓存、债务和权限是否已形成安全结果。
- 寻找其他入口:回调能否进入另一个函数读取或修改同一组状态。
- 检查调用目标:即使地址已知,它是否可升级,或会继续调用未知地址?
- 编写攻击合约测试:模拟单函数、跨函数、多次回调和失败回滚。
- 验证系统不变量:总资产、总份额、债务和账户余额在任何回调位置都应保持约束。
普通用户能看什么
普通用户很难仅靠页面判断协议是否防住重入,但可以确认合约是否开源验证、是否有可信审计、关键修复是否公开、代理是否升级过,以及异常时是否有暂停机制。审计报告也不是永久保证:新版本、参数变化和外部依赖都可能改变风险。
遇到陌生协议时,先小额测试并核对授权范围,不要因为“合约运行很久”就忽略代码风险。重入只是智能合约攻击面之一,权限、预言机、价格操纵、升级与业务逻辑仍需一起评估。
常见误区
- “不用 call 就不会重入”:其他合约调用和资产标准回调同样可能执行外部代码。
- “加 nonReentrant 就彻底安全”:漏保护入口、跨合约与只读状态仍可能出问题。
- “view 函数不会造成损失”:它可能在中间状态返回错误数据,被其他协议用于定价或结算。
- “接收方是知名合约就可信”:已知合约可能升级,也可能继续调用其他组件。
最后记住三句话
- 外部调用意味着控制权暂时交给别人。
- 在交出控制权前,先让本地状态达到安全、完整的结果。
- CEI、重入锁、提款模式、攻击测试和审计应互相补位,而不是只选一个。
风险提示:链上协议存在合约、组合性与升级风险。参与交互前请核对地址、权限、审计与实际交易内容,并控制风险敞口。本文仅作技术科普,不构成任何投资建议。
本文为链上指南原创内容。技术依据参考 Solidity 官方安全注意事项与OpenZeppelin ReentrancyGuard 文档。