查询 24 次,耗时 0.4498 秒

从重入漏洞到资产不变量:使用 Foundry 审计与加固 Solidity 金库合约

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
主题
13
点赞
0
HakB
1 月
在社区成长 1 月
LEVEL 1 1 XP
 
## 一、背景

重入漏洞是智能合约安全中最典型、也最容易被低估的一类问题。很多开发者知道“先修改状态,再发送 ETH”的检查—生效—交互(Checks-Effects-Interactions,CEI)原则,但真实审计并不只是查找 `call` 后面的状态更新。跨函数重入、只读重入、代币回调、代理合约存储布局以及错误的资产记账,都可能让形式上符合 CEI 的代码依然失守。

本文以本地 Foundry 靶场为例,演示如何审计一个 ETH 金库:先建立资产不变量,再复现漏洞,最后通过状态更新、重入锁、拉取式结算和测试用例完成加固。所有命令与代码仅用于本地实验、授权审计和防御验证,不应对未获授权的链上合约进行测试。

## 二、漏洞原理

金库通常维护两套数据:合约实际持有的 ETH,以及内部账本记录的用户余额。如果提现函数先把 ETH 交给外部地址,再扣减内部余额,接收方可在 `receive()` 或 `fallback()` 中再次调用提现函数。此时第一次调用尚未完成,内部余额仍是旧值,攻击合约便能重复提款。

需要重点检查的不只是某个函数,而是以下三个不变量:

1. 单个用户可提取金额不得超过其已确认余额;
2. 所有用户账面余额之和不应大于合约可支配资产;
3. 一次提现完成后,用户余额与合约资产应按同一金额同步减少。

如果合约还接收强制转入的 ETH、收益、手续费或 ERC-4626 份额,`address(this).balance == totalLiabilities` 未必成立。更稳妥的不变量通常是 `address(this).balance >= totalLiabilities`,并将多余部分定义为储备或待分配收益。

## 三、漏洞成立的前置条件

典型重入需要同时满足:

- 合约向不可信地址执行外部调用,例如低级 `call`、ERC-777 回调或自定义钩子;
- 关键余额、额度或状态在外部调用之后才更新,或者另一个入口能读取到尚未更新的状态;
- 接收方可以在回调中重新进入原函数或相关函数;
- 金库中存在足够资产,且没有有效的互斥锁、提款限额或状态机约束。

审计时不能因为使用了 Solidity 0.8.x 就排除重入。0.8.x 的算术检查能阻止部分下溢,但不能阻止外部回调;交易最终是否回滚还取决于具体代码和调用路径。

## 四、本地实验环境

安装 Foundry 后,在独立目录创建项目:

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

建议固定编译器与依赖版本,避免测试结果受自动升级影响。`foundry.toml` 可配置为:

```toml
[profile.default]
src = "src"
test = "test"
solc_version = "0.8.24"
optimizer = true
optimizer_runs = 200
```

## 五、存在缺陷的示例

在 `src/VulnerableVault.sol` 中写入:

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

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

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

