查询 25 次,耗时 0.2395 秒

从重入漏洞到可验证修复:用 Foundry 搭建本地智能合约安全审计实验

lv001

New member

lv001 Rep
0
0
0
Rep
0
lv001 Vouches
Vouches
0
1 MONTH
1 1 MONTH OF SERVICE
lv001 获得
0
0
0
获得
0
lv001 凭证
0
0
0
凭证
0
主题
11
点赞
0
HakB
1 月
在社区成长 1 月
LEVEL 1 1 XP
 
一、背景:为什么重入问题仍值得单独审计

重入漏洞并不是“旧版本 Solidity 才有”的历史问题。只要合约在状态更新完成前,把控制权交给不受信任的外部地址,就可能出现同一业务流程被重复进入的情况。外部调用不只包括普通转账,还包括 call、代币回调、NFT 接收钩子、闪电贷回调以及协议间组合调用。

审计时不能只搜索 call.value 之类的旧语法。更可靠的做法是确认三个问题:外部调用发生前,关键状态是否已经落账;同一入口或关联入口能否在回调期间再次进入;协议依赖的不变量是否会在回调窗口中暂时失效。

本文只在本地 Foundry 靶场中复现漏洞,用于理解原理、编写测试和验证修复,不涉及任何真实合约或未授权目标。

二、漏洞原理与成立条件

典型的提款流程包含读取余额、发送资产、清空余额三个动作。如果发送资产位于清空余额之前,接收方在 receive 或 fallback 回调中再次调用提款函数,合约就可能重复读取尚未清空的余额。

漏洞通常需要同时满足以下前置条件:

1. 合约存在向外部地址转移控制权的操作,例如低级 call 或带回调的代币转账。
2. 外部调用发生时,用户余额、债务、份额、订单状态等关键数据尚未更新。
3. 回调期间能够再次进入原函数,或进入共享同一状态的另一个函数。
4. 合约中有足够资产,且没有互斥锁、额度校验或其他不变量检查阻止重复执行。
5. 整笔交易不会因为后续算术下溢或其他异常而回滚。

最后一点经常被忽略。Solidity 0.8 以后默认检查整数溢出和下溢,某些旧式示例会在递归返回阶段触发下溢,导致交易整体回滚。因此,审计不能仅凭代码形态断言“可利用”,还要用与目标编译器版本一致的测试验证完整执行路径。

三、搭建授权本地实验环境

以下实践建议在临时目录中完成,需要预先安装 Git 和 Foundry:

forge init reentrancy-lab
cd reentrancy-lab
forge --version

删除默认示例后,可以把合约放入 src/ReentrancyLab.sol,把测试放入 test/ReentrancyLab.t.sol。所有测试均在本地 EVM 执行,不连接公网 RPC,也不使用真实私钥。

四、编写存在缺陷的金库

将以下内容写入 src/ReentrancyLab.sol:

pragma solidity ^0.8.24;

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

function deposit() external payable {
require(msg.value > 0, "zero deposit");
balances[msg.sender] += msg.value;
}

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

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

balances[msg.sender] = 0;
}
}

contract ReentrantReceiver {
VulnerableVault public immutable target;
uint256 public immutable unit;

constructor(VulnerableVault target_, uint256 unit_) {
target = target_;
unit = unit_;
}

function run() external payable {
require(msg.value == unit, "wrong value");
target.deposit{value: unit}();
target.withdrawAll();
}

receive() external payable {
if (address(target).balance >= unit) {
target.withdrawAll();
}
}
}

这里的关键不是 call 本身,而是 balances[msg.sender] = 0 位于外部调用之后。接收方回调时,账面余额仍然是原值,所以每一层调用都能通过 amount > 0 检查。函数使用“最终赋零”而不是递归返回时连续做减法,也避免了 Solidity 0.8 下溢检查让实验提前回滚。

五、编写复现测试并观察调用过程

将以下测试写入 test/ReentrancyLab.t.sol:

pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {VulnerableVault, ReentrantReceiver} from "../src/ReentrancyLab.sol";

contract ReentrancyLabTest is Test {
VulnerableVault vault;
ReentrantReceiver receiver;

receive() external payable {}

function setUp() public {
vault = new VulnerableVault();
receiver = new ReentrantReceiver(vault, 1 ether);
vm.deal(address(this), 20 ether);
}

function testLocalReentrancyDrainsVault() public {
vault.deposit{value: 10 ether}();
receiver.run{value: 1 ether}();

assertEq(address(vault).balance, 0);
assertEq(address(receiver).balance, 11 ether);
}
}

执行:

forge test -vvv
forge test --match-test testLocalReentrancyDrainsVault -vvvv

-vvvv 会输出更完整的调用轨迹。审阅轨迹时,应重点确认 withdrawAll 是否在第一次调用返回前被重复调用、每一层读取到的 amount 是否相同,以及余额清零动作是否直到调用栈回退时才执行。

这类测试的价值不仅是展示资产变化,还在于把漏洞条件固化成可重复的证据。若修改代码后该测试不再成功,仍需补充正常提款测试,避免把“所有提款都无法执行”误判为安全修复。

六、修复方案:CEI 与互斥锁组合

推荐先采用 Checks-Effects-Interactions,也就是先检查、再更新状态、最后进行外部交互。对于资产托管入口,可再增加重入锁作为纵深防御:

