Solidity 智能合约中如何实现重入攻击防护?
重入攻击(Reentrancy Attack)是以太坊智能合约中危害最大的安全漏洞之一。截至 2026 年,因重入攻击造成的损失已超过 5.62 亿美元,仅 2025 年 GMX V1 Perps 就因此损失 4200 万美元,Arcadia V2 同年也遭重入攻击。理解重入攻击的原理和防护手段,是每一个 Solidity 开发者的必修课。
重入攻击的本质
重入攻击的核心在于:合约在更新内部状态之前调用了外部合约,攻击者利用这个时间窗口递归回调目标函数,在状态被修正前重复执行提款逻辑。
solidity// 存在漏洞的合约 contract VulnerableBank { mapping(address => uint256) public balances; function withdraw() public { uint256 amount = balances[msg.sender]; require(amount > 0); // 危险:先转账,后更新状态 (bool success, ) = msg.sender.call{value: amount}(""); require(success); balances[msg.sender] = 0; // 状态更新太晚 } }
攻击者部署一个恶意合约,在其 receive() 函数中再次调用 withdraw()。由于 msg.sender.call 会触发攻击者合约的回退函数,而此时 balances[msg.sender] 尚未清零,递归调用会再次通过余额检查,直到合约资金被掏空。
防护方法 1:Checks-Effects-Interactions 模式
这是最基础也最推荐的防护方式,遵循「先检查、再更新状态、最后交互」的编码顺序。
soliditycontract SecureBank { mapping(address => uint256) public balances; function withdraw() public { uint256 amount = balances[msg.sender]; require(amount > 0, "Insufficient balance"); // Checks balances[msg.sender] = 0; // Effects:先更新状态 (bool success, ) = msg.sender.call{value: amount}(""); // Interactions require(success, "Transfer failed"); } }
当攻击者尝试递归调用 withdraw() 时,由于余额已清零,require(amount > 0) 会直接回滚。这种模式无需额外 Gas 开销,是最经济高效的防护方案。
防护方法 2:使用重入锁(ReentrancyGuard)
对于逻辑复杂的合约,仅靠编码顺序可能不够,需要引入显式锁机制。OpenZeppelin 提供了经过审计的 ReentrancyGuard 实现:
solidityimport "@openzeppelin/contracts/security/ReentrancyGuard.sol"; contract ProtectedBank is ReentrancyGuard { mapping(address => uint256) public balances; function withdraw() public nonReentrant { uint256 amount = balances[msg.sender]; require(amount > 0, "Insufficient balance"); (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); balances[msg.sender] = 0; } }
nonReentrant 修饰器的原理是使用一个状态变量标记合约是否处于执行状态,若函数已被调用且尚未返回,后续调用将被拒绝。这种方式增加了约 2500-5000 Gas 的开销,但安全性更高。
如果你不想引入 OpenZeppelin 依赖,也可以手写一个简化版本:
soliditycontract MutexBank { mapping(address => uint256) public balances; bool private locked; modifier noReentrant() { require(!locked, "Reentrant call detected"); locked = true; _; locked = false; } function withdraw() public noReentrant { uint256 amount = balances[msg.sender]; require(amount > 0, "Insufficient balance"); (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); balances[msg.sender] = 0; } }
注意:手写互斥锁的粒度是合约级别的(bool locked),而 OpenZeppelin 在 v4.9+ 版本已改为转账级别的细粒度锁,推荐优先使用官方实现。
防护方法 3:限制 Gas 消耗
transfer 和 send 会将转发 Gas 限制为 2300,不足以执行任何复杂逻辑,从技术上阻止了重入。但这种方式存在严重局限:
- 不兼容多签钱包和合约钱包(如 Gnosis Safe),这些钱包的回退函数需要超过 2300 Gas
- EIP-1884 和未来的以太坊升级可能改变 Gas 定价,使 2300 Gas 更加不够用
- 仅能防止 ETH 转账的重入,无法防护 ERC-20 代币转账的重入
solidity// 不推荐的方式 function withdraw() public { uint256 amount = balances[msg.sender]; require(amount > 0); balances[msg.sender] = 0; payable(msg.sender).transfer(amount); // Gas 限制 2300 }
此方法仅适用于简单场景,生产环境不推荐作为主要防护手段。
进阶:跨合约重入与只读重入
传统的重入锁和 CEI 模式只能防护单合约内部的重入。实际攻防中还有两种更隐蔽的变体:
跨合约重入(Cross-Contract Reentrancy)
攻击者通过合约 A 的回调函数操作合约 B 的状态。当合约 A 和合约 B 共享状态依赖(如合约 A 的余额影响合约 B 的计算),而两者更新不同步时,就会产生跨合约重入漏洞。
防护要点:确保所有关联合约的状态在同一交易中原子性更新,或使用跨合约的重入锁。
只读重入(Read-Only Reentrancy)
攻击者在回调中通过 view 函数读取处于不一致状态的中间数据,并将这些数据用于其他协议的套利或操纵。view 函数不受 nonReentrant 保护,因此这种攻击更难被发现。
防护要点:在状态更新完成前,不应让外部合约可读取中间状态。可以引入一个 isUpdating 标志,view 函数检查该标志后决定是否返回数据。
防护方法对比
| 方法 | Gas 开销 | 防护范围 | 推荐场景 |
|---|---|---|---|
| Checks-Effects-Interactions | 无额外开销 | 单合约内重入 | 所有合约,基础必用 |
| ReentrancyGuard(OpenZeppelin) | +2500-5000 Gas | 单合约内重入 | 复杂逻辑、处理资金的合约 |
| 手写互斥锁 | +2500-5000 Gas | 单合约内重入 | 不想引入依赖的简单场景 |
| Gas 限制(transfer/send) | 无额外开销 | 仅 ETH 转账重入 | 不推荐作为主要方案 |
| 跨合约状态同步 + isUpdating 标志 | 视实现而定 | 跨合约/只读重入 | 多合约交互的 DeFi 协议 |
最佳实践
- CEI 模式是底线:无论是否使用重入锁,都必须遵循 Checks-Effects-Interactions 编码顺序
- ReentrancyGuard 作为第二道防线:对于涉及资金操作的合约,在 CEI 基础上叠加
nonReentrant修饰器 - 警惕跨合约状态依赖:多合约交互时确保状态原子性更新
- 部署前安全审计:使用 Slither、Mythril 等静态分析工具扫描重入漏洞
- 形式化验证:对于高价值合约,使用 Certora 进行数学证明,确保合约逻辑的正确性
- 关注 ERC-721/ERC-1155 的回调:
onERC721Received和onERC1155Received同样是重入的入口,不要忽略
追问:重入锁能防护所有重入攻击吗?
不能。nonReentrant 只能防止同一合约内的递归调用,无法防护跨合约重入和只读重入。此外,它也不保护通过构造函数、delegatecall 等方式触发的重入。安全防护必须多层级配合:CEI 模式 + 重入锁 + 跨合约状态同步 + 静态分析,缺一不可。