智能合约重入攻击是最经典也最致命的合约漏洞之一:攻击者利用外部调用尚未返回、状态尚未更新的时间窗口反复进入取款函数,把合约余额一笔笔搬空。本文从一段可直接编译复现的脆弱取款合约出发,用 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,后续每次提交都能拿到确定的答案,而不是靠人再读一遍代码。