查询 25 次,耗时 0.2261 秒

从重入漏洞到资产不变量:用 Foundry 审计并加固一个以太坊资金库合约

Likccc

New member

Likccc Rep
0
0
0
Rep
0
Likccc Vouches
Vouches
0
1 MONTH
1 1 MONTH OF SERVICE
Likccc 获得
0
0
0
获得
0
Likccc 凭证
0
0
0
凭证
0
主题
7
点赞
0
HakB
1 月
在社区成长 1 月
LEVEL 1 1 XP
 
一、背景

资金托管、质押池、收益金库和跨链桥合约通常都包含“用户存入资产、合约记录余额、用户再取回资产”这一基本流程。代码看起来并不复杂,但只要状态更新、外部调用和权限控制的顺序处理不当,就可能出现重入、账实不符、资金锁死或越权提取等严重问题。

本文以一个本地以太坊资金库 Vault 为例,演示如何建立审计模型、识别重入漏洞、编写回归测试并完成修复。所有命令和测试仅用于本地实验、授权靶场及防御性审计,不应对未经授权的真实合约进行测试。

二、漏洞原理

下面是一个典型的错误提款逻辑:

pragma solidity ^0.8.24;

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

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

function withdrawAll() 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;
}
}

问题集中在 withdrawAll:合约先向 msg.sender 转账,之后才清零余额。如果接收方是合约,那么低级 call 会执行接收方的 receive 或 fallback。接收方可以在原始 withdrawAll 尚未完成时再次调用 withdrawAll,此时 balances[msg.sender] 仍然是旧值。

这里需要注意,重入并不只等于“重复提款”。更一般的定义是:合约在关键状态尚未稳定时,把控制权交给不可信外部地址,外部地址又回调当前合约或相关合约,从而观察或修改中间状态。常见形式包括:

1. 单函数重入:反复进入同一个提款函数。
2. 跨函数重入:提款回调期间进入 transfer、claim 或 borrow 等其他函数。
3. 跨合约重入:多个协议组件共享同一份资产或记账状态。
4. 只读重入:不直接盗取资产,但在中间状态下读取价格、份额或余额,影响其他协议决策。
5. 代币回调重入:ERC777 hooks、安全转账接收器或自定义代币在转账期间执行回调。

三、前置条件与实验环境

建议使用隔离目录和测试账户,不要导入包含真实资产的私钥。本文示例需要:

1. Foundry,用于编译、单元测试、模糊测试和本地 EVM 执行。
2. Solidity 0.8.24 或经过项目验证的兼容版本。
3. OpenZeppelin Contracts,用于 ReentrancyGuard。
4. 可选的 Slither,用于静态分析。

初始化本地项目:

forge init vault-audit
cd vault-audit
forge install OpenZeppelin/openzeppelin-contracts --no-commit
forge build

不同版本的 OpenZeppelin 目录可能不同。OpenZeppelin Contracts 5.x 中 ReentrancyGuard 通常位于 contracts/utils;较旧版本可能位于 contracts/security。审计时必须固定依赖版本,并以实际安装内容为准,不要仅凭网上示例修改 import。

四、先建立资产模型和安全不变量

审计资金合约时,不应只搜索某一种漏洞。首先应回答四个问题:

1. 合约保管什么资产,资产通过哪些入口进入?
2. 哪些地址有权改变用户余额、总负债和资产接收方?
3. 哪些语句会把控制权交给外部合约?
4. 每笔交易完成后必须满足什么条件?

对于本例,可以定义以下核心不变量:

address(vault).balance >= totalLiabilities

totalLiabilities 表示合约承诺给所有用户的以太币总额。这里使用“大于等于”而不是严格相等,是因为以太币可能通过强制转入等方式进入合约,却没有对应的用户记账。若错误地要求两者始终相等,可能导致正常提款被意外阻断。

还应检查以下业务约束:

