查询 25 次,耗时 0.2340 秒

授权本地靶场复盘:用 Foundry 验证 Solidity 重入风险与修复边界

xiaobinz08

New member

xiaobinz08 Rep
0
0
0
Rep
0
xiaobinz08 Vouches
Vouches
0
1 MONTH
1 1 MONTH OF SERVICE
xiaobinz08 获得
0
0
0
获得
0
xiaobinz08 凭证
0
0
0
凭证
0
主题
6
点赞
0
HakB
1 月
在社区成长 1 月
LEVEL 1 1 XP
 
## 背景

重入漏洞常被简化成“外部调用后再改余额”,但审计时真正需要验证的是:外部调用期间,合约是否处于可被重复利用的中间状态;重复进入能否造成超额提款、重复结算或状态错乱;修复是否覆盖所有相关入口。本文使用 Foundry 在本地搭建一个最小靶场,只验证自有示例合约,不连接真实网络,也不针对任何线上目标。

## 原理与风险边界

EVM 执行外部合约调用时,被调用方可以在返回前再次调用原合约。若原合约先向外转账,后更新余额,接收方的 `receive` 函数就可能在余额尚未扣除时再次提款。问题不在于“外部调用一定危险”,而在于调用边界前后的状态不变量没有得到维护。

本文关注的核心不变量是:用户可提取金额不得超过其记录余额,且一次提款只能消耗一次对应余额。重入只是触发该不变量破坏的一种路径。实际审计还应考虑继承函数、回调接口、代币钩子、跨函数重入、权限检查顺序及合约升级后的存储布局等因素,不能仅凭一个防重入修饰器就认定安全。

## 前置条件

1. 安装 Foundry,并确认 `forge --version` 可正常运行。
2. 在隔离目录创建工程:`forge init reentrancy-lab`,进入目录后使用 `forge test` 运行测试。
3. 所有测试均在本地 EVM 执行;不要把示例地址、攻击合约或测试脚本改用于未获授权的系统。

## 分步骤实践

### 第一步:建立有缺陷的最小合约

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

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

contract Vault {
mapping(address => uint256) public credit;

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

function withdraw() external {
uint256 amount = credit[msg.sender];
require(amount > 0, "empty");
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "send failed");
credit[msg.sender] = 0;
}
}
```

这里的危险顺序是先执行 `call`,再把 `credit` 清零。`call` 会把控制权交给接收方,因此在它返回之前,原合约仍认为用户有完整余额。`require(ok)` 只能检查转账是否成功,不能阻止接收方再次进入 `withdraw`。

### 第二步:创建仅供本地测试的回调合约

在 `test/Reentrancy.t.sol` 中加入测试。以下合约只用于验证本地示例的控制流:

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

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

contract LocalReceiver {
Vault public vault;
uint256 public entries;

constructor(Vault v) { vault = v; }

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

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

contract ReentrancyTest is Test {
Vault vault;
LocalReceiver receiver;

function setUp() public {
vault = new Vault();
receiver = new LocalReceiver(vault);
vm.deal(address(this), 10 ether);
vault.deposit{value: 5 ether}();
vm.deal(address(receiver), 1 ether);
}

function testLocalReentryDemonstration() public {
receiver.start{value: 1 ether}();
assertGt(receiver.entries(), 1);
}

receive() external payable {}
}
```

运行 `forge test -vv`,观察回调次数。测试的目的不是展示对外攻击步骤,而是通过断言证明外部调用期间确实发生了重复进入。若测试因余额或编译器配置不同而失败,应先检查 Solidity 版本、测试账户余额和调用路径,不要把单次测试结果外推为对所有实现的结论。

### 第三步:按检查—效果—交互顺序修复

一种基础修复是在转账前更新状态:

```solidity
function withdraw() external {
uint256 amount = credit[msg.sender];
require(amount > 0, "empty");
credit[msg.sender] = 0;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
```

这符合 Checks-Effects-Interactions 思路:先检查条件,再完成内部状态变更,最后进行外部交互。如果转账失败,`require` 会使整笔交易回滚,余额更新也随之回滚。对复杂系统,还可以采用经过审查的重入锁,并在测试中覆盖同一函数重入和跨函数重入;锁是纵深防御,不应代替正确的状态顺序。

### 第四步:验证修复而非只看代码

将测试改成断言修复后的余额与资金关系,例如确认提款后 `credit[address(receiver)] == 0`,且回调未能造成重复提款。再补充失败路径测试:接收方主动回滚时,交易应按预期回滚;普通用户存款、提款仍可正常工作。建议使用 `forge test` 做回归,必要时增加模糊测试,对多用户、多次存取款序列检查“合约余额与账面负债关系”等不变量。测试不应只断言某个攻击示例失败,还要确认正常业务未被修复误伤。

## 审计时的可操作检查清单

- 搜索 `call`、`delegatecall`、代币转账及带回调接口,逐一标出控制权交接点。
- 对每个交接点,记录调用前后的余额、份额、状态标志和权限状态。
- 检查被调用方能否直接或间接重新进入当前合约的其他敏感函数。
- 核对失败回滚语义、事件发出顺序及外部依赖的实际行为。
- 用最小复现测试证明问题条件,再用修复后回归测试证明相关不变量成立。
- 报告结论写明影响条件、受影响资产或状态、复现环境和未覆盖假设,避免把“未复现”表述成“绝对安全”。

## 风险与注意事项

示例使用原生币与简化账本,不能代表所有代币和协议的行为。某些代币存在回调、非标准返回值或特殊转账语义;代理合约还涉及实现合约、初始化和存储布局。重入锁可能因继承、函数组合或锁粒度设计不当而引入可用性问题。测试通过只说明已覆盖路径下的行为符合断言,不等于完成全面审计。所有验证应限于自建代码、本地链或明确授权的环境,并避免把测试资金、私钥或生产配置放进公开仓库。

## 常见问题

**Q:使用 `transfer` 是否就能彻底避免重入?** 不能把安全性建立在固定转发 gas 的假设上;接收方行为和链上执行环境可能变化。优先保证状态更新顺序,并针对实际依赖设计测试。

**Q:加了重入锁就不用检查其他入口了吗?** 不是。若相关函数没有共享同一保护边界,或重入路径进入另一函数,仍可能破坏不变量。应从状态与调用图出发验证。

**Q:测试里没有复现,是否说明没有漏洞?** 不能。测试受用例、状态空间和依赖模型限制。需要结合静态审查、调用关系分析、模糊测试和业务不变量检查。

**Q:修复后应怎样写审计结论?** 说明原始缺陷、触发条件、受影响状态、修复方式及回归证据,并明确测试范围和剩余假设,不使用超出证据的绝对化措辞。

## 总结

重入审计的重点不是寻找某个固定代码模板,而是识别外部调用造成的控制权交接,并逐项验证状态不变量是否在交接期间仍成立。以本地靶场建立最小复现、按 Checks-Effects-Interactions 修复、再通过正常路径与失败路径回归,可以形成可复核的证据链。对真实项目,结论必须结合完整调用图、依赖行为和授权范围,不能用单一测试或单个防护手段替代审计。
 
顶部