查询 36 次,耗时 0.6351 秒

从漏洞复现到回归测试:智能合约重入攻击的系统审计与修复指南

speedhacker

New member

speedhacker Rep
0
0
0
Rep
0
speedhacker Vouches
Vouches
0
5 MONTHS
5 5 MONTHS OF SERVICE
speedhacker 获得
0
0
0
获得
0
speedhacker 凭证
0
0
0
凭证
0
主题
13
点赞
0
HakB
5 月
在社区成长 5 月
LEVEL 100 XP
 
一、背景

重入漏洞是智能合约安全审计中的经典问题,但它并没有随着 Solidity 编译器和常用安全库的完善而消失。现代项目中的重入风险不再局限于“提款函数先转账、后清零”这一种形式,还可能出现在跨函数调用、多个合约共享状态、ERC-777 回调、NFT 安全接收回调、闪电贷组合调用,以及只读函数被外部协议用作价格或份额依据等场景。

本文以本地授权实验为边界,使用 Foundry 搭建一个最小化靶场,演示如何识别、复现和修复典型重入漏洞,并补充审计时容易遗漏的检查项。所有命令仅面向本地开发链和自有代码,不应对未经授权的链上合约进行测试。

二、漏洞原理

EVM 执行外部调用时,会暂时把控制权交给被调用地址。如果当前合约在外部调用前尚未完成关键状态更新,接收方就可能在 fallback 或 receive 函数中再次调用原函数。由于上一层调用仍未更新余额,重复调用会继续通过余额检查。

典型危险顺序如下:

1. 检查用户余额;
2. 向用户控制的地址发送 ETH 或调用外部合约;
3. 在外部调用返回后才更新余额。

安全审计不能只搜索 call{value: ...}。transfer、safeTransfer、safeTransferFrom、ERC-777 的 tokensReceived、ERC-721/ERC-1155 的安全接收回调,以及协议自定义 hook,都可能形成控制流转移。即使外部调用不发送 ETH,只要它能执行不可信代码,就要评估重入窗口。

三、实验前置条件

建议在隔离目录中完成实验,需要:

1. Git;
2. Foundry 工具链,包括 forge、cast 和 anvil;
3. 基本 Solidity 与 EVM 调用栈知识;
4. 仅使用本地测试账户,不导入主网私钥;
5. Solidity 0.8.x。该版本会检查整数溢出,但不会自动阻止重入。

初始化项目:

forge init reentrancy-lab
cd reentrancy-lab
forge install OpenZeppelin/openzeppelin-contracts --no-commit

如果当前 Foundry 版本不支持 --no-commit,可直接执行 forge install OpenZeppelin/openzeppelin-contracts,并根据团队的依赖管理方式提交子模块或锁定版本。生产项目应固定依赖版本,避免审计结果因上游代码变化而失效。

四、构造本地漏洞靶场

在 src/VulnerableVault.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 withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");

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

balances[msg.sender] = 0;
}
}

问题位于 withdraw:余额清零发生在外部调用之后。攻击合约收到 ETH 时,可以在 receive 中重新进入 withdraw。需要注意,这里使用 Solidity 0.8.x 并不能提供保护,因为问题属于调用顺序和状态机设计缺陷,而非算术溢出。

五、编写可重复的本地测试

在 test/Reentrancy.t.sol 中创建测试。为了让测试结果稳定,建议显式设置参与者余额,并断言金库和测试合约的余额变化,而不是只观察交易是否成功。

pragma solidity ^0.8.24;

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

contract ReenteringReceiver {
VulnerableVault public immutable vault;
uint256 public rounds;

constructor(VulnerableVault _vault) {
vault = _vault;
}

function seedAndStart() external payable {
require(msg.value == 1 ether, "need 1 ether");
vault.deposit{value: 1 ether}();
vault.withdraw();
}

receive() external payable {
if (address(vault).balance >= 1 ether && rounds < 3) {
rounds++;
vault.withdraw();
}
}
}