1. 用户不能提取超过自身余额的金额。
2. 成功提款后,用户余额和总负债必须同步减少。
3. 外部转账失败时,整笔交易应回滚,不能只减少账面余额。
4. 任意回调都不能使同一笔余额被重复使用。
5. 不存在未经说明的管理员任意划走用户资金能力。

五、分步骤修复

步骤一:采用检查—生效—交互模式

Checks-Effects-Interactions,简称 CEI。先检查输入和权限,再完成内部状态更新,最后执行外部调用。这样即使发生回调,外部调用看到的也是已经扣减后的余额。

步骤二:增加重入锁

CEI 是基础措施,但复杂项目容易在新增函数时破坏原有假设。ReentrancyGuard 可以作为第二层防线。需要注意:它只能保护添加了 nonReentrant 的入口,不能自动修复未加修饰器的相关函数,也不能代替业务不变量分析。

步骤三:显式维护总负债

只记录用户余额而不记录总负债,会增加审计和监控难度。维护 totalLiabilities 后,可以编写不变量测试,并在链下监控偿付能力。

步骤四:使用自定义错误和事件

错误应区分零金额、余额不足和转账失败。存款、提款事件则便于监控异常交易和核对账目。

修复后的示例:

pragma solidity ^0.8.24;

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

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

error ZeroAmount();
error InsufficientBalance(uint256 available, uint256 requested);
error NativeTransferFailed();

event Deposited(address indexed account, uint256 amount);
event Withdrawn(address indexed account, uint256 amount);

function deposit() external payable {
if (msg.value == 0) revert ZeroAmount();

balances[msg.sender] += msg.value;
totalLiabilities += msg.value;
emit Deposited(msg.sender, msg.value);
}

function withdraw(uint256 amount) external nonReentrant {
if (amount == 0) revert ZeroAmount();

uint256 available = balances[msg.sender];
if (amount > available) {
revert InsufficientBalance(available, amount);
}

balances[msg.sender] = available - amount;
totalLiabilities -= amount;

(bool ok, ) = payable(msg.sender).call{value: amount}("");
if (!ok) revert NativeTransferFailed();

emit Withdrawn(msg.sender, amount);
}
}

如果最后的 call 失败,revert 会回滚此前对 balances 和 totalLiabilities 的修改,因此不会出现“扣账成功但用户没有收到资金”的永久状态。

六、编写本地回归测试

基本测试至少应覆盖正常提款、超额提款、转账失败和回调重入。下面的探针合约只用于验证修复效果:它在收到以太币时尝试再次提款,并捕获第二次调用的失败。

pragma solidity ^0.8.24;

interface IVault {
function deposit() external payable;
function withdraw(uint256 amount) external;
}

contract ReentrantProbe {
IVault public immutable vault;
bool public callbackEntered;
bool public secondWithdrawalSucceeded;

constructor(address vaultAddress) {
vault = IVault(vaultAddress);
}

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

receive() external payable {
callbackEntered = true;
try vault.withdraw(msg.value) {
secondWithdrawalSucceeded = true;
} catch {
secondWithdrawalSucceeded = false;
}
}
}

对应测试应验证三个结果:第一次提款成功、回调确实发生、第二次提款没有成功。同时确认资金库仍满足偿付能力不变量。

可执行以下命令:

forge build
forge test -vvv
forge test --match-test test_ReentrantCallbackCannotWithdrawTwice -vvvv

测试逻辑可以包含以下断言:

assertTrue(probe.callbackEntered());
assertFalse(probe.secondWithdrawalSucceeded());
assertEq(vault.balances(address(probe)), 0);
assertGe(address(vault).balance, vault.totalLiabilities());

还应增加模糊测试,对提款金额进行边界覆盖。例如先约束存款值,再随机选择不超过余额的提款值:

uint256 depositAmount = bound(rawDeposit, 1 wei, 100 ether);
uint256 withdrawAmount = bound(rawWithdraw, 1 wei, depositAmount);

测试完成后,应断言用户余额减少值、合约余额减少值和 totalLiabilities 减少值完全一致。不能只检查函数是否成功返回。