contract FixedVault {
mapping(address => uint256) public balances;
bool private entered;

modifier nonReentrant() {
require(!entered, "reentrant call");
entered = true;
_;
entered = false;
}

function deposit() external payable {
require(msg.value > 0, "zero deposit");
balances[msg.sender] += msg.value;
}

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

balances[msg.sender] = 0;

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

状态先清零后再发送资产,即使接收方获得控制权,也不能再次读取到可提款余额。若外部调用失败,require 会让整个交易回滚,清零动作也随之回滚,因此不会无故丢失用户账面余额。

互斥锁不能替代正确的业务顺序。多个函数如果共享余额、份额或债务状态,必须识别“跨函数重入”。例如 withdraw 使用了锁,但 redeem 或 claim 没有使用,回调仍可能从另一个入口破坏同一不变量。升级合约还应使用经过验证、适配代理模式的重入保护实现,并核对初始化流程与存储布局,不能直接照搬带构造函数假设的普通合约代码。

七、为修复补充正常路径和回归测试

修复后至少应验证以下场景:

1. 普通账户存入 1 ether 后能完整取回。
2. 提款后账面余额为零,合约总余额同步减少。
3. 回调接收方尝试重复提款时交易失败,或只能取得自己的合法份额。
4. 外部接收方拒绝收款时,账面状态不会永久丢失。
5. 多用户顺序存取时,一个用户不能影响另一个用户的余额。

可使用 Foundry 的 vm.expectRevert 验证重入交易失败,并使用 vm.prank、vm.deal 构造不同账户。进一步可以编写模糊测试,让存款金额在合理范围内随机变化:

function testFuzzDepositAccounting(uint96 rawAmount) public {
uint256 amount = bound(uint256(rawAmount), 1 wei, 100 ether);
address user = makeAddr("user");
vm.deal(user, amount);

vm.prank(user);
fixedVault.deposit{value: amount}();

assertEq(fixedVault.balances(user), amount);
assertEq(address(fixedVault).balance, amount);
}

更重要的是定义协议不变量。例如在不考虑强制转入 ETH 的简化模型中,所有用户账面余额之和不应大于金库可用资产;任一用户成功提款后,其余额必须归零;同一笔份额不能被兑换两次。真实项目中,selfdestruct 强制转账、手续费、收益累计和舍入误差都可能让“合约余额必须严格等于账面总额”这一断言失效,所以不变量必须贴合实际会计模型。

八、静态分析与人工审计步骤

本地安装并运行 Slither 后,可先做自动化筛查:

slither .
slither . --detect reentrancy-eth,reentrancy-no-eth

工具报告只能作为线索。人工复核建议按以下顺序进行:

第一步,列出资产与权限。确认合约控制哪些原生币、ERC-20、NFT、份额、债务和管理员能力。

第二步,枚举所有外部入口。除 public 和 external 函数外,还要检查 receive、fallback、代币回调、跨链消息接收器和代理升级入口。

第三步,标记外部交互点。搜索 call、delegatecall、staticcall、transferFrom、safeTransfer、safeMint,以及可能触发钩子的协议接口。

第四步,沿调用前后追踪状态。检查余额、nonce、订单状态、投票权、抵押率和已领取标记是在交互前还是交互后更新。

第五步,审查共享状态。不要只看同函数递归,还要检查 A 函数回调进入 B 函数、只读重入以及外部协议在中间状态读取错误价格的可能性。

第六步,写最小复现测试。测试应证明被破坏的不变量,而不只是断言某次调用成功。

第七步,修复后运行单元测试、模糊测试和不变量测试,并对修改前后的存储写入、事件和错误路径做差异检查。

九、风险与注意事项

不要把 transfer 或 send 当作通用的重入修复。依赖固定 gas stipend 可能造成兼容性和可用性问题,网络规则与接收合约逻辑也可能变化。更稳妥的原则仍是先更新状态,再执行外部调用,并在必要时加锁。

重入锁还可能引入接口组合问题。一个带锁的 external 函数若直接调用另一个同样带锁的函数,可能意外触发自身保护。常见处理方式是让外部入口加锁,把共享逻辑放入 internal 函数,但必须再次确认内部逻辑不会绕开必要的权限检查。

对于 ERC-777、ERC-721 safeTransfer、ERC-1155 safeTransfer 和支持回调的 DeFi 协议,表面上没有低级 call 也不代表没有控制权转移。审计应根据接口语义判断是否存在回调。

任何安全测试都应限制在本地环境、公开授权靶场或明确书面授权范围内。不要把主网地址、真实私钥或生产 RPC 写入复现脚本。对公开项目发现疑似问题时,应优先遵循其漏洞披露和赏金计划规则。

十、常见问题

问:只要把状态更新移到 call 前面就绝对安全吗?
答:不能这样概括。CEI 能消除大量经典重入窗口,但仍需检查跨函数共享状态、代理调用、回调读取中间价格、权限变化以及外部协议组合。修复结论应由具体不变量和测试支撑。

问:使用 Solidity 0.8 是否自动避免重入?
答:不会。0.8 默认算术检查可能让某些旧利用脚本回滚,但控制流重入和重复记账问题仍然存在。

问:静态分析没有告警是否代表安全?
答:不代表。工具可能无法理解复杂业务不变量、代理结构、内联汇编和跨合约状态。静态分析适合发现候选点,最终仍需人工追踪与动态测试。

问:重入保护是否应加在所有函数上?
答:不应机械添加。应优先覆盖存在外部交互且会修改关键状态的入口,并分析函数间调用关系。过度加锁可能破坏合法组合调用,也不能修复错误的会计模型。

十一、总结

重入审计的核心不是搜索某个危险函数名,而是识别“状态尚未稳定时,控制权是否交给了不受信任对象”。一套可复核的流程应包括资产与权限建模、入口和交互点枚举、关键不变量定义、本地最小复现、CEI 与互斥锁修复,以及单元测试、模糊测试和不变量测试的回归验证。只有同时证明漏洞为什么成立、修复为什么有效、正常业务为什么没有被破坏,审计结论才具备可操作性。
 
顶部