可重入攻击解析:Solidity 防护方法

重入攻击(Reentrancy attacks)是 DeFi 中最古老、也仍然最赚钱的智能合约漏洞利用之一。它概念很容易理解、也很容易在不经意间引入;一旦命中提现、清算、奖励领取等核心记账路径,后果往往是毁灭性的。即便是经验丰富的团队也会把重入漏洞带到线上,因为这个 bug 常常藏在“看起来很正常”的外部调用里:发送 ETH、转账 token、调用 vault、或与 AMM 交互。

本文将解释重入攻击如何运作、真实系统里的“smart contract reentrancy”长什么样、为什么在 The DAO 之后它仍然重要,以及如何在 Solidity 中用成熟模式精准实现reentrancy prevention(Checks-Effects-Interactions、pull payments、ReentrancyGuard,以及更谨慎的外部调用设计)。我们也会把重入与相邻主题串起来,包括 flash-loan attacksdelegatecall risks 以及现代 Solidity 最佳实践。

在 Soken(soken.io),我们审阅过数百份覆盖借贷、质押、跨链桥与治理的生产合约。重入依然是反复出现的根因或重要诱因——尤其当协议集成新 token、hooks、callbacks,或可组合的 DeFi primitives 时。


什么是重入攻击(为什么这么危险)?

重入攻击(reentrancy attack)发生在:合约在完成自身状态更新之前就进行了外部调用,导致被调用方可以回调(“re-enter”)进入脆弱函数,从而重复执行提现等动作,多次拿走资金。

重入之所以危险,是因为 Ethereum 是同步(synchronous)执行的:当你的合约调用另一个合约时,执行流会立刻跳到外部合约——在你的函数结束之前。如果你的合约还没更新余额或锁定标志,攻击者就能利用这个“夹在中间”的状态。

Quotable (40–60 words):
重入攻击利用的是:合约在完成内部记账之前就进行了外部调用。由于 EVM 调用是同步的,被调用方可以在状态仍反映“旧余额”时重入脆弱函数。这样可实现重复提现、重复领取或绕过限制——往往在一次交易中抽干 TVL。

经典心智模型:“我太早对外部发起调用了”

一个典型的脆弱流程如下:

  1. 用户调用 withdraw()
  2. 合约向用户发送 ETH/token(外部调用
  3. 合约更新 balances[msg.sender]为时已晚
  4. 攻击者的 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):
重入不只是“提现两次”。现代协议会面对直接重入(同一函数)、跨函数重入(重入另一个共享状态的函数)、以及只读重入(利用中间状态影响定价或检查)。任何在最终记账前发生的外部调用,都可能触发这些变体之一。

常见重入变体

  1. 单函数重入(经典)
    反复重入 withdraw()

  2. 跨函数重入
    withdraw() 对外调用后,攻击者重入 claimRewards()borrow(),而这些函数依赖同一份、尚未完全更新的共享状态。

  3. 只读重入(依赖状态的读取)
    即便你在外部调用后不再写状态,攻击者也能利用临时不一致影响:

  4. price-per-share 计算
  5. 抵押品检查
  6. oracle 相关逻辑
  7. “isHealthy” 校验

  8. Token hook / callback 重入

  9. ERC777 tokensReceived
  10. ERC721 onERC721Received
  11. ERC1155 onERC1155Received
  12. 可升级/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 testingDeFi security reviews 帮助团队加固生产系统,覆盖 vault、质押、治理与复杂集成。如果你希望进行以重入为重点的审查(包含 PoCs 与 Foundry 测试),请在 soken.io 联系我们预约审计,更有把握地交付上线。

Frequently Asked Questions

什么是智能合约中的可重入攻击?

可重入攻击是指合约在更新自身状态之前先进行外部调用,攻击者合约可借此重新进入脆弱函数并重复执行。利用同一笔交易中余额等状态不一致,攻击者可反复触发提现或领取逻辑,从而逐步抽走资金。

如何在 Solidity 代码中识别可重入漏洞?

重点检查在关键状态更新(如扣减余额、递增 nonce)之前是否发生外部调用,例如发送 ETH、调用代币合约、AMM 或金库。也要标记可能触发回调的路径,如 ERC777 hooks 以及 fallback/receive。若函数可在执行中再次进入,可能存在可重入风险。

Solidity 中防止可重入的最佳实践是什么?

采用 Checks-Effects-Interactions:先校验输入,再更新内部状态,最后进行外部交互。优先使用“拉取式支付”替代“推送式转账”。在敏感函数上加入可重入锁(如 OpenZeppelin ReentrancyGuard)。尽量减少核心记账路径中的外部调用,并谨慎设计可调用接口以避免回调。

使用 OpenZeppelin ReentrancyGuard 是否能完全防止可重入攻击?

ReentrancyGuard 能阻止许多同一合约内的重入模式,但并非完整的安全保证。跨函数重入、外部协议回调以及记账逻辑缺陷仍可能导致损失。应将可重入锁与正确的状态更新顺序(CEI)、拉取式支付模式和严格的外部调用设计结合,获得更稳健的防护。