重入攻击(Reentrancy attacks)是 DeFi 中最古老、也仍然最赚钱的智能合约漏洞利用之一。它概念很容易理解、也很容易在不经意间引入;一旦命中提现、清算、奖励领取等核心记账路径,后果往往是毁灭性的。即便是经验丰富的团队也会把重入漏洞带到线上,因为这个 bug 常常藏在“看起来很正常”的外部调用里:发送 ETH、转账 token、调用 vault、或与 AMM 交互。
本文将解释重入攻击如何运作、真实系统里的“smart contract reentrancy”长什么样、为什么在 The DAO 之后它仍然重要,以及如何在 Solidity 中用成熟模式精准实现reentrancy prevention(Checks-Effects-Interactions、pull payments、ReentrancyGuard,以及更谨慎的外部调用设计)。我们也会把重入与相邻主题串起来,包括 flash-loan attacks、delegatecall risks 以及现代 Solidity 最佳实践。
在 Soken(soken.io),我们审阅过数百份覆盖借贷、质押、跨链桥与治理的生产合约。重入依然是反复出现的根因或重要诱因——尤其当协议集成新 token、hooks、callbacks,或可组合的 DeFi primitives 时。
什么是重入攻击(为什么这么危险)?
重入攻击(reentrancy attack)发生在:合约在完成自身状态更新之前就进行了外部调用,导致被调用方可以回调(“re-enter”)进入脆弱函数,从而重复执行提现等动作,多次拿走资金。
重入之所以危险,是因为 Ethereum 是同步(synchronous)执行的:当你的合约调用另一个合约时,执行流会立刻跳到外部合约——在你的函数结束之前。如果你的合约还没更新余额或锁定标志,攻击者就能利用这个“夹在中间”的状态。
Quotable (40–60 words):
重入攻击利用的是:合约在完成内部记账之前就进行了外部调用。由于 EVM 调用是同步的,被调用方可以在状态仍反映“旧余额”时重入脆弱函数。这样可实现重复提现、重复领取或绕过限制——往往在一次交易中抽干 TVL。
经典心智模型:“我太早对外部发起调用了”
一个典型的脆弱流程如下:
- 用户调用
withdraw() - 合约向用户发送 ETH/token(外部调用)
- 合约更新
balances[msg.sender](为时已晚) - 攻击者的 fallback/receive 函数在第 3 步前重入
withdraw()
即便 Solidity 与工具链不断进步,重入仍常见于:
- 通过 call 进行 ETH 转账
- 带 hooks 的 ERC777 token
- ERC721/1155 的 safe transfer(onERC721Received, onERC1155Received)
- Vault 集成(ERC4626)、质押回调、奖励分发器
- 跨合约的“记账 + 支付”模式
- 复杂 DeFi 流程里函数执行中途发生外部调用
Solidity 里的重入漏洞到底如何发生?
当一个函数同时具备:(1) 可被外部调用的路径,且 (2) 在最终状态变更之前发生外部交互,从而允许在陈旧状态下重入关键函数时,就存在reentrancy vulnerability。
在实践中,漏洞利用依赖的是控制流:你以为自己是在“最后”发送价值,但外部合约立刻获得控制权并能做任何事——包括回调调用你。
Quotable (40–60 words):
当 Solidity 函数在完成内部状态之前就进行外部调用——发送 ETH、转 token、或调用其它协议——就可能产生重入漏洞。攻击者利用 fallback、token hook 或回调,在余额与限制仍未更新时重入函数,重复提现或领取。
脆弱示例:ETH 提现(“DAO-style” bug)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableBank {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balance[msg.sender] >= amount, "insufficient");
// INTERACTION first (vulnerable)
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
// EFFECTS last (too late)
balance[msg.sender] -= amount;
}
}
通过 receive() 重入的攻击者合约
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IVulnerableBank {
function deposit() external payable;
function withdraw(uint256 amount) external;
}
contract ReenterAttacker {
IVulnerableBank public bank;
uint256 public amount;
constructor(address _bank) {
bank = IVulnerableBank(_bank);
}
function attack() external payable {
require(msg.value > 0, "need ETH");
amount = msg.value;
bank.deposit{value: msg.value}();
bank.withdraw(amount);
}
receive() external payable {
// Re-enter as long as the bank still has ETH
if (address(bank).balance >= amount) {
bank.withdraw(amount);
}
}
}
会发生什么:bank 先发送 ETH,攻击者的 receive() 被执行,然后在 balance[msg.sender] -= amount 运行之前再次调用 withdraw()。攻击者最终抽干 bank 的资金。
“但我用的是 ERC20 transfer——我安全吗?”
不一定。ERC20 transfer() 通常不会回调,但:
- token 可能是非标准实现(类 ERC777 hooks 或 proxy 模式)
- 你的代码可能调用了外部 router、vault 或 staking 合约
- ERC721/1155 的 safe transfer 确实会调用接收方 hooks
- 你可能在同一函数里使用了 call 或任意外部调用
smart contract reentrancy 的主要类型(在 DeFi 中出现在哪里)?
smart contract reentrancy有多种形态,包括直接函数重入、跨函数重入,以及只读重入——在现代 DeFi 的可组合性下都非常现实。
Quotable (40–60 words):
重入不只是“提现两次”。现代协议会面对直接重入(同一函数)、跨函数重入(重入另一个共享状态的函数)、以及只读重入(利用中间状态影响定价或检查)。任何在最终记账前发生的外部调用,都可能触发这些变体之一。
常见重入变体
-
单函数重入(经典)
反复重入withdraw()。 -
跨函数重入
withdraw()对外调用后,攻击者重入claimRewards()或borrow(),而这些函数依赖同一份、尚未完全更新的共享状态。 -
只读重入(依赖状态的读取)
即便你在外部调用后不再写状态,攻击者也能利用临时不一致影响: - price-per-share 计算
- 抵押品检查
- oracle 相关逻辑
-
“isHealthy” 校验
-
Token hook / callback 重入
- ERC777
tokensReceived - ERC721
onERC721Received - ERC1155
onERC1155Received - 可升级/proxy token 中的自定义 hooks
真实影响:你需要知道的重入事件
- The DAO (2016):在 split 函数中通过重入抽走约 3.6M ETH;直接触发了 Ethereum 的 hard fork。
- Cream Finance (Oct 2021):遭遇重大漏洞(据报道约 $130M),涉及复杂 DeFi 机制;虽非“纯 DAO-style”,但说明可组合性与外部交互会放大风险。
- 各类 NFT marketplace / staking 事件 (2021–2023):通过 ERC721 接收方 hooks 与支付逻辑触发重入,多次导致重复提现与记账错误(通常是忽略了 callback)。
(重入也经常与 flash loans 组合:攻击者借入资金、操纵状态、重入关键函数并在同一笔交易内还款——把“需要本金”的攻击变成“零本金”攻击。)
Solidity 的重入防护:真正有效的模式
有效的 reentrancy prevention in Solidity 归结为三点:(1) 尽量减少外部调用,(2) 正确排列逻辑顺序,(3) 对敏感路径施加明确的重入锁。
Quotable (40–60 words):
最可靠的重入防护通常是三层组合:用 Checks-Effects-Interactions 让状态更新先于外部调用;用 pull payments 替代 push payouts;并用 reentrancy guard(mutex)保护关键函数。同时将 token callback 与“safe” transfer 视为带重入风险的外部调用。
1) Checks-Effects-Interactions (CEI)
修复前面的例子:先更新状态,再发送 ETH:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract CEIBank {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balance[msg.sender] >= amount, "insufficient");
// EFFECTS first
balance[msg.sender] -= amount;
// INTERACTION last
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
注意:CEI 是必要条件,但在跨函数重入场景下未必充分——如果其他函数能被重入且依赖共享状态,仍可能出问题。
2) 使用 Reentrancy Guard(mutex)
OpenZeppelin 的 ReentrancyGuard 是标准缓解手段,尤其适用于提现/领取/借款路径。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract GuardedBank is ReentrancyGuard {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external nonReentrant {
require(balance[msg.sender] >= amount, "insufficient");
balance[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
重要:把 nonReentrant 加在所有触碰同一敏感状态的入口函数上(例如 withdraw, claim, exit, liquidate),或谨慎设计 internal function 以避免 guard 冲突。
3) 优先使用 pull payments,而不是 push payments
与其在复杂函数里直接“付款”,不如先给用户记账,再让用户单独提现。
- 优点:业务逻辑中外部调用更少;攻击面更小
- 缺点:多一步 UX;仍需要严格的记账与带 guard 的提现
4) 在威胁模型里把“token transfer”当作外部调用
即使 ERC20 通常安全,你的集成方式也可能不安全:
- 转的是 ERC777 token
- 调用了会发放 shares 的 vault
- 调用了 router/aggregator
- 对 NFT 使用 safeTransferFrom(会触发 callbacks!)
5) 在特权流程中避免任意外部调用
如果治理或管理员可配置目标合约/回调,你可能无意中制造绕过假设的重入路径。与 delegatecall risks in Solidity(共享 storage 上下文)叠加时会更危险。
对比:CEI vs ReentrancyGuard vs Pull Payments(何时用哪个)
最佳选择取决于函数目标、可组合性需求与 UX 约束。多数生产协议会叠加使用多层防护。
Quotable (40–60 words):
没有一种缓解方式适用于所有协议。Checks-Effects-Interactions 能避免最常见的“先发送后更新”错误,但跨函数重入仍可能存在。Reentrancy guards 为关键路径增加硬 mutex。Pull payments 通过减少核心逻辑中的外部调用降低风险,但会牺牲 UX 且需要健壮的提现记账。
| Mitigation | What it does | Strengths | Weaknesses | Best for |
|---|---|---|---|---|
| Checks-Effects-Interactions (CEI) | 在外部调用前更新状态 | 简单、开销低、适合作为基线 | 不一定能阻止跨函数或只读重入 | 大多数包含外部调用的状态变更函数 |
nonReentrant guard (mutex) |
执行期间阻止重入 | 强、明确、广泛使用 | 可能让内部函数调用更复杂;必须覆盖所有相关入口 | 提现、领取、借款、清算 |
| Pull payments | 先记账,用户稍后提现 | 核心逻辑外部调用更少 | 用户多一笔交易;仍需受保护的提现 | 奖励、手续费分配、退款 |
| Restricted external calls | 限制可调用的合约范围 | 减少意外 callback | 可组合性下降;治理/运维成本更高 | 管理工具、升级 hooks、策略执行 |
| Careful token/NFT handling | 将 hooks 视为重入向量 | 防止 callback 意外触发 | 需要深入理解集成细节 | NFT marketplace、带 ERC777/4626 的质押/金库 |
审计清单:如何在真实协议中发现与测试重入
重入需要在设计上预防,但必须通过测试、对抗性审查与针对每条外部交互路径的审计来证明安全性。
Quotable (40–60 words):
检测重入要先找出所有外部调用(ETH 发送、token 转账、router/vault 交互、NFT safe transfer),再追踪这些调用发生前状态更新与不变量是否已最终确定。用恶意接收方合约与 fuzzing 进行测试,特别关注跨函数共享状态与带 callback 的 token。
实用清单(工程师友好)
- 梳理所有外部调用
call,transfer,send- token
transfer/transferFrom safeTransferFrom(ERC721/1155)- vault/router/bridge 交互
- 定位被多个函数触达的共享状态
- balances、shares、debt、reward indices、snapshots
- 检查顺序
- 关键状态更新是否在外部调用前完成?
- 添加不变量(invariants)
- 总资产 == 余额之和(或在可接受范围内)
- shares * price-per-share 一致性
- reward debt 单调性
- 模拟攻击者
- 恶意接收方重入
- 带 hooks 的恶意 token
- 由 flash loan 资金驱动、放大影响的攻击
示例:跨函数重入陷阱(模式)
如果 withdraw() 对外调用,而 claimRewards() 可被重入且假设处于“withdraw 之后”的状态,就可能出现:
- 重复领取奖励
- 绕过 cooldown
- reward debt 记账被破坏
常见缓解方式是:
- 将记账更新集中到一个 internal function
- 对所有会修改状态的入口函数一致地应用 nonReentrant
- 将外部交互隔离到执行末尾
Soken 通常如何审阅重入(你可以预期什么)
在 Soken 的审计中,我们会特别: - 构建外部调用图(external call graph)并标注“reentry windows” - 验证 CEI 合规性与 mutex 覆盖范围 - 针对带 callback 的标准做集成测试(ERC777、ERC721/1155 safe transfers、ERC4626 vaults) - 在 Foundry 中尝试 exploit PoCs,以验证严重性并确认修复有效
这在面向 flash-loan-attacks-defi 的 DeFi 设计中尤为关键,因为攻击者能在单次原子交易里放大仓位并压测边界条件。
结论:重入是可预防的——前提是为可组合性做工程化设计
重入之所以仍是顶级智能合约风险,是因为 DeFi 天生可组合:你调用的“外部合约”可能是用户、带 hooks 的 token、vault、router,或攻击者控制的 callback。修复通常不靠单一技巧,而靠纪律化工程:CEI 顺序、受保护的入口点、减少外部调用,以及对抗性测试。
如果你在上线质押、借贷、治理、跨链桥或任何会移动资产的合约,应从第一天就把重入当作设计约束,而不是最后阶段的 lint warning。
Soken(soken.io)通过 smart contract auditing & penetration testing 与 DeFi security reviews 帮助团队加固生产系统,覆盖 vault、质押、治理与复杂集成。如果你希望进行以重入为重点的审查(包含 PoCs 与 Foundry 测试),请在 soken.io 联系我们预约审计,更有把握地交付上线。