contract ReentrancyTest is Test {
VulnerableVault vault;
ReenteringReceiver receiver;
address user = address(0xBEEF);

function setUp() public {
vault = new VulnerableVault();
receiver = new ReenteringReceiver(vault);
vm.deal(user, 10 ether);
vm.deal(address(this), 10 ether);

vm.prank(user);
vault.deposit{value: 4 ether}();
}

function test_ReentrancyDrainsSharedLiquidity() public {
receiver.seedAndStart{value: 1 ether}();

assertEq(address(vault).balance, 1 ether);
assertEq(address(receiver).balance, 4 ether);
assertEq(vault.balances(user), 4 ether);
}
}

执行:

forge test -vvv

需要特别理解测试断言的含义:账本仍声称普通用户拥有 4 ETH,但合约实际只剩 1 ETH,形成资不抵债。漏洞影响通常不只是攻击者取回自己的存款,而是攻击者利用自己的合法余额,多次提取由其他用户提供的共享流动性。

如需查看更完整的调用轨迹,可运行:

forge test --match-test test_ReentrancyDrainsSharedLiquidity -vvvv

审计时应在轨迹中确认 withdraw 是否在前一次 withdraw 完成前再次出现,以及 balances 清零发生在哪一层调用返回之后。

六、第一层修复:检查—生效—交互

最基础的修复是采用 Checks-Effects-Interactions,即先检查条件,再完成内部状态变更,最后执行外部交互:

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

balances[msg.sender] = 0;

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

如果发送失败,require 会使整笔交易回滚,之前的余额清零也会被撤销,因此不会造成用户余额凭空丢失。修复后,回调中的第二次 withdraw 会因为余额已经为零而失败。

不过,单纯调整顺序并非所有系统都足够。如果一次操作涉及多个状态变量、多个资产或多个合约,需要保证所有安全不变量都在第一次外部调用前恢复。例如赎回份额时,应同时处理用户份额、总份额、待领取金额和相关会计缓存,不能只更新其中一个字段。

七、第二层修复:增加重入锁

可以使用 OpenZeppelin 的 ReentrancyGuard 作为纵深防御:

pragma solidity ^0.8.24;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

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

function deposit() external payable {
require(msg.value > 0, "zero deposit");
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, "send failed");
}
}

重入锁不能代替正确的状态更新顺序。原因包括:某些函数可能遗漏 nonReentrant;多个入口可能共享状态;升级过程中可能引入新的外部调用;某些只读重入和跨合约重入也不是给单一函数加锁就能彻底解决。更可靠的做法是先恢复状态机不变量,再使用锁降低未来改动引入漏洞的概率。

对于可升级合约,不能直接照搬普通合约的构造函数式实现。应选用与代理模式匹配的 upgradeable 版本,执行初始化函数,并检查存储布局。升级前还要确认新增的重入锁状态变量不会覆盖既有存储槽。

八、补充回归测试与不变量测试

修复后至少应添加以下回归断言:

1. 单个用户正常存取成功;
2. 接收方拒绝 ETH 时,余额记录不会被永久清除;
3. 回调尝试重入时,最多只能领取一次;
4. 多用户场景下,一个用户无法减少其他用户的可兑付资产;
5. 合约资产应覆盖全部用户负债,或满足协议明确定义的会计关系。

可以将核心不变量写为:

address(vault).balance >= sumOfRecordedUserBalances

真实项目中未必能直接遍历 mapping,因此测试模型可以维护参与测试地址列表,或者在合约中记录 totalLiabilities:存款时增加,提款时减少。随后使用 Foundry invariant testing 对 deposit、withdraw 和回调接收器进行随机调用,检查合约余额与总负债始终一致。

可先运行静态分析辅助筛查:

slither src/SafeVault.sol --detect reentrancy-eth,reentrancy-no-eth

静态分析结果不能直接等同于漏洞结论。工具可能因状态已提前更新而误报,也可能无法理解跨合约会计约束而漏报。正确流程应是:工具定位可疑外部调用,人工梳理控制流与不变量,再用最小化测试验证可利用性和实际影响。

九、审计时的分步骤检查清单

第一步,列出全部外部交互点。包括低级 call、delegatecall、staticcall、代币转账、NFT 安全转账、预言机调用、桥接消息、DEX 回调和协议 hook。

第二步,标记交互前后读写的状态。重点关注余额、份额、抵押率、已领取标志、nonce、限额、价格缓存和总供应量。

第三步,枚举可重入入口。不要只检查当前函数,还要检查攻击者能否从回调进入另一个公开函数,并借助共享状态获利,这属于跨函数重入。