七、静态分析与人工复核

安装并运行 Slither 后,可以重点查看 reentrancy、unchecked-lowlevel、arbitrary-send-eth 和权限相关结果:

slither .
slither . --print call-graph
slither . --print function-summary

静态工具只能提供线索,不能替代人工确认。常见误区是看到“没有 reentrancy 告警”就认定安全。审计人员还需要人工列出所有外部交互点,包括:

1. call、delegatecall、staticcall。
2. ERC20、ERC721、ERC1155 和 ERC777 的转账接口。
3. 预言机、路由器、借贷池和跨链消息端点。
4. 可升级代理中的初始化与升级调用。
5. 任何用户可控地址上的接口调用。

随后检查这些调用前后的状态是否稳定,以及回调能否进入其他共享状态函数。

八、风险与注意事项

1. 不要把 transfer 或 send 当作根本修复。依赖固定 gas 津贴可能造成兼容性和拒绝服务问题,CEI、重入锁与正确记账更可靠。
2. nonReentrant 函数通常不能直接相互调用。常用做法是把核心逻辑放在 private 函数中,由不同 external nonReentrant 入口调用,但必须重新评估组合调用语义。
3. ERC20 并非都严格遵守标准。有些代币不返回布尔值,有些会收取转账费,还有些会触发回调。处理代币资金库时宜使用经过审查的 SafeERC20,并按实际到账数量记账。
4. 代理合约要额外审查初始化器是否可重复调用、实现合约是否被锁定、升级权限是否受多签或时间锁控制,以及存储布局是否兼容。
5. 紧急暂停只能限制损失,不能替代修复。暂停权限过于集中本身也是治理风险。
6. 管理员救援函数不得提走用户负债覆盖范围内的资产。若允许提取意外转入的以太币,可提额度最多应基于 address(this).balance - totalLiabilities,并同样做好权限、事件和重入保护。
7. 不要在主网分叉或公共测试网使用真实私钥执行攻击性验证。涉及现有协议时,应先取得明确书面授权并限定测试范围。

九、常见问题

问:已经先扣余额,是否还需要 ReentrancyGuard?
答:简单单函数场景中,正确的 CEI 往往能阻止重复提款,但实际项目可能存在跨函数共享状态、未来升级或遗漏入口。两者配合属于纵深防御,前提是不能因为有锁就忽略状态设计。

问:为什么不直接检查合约余额等于所有用户余额?
答:以太币可能被强制转入,合约余额因此可能高于账面负债。安全检查通常应是余额不低于负债。对于代币,还要考虑转账税、重基准和余额变化机制。

问:外部转账失败,而余额已经扣除了怎么办?
答:示例在失败时执行 revert。EVM 会回滚当前调用帧中的状态修改,所以扣账和总负债变化也会恢复。如果代码只是返回 false 而不回滚,则必须设计明确的补偿流程。

问:只写一个正常提款测试够吗?
答:不够。至少要覆盖零金额、余额边界、超额提款、恶意接收方回调、拒收以太币、连续多次操作和多用户交错操作。高价值协议还应加入状态机模糊测试和不变量测试。

问:静态分析结果很多,应该先看什么?
答:优先处理能够影响资产、权限和升级控制的问题,再处理拒绝服务、精度和事件完整性。每条告警都应关联具体调用路径和业务影响,避免机械地按工具等级排序。

十、总结

重入漏洞的本质不是某一行 call,而是关键状态尚未稳定时把执行权交给外部对象。可靠的审计流程应从资产和信任边界出发,定义可验证的不变量,再检查外部调用、状态更新、权限和失败路径。对于资金库类合约,建议同时落实 CEI、重入锁、总负债记账、事件监控、边界测试和不变量测试。

修复完成并不等于审计结束。依赖版本、代理升级、代币特性和后续新增入口都可能改变安全假设。将上述测试纳入持续集成,并在每次业务逻辑或依赖升级后重新执行,才能让安全要求从一次性检查变成可持续验证的工程约束。
 
顶部