function withdraw(uint256 amount) external {
require(balanceOf[msg.sender] >= amount, "insufficient");

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

balanceOf[msg.sender] -= amount;
totalLiabilities -= amount;
}
}
```

问题位于 `withdraw`:外部调用发生时,`balanceOf[msg.sender]` 与 `totalLiabilities` 尚未扣减。接收方可利用回调观察并重复使用旧余额。

## 六、分步骤审计与验证

### 第一步:标记所有外部交互

搜索低级调用、代币转账和可触发钩子的接口:

```bash
rg '\.call|delegatecall|staticcall|safeTransfer|safeTransferFrom|transferFrom' src
```

不要只看 ETH 转账。ERC-777、ERC-721 安全转账、自定义 `onDeposit` 钩子以及恶意 ERC-20 都可能把控制权交给外部合约。`view` 函数也可能在重入期间返回临时状态,进而影响依赖该价格或份额的其他协议。

### 第二步:绘制状态变化顺序

对每个资金出口记录:调用者校验、额度校验、内部记账、外部调用、事件与最终断言。若外部调用位于关键记账之前,应视为高优先级问题。还应检查是否存在 `withdrawAll()`、`claim()`、`redeem()` 等共享同一余额但未共用锁的入口。

### 第三步:编写最小化本地回归测试

测试目标不是攻击真实资产,而是证明不变量可以在本地被破坏。测试应至少覆盖:普通用户提款、回调接收方、余额不足、外部调用失败以及多入口组合。运行命令:

```bash
forge test -vvv
```

若需要观察具体调用顺序,可提高日志级别:

```bash
forge test --match-test test_ReentrantWithdraw -vvvv
```

测试断言应围绕结果,而不是只断言“某个调用成功”。例如,检查攻击前后金库余额、攻击合约净增资产、用户账面余额以及 `totalLiabilities` 是否一致。

### 第四步:修复状态更新顺序

最直接的修复是先扣减账本,再执行外部调用:

```solidity
function withdraw(uint256 amount) external {
require(amount > 0, "zero amount");
require(balanceOf[msg.sender] >= amount, "insufficient");

balanceOf[msg.sender] -= amount;
totalLiabilities -= amount;

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

assert(address(this).balance >= totalLiabilities);
}
```

如果发送失败,`require(ok)` 会回滚整笔交易,之前的余额扣减也会一并回滚,因此不会无故吞掉用户余额。这里的 `assert` 用于表达内部不变量;生产合约是否保留,应结合 gas、强制转账和业务资产模型评估。

### 第五步:增加互斥锁作为纵深防御

对于存在多个资金入口、复杂继承或未来可能扩展的合约,可以使用 OpenZeppelin `ReentrancyGuard`:

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

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

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

function withdraw(uint256 amount) external nonReentrant {
require(amount > 0, "zero amount");
require(balanceOf[msg.sender] >= amount, "insufficient");

balanceOf[msg.sender] -= amount;
totalLiabilities -= amount;

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

重入锁不能替代正确记账。它主要防止同一锁域内再次进入,无法自动修复权限绕过、价格操纵、代理存储冲突或异步跨交易问题。还需注意:两个 `nonReentrant` 外部函数不能在内部直接互相调用。常见做法是把核心逻辑拆成 `private` 函数,再由受保护的外部入口调用。

### 第六步:加入模糊测试和不变量测试

仅测试固定金额容易遗漏零值、边界值和多用户交错操作。可以让 Foundry 随机执行存取款,并持续验证:

```solidity
function invariant_Solvency() public view {
assert(address(vault).balance >= vault.totalLiabilities());
}
```

若业务允许管理员提取手续费,应将“用户负债”和“协议收入”分开记账。否则管理员提款可能在不触发单用户余额错误的情况下破坏整体偿付能力。

## 七、方案评审中的关键风险

1. **跨函数重入**:给 `withdraw` 加锁但遗漏 `claimReward`,而两者共享余额或奖励状态,仍可能被组合调用。
2. **只读重入**:某协议在外部调用期间暴露临时价格,另一个协议读取该价格并据此放贷。读取函数本身不写状态,不代表没有风险。
3. **升级代理**:引入 `ReentrancyGuard` 会增加存储变量。升级合约必须核对存储布局,不能覆盖原有槽位;可使用适用于代理的初始化版本,并运行存储布局检查。
4. **拒绝服务**:直接向一组地址循环转账,任一接收方回滚都可能阻断全体结算。批量分发宜采用拉取式领取,并限制循环规模。
5. **非常规代币**:部分 ERC-20 不返回布尔值、收取转账税或会重基准。应使用安全封装,并根据实际到账量记账,而不是盲信参数金额。
6. **强制 ETH 转入**:合约可能通过自毁遗留机制或其他协议结算收到非预期 ETH,因此不应轻率使用严格相等的不变量。
7. **`tx.origin` 鉴权**:不要用 `tx.origin` 判断提款者。权限校验应基于 `msg.sender`、角色控制或经过验证的签名与 nonce。

## 八、常见问题

**问:把 `call` 改成 `transfer` 是否足够?**

不建议把固定 gas 津贴当作安全边界。EVM gas 成本可能变化,而且 `transfer` 可能导致某些智能钱包无法收款。正确方案仍是先更新状态、检查返回值、必要时加重入锁,并考虑拉取式支付。

**问:CEI 与 `nonReentrant` 是否二选一?**

不是。CEI 保证局部状态顺序正确,重入锁提供额外互斥保护。资金类合约通常应同时采用,并用不变量测试验证整体偿付能力。

**问:为何不能只运行静态扫描器?**

静态工具擅长发现可疑模式,但不了解全部业务语义,可能出现误报和漏报。可靠流程应包括人工梳理资产流、权限与信任边界,结合静态分析、单元测试、模糊测试、不变量测试和独立复核。

**问:上线前还要检查什么?**

至少检查编译器与依赖锁定、管理员和暂停权限、代理升级路径、签名重放保护、预言机时效、滑点限制、紧急提款、事件完整性及部署参数。高价值合约还应进行独立审计,并准备监控、暂停和事件响应方案。

## 九、总结

重入漏洞的核心并不是某一行 `call`,而是外部控制权转移时,合约是否暴露了尚未完成的状态。有效审计应从资产与负债模型出发,为每个资金出口建立不变量,再检查外部交互、跨函数共享状态和异常回滚路径。修复时优先采用 CEI,辅以重入锁、拉取式支付和明确的负债记账,最后通过普通测试、回调测试、模糊测试与不变量测试形成可重复验证的证据。只有把“代码模式检查”提升为“系统偿付能力验证”,才能覆盖真实项目中更复杂的重入与会计风险。
 
顶部