第四步,建立安全不变量。例如总资产不小于总负债、同一凭证只能结算一次、一次提案只能执行一次、铸币量与收到的资产匹配。

第五步,构造最小复现。测试应限制回调轮数,避免因无限递归或 gas 耗尽掩盖真正问题;同时记录攻击前后各参与者资产和内部账本。

第六步,完成修复后运行原始利用测试。高质量回归测试应证明旧攻击路径失败,同时正常提款仍可用,而不是简单期待所有交易都回滚。

十、风险与注意事项

1. 不要把 transfer 的 2300 gas 补贴当作长期安全边界。EVM gas 成本可能变化,而且协议可能需要兼容智能合约钱包。应通过状态机设计防御,而不是依赖 gas 限制。
2. 使用 ReentrancyGuard 时,两个 nonReentrant 外部函数不能直接互相调用。常见做法是让外部入口调用 private 内部函数,但仍要核对共享状态。
3. ERC-777、ERC-721 和 ERC-1155 回调可能在表面上普通的转账中执行接收方代码。
4. delegatecall 会在调用方存储上下文中执行代码,风险不仅是重入,还包括任意状态修改和存储碰撞。
5. 只读重入不会直接修改被重入合约,却可能让外部协议在中间状态读取错误价格、份额或储备量。审计组合协议时尤其需要检查。
6. 修复不能只覆盖示例入口。如果 emergencyWithdraw、claim、redeem、liquidate 等函数操作同一份会计状态,也必须一起评审。
7. 测试私钥、助记词和 RPC 凭据不应写入仓库。即使测试目标是本地链,也要防止脚本误连主网。

十一、常见问题

问:Solidity 0.8 已经有溢出检查,为什么仍会重入?
答:溢出检查解决的是算术边界问题;重入源于外部控制流和状态更新时序,两者没有直接关系。

问:只加 nonReentrant 是否足够?
答:通常不建议把它视为唯一修复。应先按检查—生效—交互重构,并检查跨函数、跨合约和只读重入。重入锁更适合作为纵深防御。

问:为什么不直接禁止合约地址提款?
答:通过 tx.origin 或代码长度识别合约既不可靠,也会破坏智能钱包和账户抽象兼容性。构造期间代码长度可以为零,地址形态不是可信安全边界。

问:外部调用失败后,先清零是否会造成资金损失?
答:如果随后使用 require 回滚整个交易,状态修改会一并回滚。但若代码只记录失败而不回滚,或使用 try/catch 吞掉异常,就必须明确设计补偿和重新领取机制。

问:静态分析没有告警,是否可以认为安全?
答:不能。工具难以完整推导业务不变量、代理结构和跨协议调用。静态分析应与人工审计、单元测试、模糊测试、不变量测试和部署配置复核结合。

十二、总结

重入漏洞的本质不是某一个危险语法,而是“不可信外部代码获得控制权时,合约是否已经恢复到一致状态”。有效审计应从外部调用点出发,梳理所有可重入入口和共享状态,明确资产与负债等关键不变量,再通过本地最小复现验证影响。

修复时优先采用检查—生效—交互模式,必要时增加成熟的重入锁,并为跨函数、回调代币、失败转账及多用户会计场景添加回归测试。最后使用静态分析和不变量测试扩大覆盖面。只有原始攻击路径被稳定阻断、正常业务仍可执行、核心会计关系持续成立,才能认为修复真正闭环。
 

3100953

New member

3100953 Rep
0
0
0
Rep
0
3100953 Vouches
Vouches
0
2 MONTHS
2 2 MONTHS OF SERVICE
3100953 获得
0
0
0
获得
0
3100953 凭证
0
0
0
凭证
0
主题
14
点赞
0
HakB
2 月
在社区成长 2 月
LEVEL 50 100 XP
 
建议把修复后的回归测试从“攻击失败”提升为“不变量保持”:用 Foundry invariant handler 随机组合 deposit、withdraw 与回调入口,持续断言 address(vault).balance >= 总负债。若协议包含份额、待领取额或跨合约记账,应将这些字段统一纳入负债计算。此外,ReentrancyGuard 只能覆盖加锁入口;审计时要检查未加锁函数是否能在回调期间读取或修改同一状态,避免跨函数重入绕过。
 
顶部