3
月
在社区成长 3 月
LEVEL 1
1 XP
## 一、背景:为什么要把审计结论变成可执行测试
智能合约一旦部署,权限、资产和状态变化通常由链上代码直接约束。传统代码审查中“发现问题后再修复”的思路并不完全适用:合约可能难以升级,错误交易也无法简单撤销。因此,一次可靠的安全审计不应只交付漏洞列表,还应交付可复现用例、修复方案以及能够长期运行的回归测试。
本文以资金金库合约为本地实验对象,演示如何使用 Foundry 分析重入漏洞,并把人工审计、单元测试、模糊测试和不变量测试串成一套流程。所有操作仅面向自有代码和本地 Anvil 环境,不涉及真实目标。
## 二、漏洞原理与威胁模型
重入并不等于“调用了外部合约”。真正危险的是:合约在关键状态尚未更新时,将控制权交给不受信任的地址;对方在回调过程中再次进入原函数,使多次执行共享同一份过期状态。
下面是一个故意保留缺陷的简化金库:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract UnsafeVault {
mapping(address => uint256) public balanceOf;
function deposit() external payable {
balanceOf[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balanceOf[msg.sender] >= amount, "insufficient balance");
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balanceOf[msg.sender] -= amount;
}
}
```
问题位于 `withdraw`:先执行外部调用,后扣减内部余额。如果接收方是合约,其 `receive` 或 `fallback` 可以在余额尚未扣减时再次调用 `withdraw`。
需要强调,Solidity 0.8 的算术检查并不能从根本上解决该问题。某些重入路径最终可能因下溢而整体回滚,但安全性不能依赖偶然的回滚行为;一旦代码加入 `unchecked`、分支结算、积分记账、跨函数入口或其他资产来源,风险模型就会改变。
## 三、漏洞成立的前置条件
审计时应明确可利用条件,而不是只看到 `call` 就报告高危:
1. 存在向用户可控地址发起的外部调用;
2. 外部调用发生时,影响授权或结算的关键状态尚未更新;
3. 回调能够重新进入原函数,或进入共享同一状态的其他函数;
4. 合约中存在可被重复结算的资产、额度或权益;
5. 没有有效的互斥锁、状态机限制或其他等价防护;
6. 攻击路径可以在同一交易中完成,并且不会因后续检查而整体回滚。
跨函数重入尤其容易漏报。例如 `claim()` 和 `withdraw()` 分别看似安全,但如果二者共同依赖 `credit[msg.sender]`,其中一个函数外调期间可能通过另一个函数重复消费额度。
## 四、搭建本地审计环境
前置工具为 Git、Foundry 和一个仅用于实验的目录。安装 Foundry 时应从官方来源核对安装方式与版本,不要直接执行来源不明的脚本。
```bash
forge --version
anvil --version
mkdir vault-audit && cd vault-audit
forge init
forge build
```
建议固定编译器和依赖版本,避免审计结果随环境漂移。可在 `foundry.toml` 中设置:
```toml
[profile.default]
src = "src"
test = "test"
solc_version = "0.8.24"
optimizer = true
optimizer_runs = 200
fuzz_runs = 1000
```
将待审计合约放入 `src/UnsafeVault.sol`。运行:
```bash
forge fmt --check
forge build
forge test -vv
```
编译成功只说明语法和类型基本正确,并不意味着业务安全。
## 五、分步骤开展人工审计
### 第一步:建立资产与信任边界
先列出合约管理的资产:原生代币、ERC-20、NFT、份额凭证以及仅存在于映射中的内部信用。然后标记所有不可信边界,包括用户地址、外部代币、预言机、路由器、代理实现和管理员。
对金库而言,核心安全目标至少包括:
- 用户不能提取超过自身已记账余额的资产;
- 一次提款只能消费一次额度;
- 所有用户内部余额之和不应无故超过合约可支配资产;
- 单个恶意接收方失败,不应破坏其他用户的余额记录。
### 第二步:定位状态修改与外部调用
逐个函数标注读取、写入和外调位置。重点检索:
```bash
rg "\.call|delegatecall|staticcall|transferFrom|safeTransfer|safeMint" src test
```
不能只检查显式的低级 `call`。ERC-777 回调、ERC-721/1155 的安全转账、任意代币合约以及可升级代理都可能转移控制权。
### 第三步:绘制调用顺序
对 `withdraw` 可得到:
1. 读取 `balanceOf[msg.sender]`;
2. 检查额度;
3. 调用 `msg.sender`;
4. 扣减余额。
第三步和第四步顺序错误,违反 Checks-Effects-Interactions,即“检查—状态生效—外部交互”模式。
### 第四步:检查权限与可升级面
即使本例没有管理员,也应在实际项目中检查初始化函数能否重复调用、升级权限是否受多签和时间锁保护、实现合约是否被初始化、角色管理员关系是否正确,以及紧急暂停是否覆盖真正的资金出口。重入修复不能掩盖权限配置问题。
## 六、编写本地复现测试
测试目标不是攻击真实合约,而是证明“回调发生时关键状态仍是旧值”。可以创建一个受控接收合约,在回调里尝试二次进入,并记录观察结果:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {UnsafeVault} from "../src/UnsafeVault.sol";
contract ReenteringReceiver {
UnsafeVault public immutable vault;
bool public attempted;
uint256 public observedBalance;
constructor(UnsafeVault v) {
vault = v;
}
function deposit() external payable {
vault.deposit{value: msg.value}();
}
function begin(uint256 amount) external {
vault.withdraw(amount);
}
receive() external payable {
observedBalance = vault.balanceOf(address(this));
if (!attempted) {
attempted = true;
try vault.withdraw(msg.value) {} catch {}
}
}
}
contract UnsafeVaultTest is Test {
UnsafeVault vault;
ReenteringReceiver receiver;
function setUp() public {
vault = new UnsafeVault();
receiver = new ReenteringReceiver(vault);
vm.deal(address(this), 10 ether);
receiver.deposit{value: 1 ether}();
}
function test_StateIsStaleDuringCallback() public {
receiver.begin(1 ether);
assertTrue(receiver.attempted());
assertEq(receiver.observedBalance(), 1 ether);
}
}
```
执行测试:
```bash
forge test --match-test test_StateIsStaleDuringCallback -vvvv
```
这里验证的是根因:回调期间仍能观察到未扣减的余额。测试不依赖从第三方合约盗取资金,也不应被直接迁移到未授权目标。
## 七、修复方案
首选方案是先更新状态,再进行外部交互,并添加重入锁作为纵深防御:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeVault is ReentrancyGuard {
mapping(address => uint256) public balanceOf;
event Deposited(address indexed account, uint256 amount);
event Withdrawn(address indexed account, uint256 amount);
error ZeroAmount();
error InsufficientBalance();
error TransferFailed();
function deposit() external payable {
if (msg.value == 0) revert ZeroAmount();
balanceOf[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value);
}
function withdraw(uint256 amount) external nonReentrant {
if (amount == 0) revert ZeroAmount();
uint256 oldBalance = balanceOf[msg.sender];
if (oldBalance < amount) revert InsufficientBalance();
balanceOf[msg.sender] = oldBalance - amount;
(bool ok, ) = msg.sender.call{value: amount}("");
if (!ok) revert TransferFailed();
emit Withdrawn(msg.sender, amount);
}
}
```
这个修复包含三层含义:
1. Checks-Effects-Interactions 消除回调期间的旧状态;
2. `nonReentrant` 阻断同一互斥域内的嵌套进入;
3. 外调失败时整笔交易回滚,之前的余额扣减也会被 EVM 原子性恢复。
不要把 `transfer` 的固定 gas 限制当作主要防线。Gas 成本可能随协议升级变化,而且固定 gas 还可能导致某些合法智能钱包无法收款。更稳妥的做法是保持状态顺序正确,并显式检查调用结果。
## 八、把修复转成回归测试与不变量
修复后至少增加以下测试:
- 正常存款和提款成功;
- 超额提款回滚;
- 零金额行为符合设计;
- 恶意接收方无法在回调中重复消费余额;
- 接收方拒绝收款时,内部余额不会丢失;
- 多用户操作不会互相污染余额;
- 暂停、升级或角色变更不破坏提款约束。
模糊测试可覆盖不同金额:
```solidity
function testFuzz_DepositThenWithdraw(uint96 rawAmount) public {
uint256 amount = bound(uint256(rawAmount), 1 wei, 100 ether);
address user = makeAddr("user");
vm.deal(user, amount);
vm.startPrank(user);
safeVault.deposit{value: amount}();
safeVault.withdraw(amount);
vm.stopPrank();
assertEq(safeVault.balanceOf(user), 0);
}
```
进一步可使用 Handler 驱动多个用户随机执行 `deposit` 和 `withdraw`,检查一个核心不变量:如果金库没有手续费、外部收益或强制转入的特殊记账,已记账负债总额不应超过合约余额。
需要注意,任何地址都可以通过某些 EVM 机制向合约强制转入原生代币,因此通常不应断言“合约余额严格等于内部负债”。更稳健的不变量是:
```text
address(vault).balance >= totalRecordedLiabilities
```
而不是:
```text
address(vault).balance == totalRecordedLiabilities
```
运行完整测试:
```bash
forge test -vv
forge test --fuzz-runs 5000
forge test --match-contract SafeVaultInvariantTest -vv
forge snapshot
```
Gas 快照不是安全证明,但可以帮助发现修复后意外出现的高成本路径和潜在拒绝服务风险。
## 九、静态分析与人工复核如何配合
可在本地对自有项目运行 Slither 等静态分析工具。示例:
```bash
slither . --exclude-dependencies
```
工具输出应作为线索,不应直接等同于漏洞结论。常见误区包括:
- 看到任意外部调用就判定可利用重入;
- 忽略调用后的回滚导致整条路径不可获利;
- 只检查单函数,不检查共享状态和跨函数重入;
- 将管理员有意保留的能力与权限绕过混为一谈;
- 未考虑代理、初始化器和存储布局;
- 未复核代币的非标准返回值、转账税或回调行为。
推荐对每个发现记录:影响资产、攻击前置条件、最短调用路径、可复现测试、严重性依据、修复补丁以及修复后的负向测试。这样比单纯给出扫描器截图更有审计价值。
## 十、风险与注意事项
1. **重入锁不是万能方案**:若两个入口使用不同锁,或某个入口未加锁,共享状态仍可能被跨函数重入。
2. **升级合约需要存储布局审查**:引入基类可能改变存储结构。修复代理合约时,应核对所用代理模式和库版本,并执行升级验证。
3. **代币不一定遵循标准行为**:部分 ERC-20 不返回布尔值,部分资产具有转账税、rebasing 或回调。应采用经过审查的安全封装,并按实际到账额设计记账逻辑。
4. **预言机与闪电贷会放大业务漏洞**:重入之外,若份额定价依赖可瞬时操纵的现货价格,还需验证价格来源、时间窗口和流动性假设。
5. **拒绝服务也要测试**:接收方可能故意回滚。如果系统采用批量推送付款,一个失败地址可能阻塞整个批次。提款式结算通常更容易隔离失败。
6. **不要在主网做探索性测试**:复现应使用单元测试、本地 Anvil、专用测试网或书面授权的靶场,并避免使用真实私钥和真实资产。
## 十一、常见问题
### Q1:只遵循 Checks-Effects-Interactions 就足够吗?
不一定。它能解决大量直接重入,但复杂合约还可能存在跨函数重入、只读重入以及外部依赖读取中间状态等问题。高价值合约通常应结合互斥锁、明确状态机和不变量测试。
### Q2:所有外部函数都应添加 `nonReentrant` 吗?
不建议机械添加。应先识别共享状态和外调边界,再确定互斥域。过度添加会增加 gas、组合限制和维护成本,也可能掩盖错误的状态更新顺序。
### Q3:为什么测试回调时看到旧余额就值得报告?
因为它证明了不可信代码执行期间,关键授权状态仍未生效。这是明确的设计缺陷证据。最终严重性仍需结合资产规模、可达性、后续回滚和其他入口判断。
### Q4:静态分析没有报警是否代表安全?
不是。静态分析通常难以完整理解业务不变量、经济模型、治理权限和跨协议组合。它适合扩大覆盖面,但不能替代人工建模、动态测试和独立复核。
### Q5:如何验证修复没有引入新问题?
保留原始复现用例,使其在修复版本上无法再观察到危险状态;同时补充正常流程、失败回滚、边界金额、多用户模糊测试和不变量测试。若是可升级系统,还要验证存储布局与初始化流程。
## 十二、总结
一套可执行的智能合约审计流程应从资产和信任边界开始,围绕关键不变量审查状态更新、权限控制与外部调用,再用本地测试证明问题根因。对于重入漏洞,核心不是简单搜索 `call`,而是判断不可信回调发生时,额度、份额、所有权或结算状态是否已经生效。
修复时应优先调整状态更新顺序,再使用重入锁提供纵深防御,并以单元测试、模糊测试和不变量测试固化安全目标。最终交付物应让开发者能够复现、修复并持续验证,而不是停留在一次性的漏洞描述上。
智能合约一旦部署,权限、资产和状态变化通常由链上代码直接约束。传统代码审查中“发现问题后再修复”的思路并不完全适用:合约可能难以升级,错误交易也无法简单撤销。因此,一次可靠的安全审计不应只交付漏洞列表,还应交付可复现用例、修复方案以及能够长期运行的回归测试。
本文以资金金库合约为本地实验对象,演示如何使用 Foundry 分析重入漏洞,并把人工审计、单元测试、模糊测试和不变量测试串成一套流程。所有操作仅面向自有代码和本地 Anvil 环境,不涉及真实目标。
## 二、漏洞原理与威胁模型
重入并不等于“调用了外部合约”。真正危险的是:合约在关键状态尚未更新时,将控制权交给不受信任的地址;对方在回调过程中再次进入原函数,使多次执行共享同一份过期状态。
下面是一个故意保留缺陷的简化金库:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract UnsafeVault {
mapping(address => uint256) public balanceOf;
function deposit() external payable {
balanceOf[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balanceOf[msg.sender] >= amount, "insufficient balance");
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
balanceOf[msg.sender] -= amount;
}
}
```
问题位于 `withdraw`:先执行外部调用,后扣减内部余额。如果接收方是合约,其 `receive` 或 `fallback` 可以在余额尚未扣减时再次调用 `withdraw`。
需要强调,Solidity 0.8 的算术检查并不能从根本上解决该问题。某些重入路径最终可能因下溢而整体回滚,但安全性不能依赖偶然的回滚行为;一旦代码加入 `unchecked`、分支结算、积分记账、跨函数入口或其他资产来源,风险模型就会改变。
## 三、漏洞成立的前置条件
审计时应明确可利用条件,而不是只看到 `call` 就报告高危:
1. 存在向用户可控地址发起的外部调用;
2. 外部调用发生时,影响授权或结算的关键状态尚未更新;
3. 回调能够重新进入原函数,或进入共享同一状态的其他函数;
4. 合约中存在可被重复结算的资产、额度或权益;
5. 没有有效的互斥锁、状态机限制或其他等价防护;
6. 攻击路径可以在同一交易中完成,并且不会因后续检查而整体回滚。
跨函数重入尤其容易漏报。例如 `claim()` 和 `withdraw()` 分别看似安全,但如果二者共同依赖 `credit[msg.sender]`,其中一个函数外调期间可能通过另一个函数重复消费额度。
## 四、搭建本地审计环境
前置工具为 Git、Foundry 和一个仅用于实验的目录。安装 Foundry 时应从官方来源核对安装方式与版本,不要直接执行来源不明的脚本。
```bash
forge --version
anvil --version
mkdir vault-audit && cd vault-audit
forge init
forge build
```
建议固定编译器和依赖版本,避免审计结果随环境漂移。可在 `foundry.toml` 中设置:
```toml
[profile.default]
src = "src"
test = "test"
solc_version = "0.8.24"
optimizer = true
optimizer_runs = 200
fuzz_runs = 1000
```
将待审计合约放入 `src/UnsafeVault.sol`。运行:
```bash
forge fmt --check
forge build
forge test -vv
```
编译成功只说明语法和类型基本正确,并不意味着业务安全。
## 五、分步骤开展人工审计
### 第一步:建立资产与信任边界
先列出合约管理的资产:原生代币、ERC-20、NFT、份额凭证以及仅存在于映射中的内部信用。然后标记所有不可信边界,包括用户地址、外部代币、预言机、路由器、代理实现和管理员。
对金库而言,核心安全目标至少包括:
- 用户不能提取超过自身已记账余额的资产;
- 一次提款只能消费一次额度;
- 所有用户内部余额之和不应无故超过合约可支配资产;
- 单个恶意接收方失败,不应破坏其他用户的余额记录。
### 第二步:定位状态修改与外部调用
逐个函数标注读取、写入和外调位置。重点检索:
```bash
rg "\.call|delegatecall|staticcall|transferFrom|safeTransfer|safeMint" src test
```
不能只检查显式的低级 `call`。ERC-777 回调、ERC-721/1155 的安全转账、任意代币合约以及可升级代理都可能转移控制权。
### 第三步:绘制调用顺序
对 `withdraw` 可得到:
1. 读取 `balanceOf[msg.sender]`;
2. 检查额度;
3. 调用 `msg.sender`;
4. 扣减余额。
第三步和第四步顺序错误,违反 Checks-Effects-Interactions,即“检查—状态生效—外部交互”模式。
### 第四步:检查权限与可升级面
即使本例没有管理员,也应在实际项目中检查初始化函数能否重复调用、升级权限是否受多签和时间锁保护、实现合约是否被初始化、角色管理员关系是否正确,以及紧急暂停是否覆盖真正的资金出口。重入修复不能掩盖权限配置问题。
## 六、编写本地复现测试
测试目标不是攻击真实合约,而是证明“回调发生时关键状态仍是旧值”。可以创建一个受控接收合约,在回调里尝试二次进入,并记录观察结果:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {UnsafeVault} from "../src/UnsafeVault.sol";
contract ReenteringReceiver {
UnsafeVault public immutable vault;
bool public attempted;
uint256 public observedBalance;
constructor(UnsafeVault v) {
vault = v;
}
function deposit() external payable {
vault.deposit{value: msg.value}();
}
function begin(uint256 amount) external {
vault.withdraw(amount);
}
receive() external payable {
observedBalance = vault.balanceOf(address(this));
if (!attempted) {
attempted = true;
try vault.withdraw(msg.value) {} catch {}
}
}
}
contract UnsafeVaultTest is Test {
UnsafeVault vault;
ReenteringReceiver receiver;
function setUp() public {
vault = new UnsafeVault();
receiver = new ReenteringReceiver(vault);
vm.deal(address(this), 10 ether);
receiver.deposit{value: 1 ether}();
}
function test_StateIsStaleDuringCallback() public {
receiver.begin(1 ether);
assertTrue(receiver.attempted());
assertEq(receiver.observedBalance(), 1 ether);
}
}
```
执行测试:
```bash
forge test --match-test test_StateIsStaleDuringCallback -vvvv
```
这里验证的是根因:回调期间仍能观察到未扣减的余额。测试不依赖从第三方合约盗取资金,也不应被直接迁移到未授权目标。
## 七、修复方案
首选方案是先更新状态,再进行外部交互,并添加重入锁作为纵深防御:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeVault is ReentrancyGuard {
mapping(address => uint256) public balanceOf;
event Deposited(address indexed account, uint256 amount);
event Withdrawn(address indexed account, uint256 amount);
error ZeroAmount();
error InsufficientBalance();
error TransferFailed();
function deposit() external payable {
if (msg.value == 0) revert ZeroAmount();
balanceOf[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value);
}
function withdraw(uint256 amount) external nonReentrant {
if (amount == 0) revert ZeroAmount();
uint256 oldBalance = balanceOf[msg.sender];
if (oldBalance < amount) revert InsufficientBalance();
balanceOf[msg.sender] = oldBalance - amount;
(bool ok, ) = msg.sender.call{value: amount}("");
if (!ok) revert TransferFailed();
emit Withdrawn(msg.sender, amount);
}
}
```
这个修复包含三层含义:
1. Checks-Effects-Interactions 消除回调期间的旧状态;
2. `nonReentrant` 阻断同一互斥域内的嵌套进入;
3. 外调失败时整笔交易回滚,之前的余额扣减也会被 EVM 原子性恢复。
不要把 `transfer` 的固定 gas 限制当作主要防线。Gas 成本可能随协议升级变化,而且固定 gas 还可能导致某些合法智能钱包无法收款。更稳妥的做法是保持状态顺序正确,并显式检查调用结果。
## 八、把修复转成回归测试与不变量
修复后至少增加以下测试:
- 正常存款和提款成功;
- 超额提款回滚;
- 零金额行为符合设计;
- 恶意接收方无法在回调中重复消费余额;
- 接收方拒绝收款时,内部余额不会丢失;
- 多用户操作不会互相污染余额;
- 暂停、升级或角色变更不破坏提款约束。
模糊测试可覆盖不同金额:
```solidity
function testFuzz_DepositThenWithdraw(uint96 rawAmount) public {
uint256 amount = bound(uint256(rawAmount), 1 wei, 100 ether);
address user = makeAddr("user");
vm.deal(user, amount);
vm.startPrank(user);
safeVault.deposit{value: amount}();
safeVault.withdraw(amount);
vm.stopPrank();
assertEq(safeVault.balanceOf(user), 0);
}
```
进一步可使用 Handler 驱动多个用户随机执行 `deposit` 和 `withdraw`,检查一个核心不变量:如果金库没有手续费、外部收益或强制转入的特殊记账,已记账负债总额不应超过合约余额。
需要注意,任何地址都可以通过某些 EVM 机制向合约强制转入原生代币,因此通常不应断言“合约余额严格等于内部负债”。更稳健的不变量是:
```text
address(vault).balance >= totalRecordedLiabilities
```
而不是:
```text
address(vault).balance == totalRecordedLiabilities
```
运行完整测试:
```bash
forge test -vv
forge test --fuzz-runs 5000
forge test --match-contract SafeVaultInvariantTest -vv
forge snapshot
```
Gas 快照不是安全证明,但可以帮助发现修复后意外出现的高成本路径和潜在拒绝服务风险。
## 九、静态分析与人工复核如何配合
可在本地对自有项目运行 Slither 等静态分析工具。示例:
```bash
slither . --exclude-dependencies
```
工具输出应作为线索,不应直接等同于漏洞结论。常见误区包括:
- 看到任意外部调用就判定可利用重入;
- 忽略调用后的回滚导致整条路径不可获利;
- 只检查单函数,不检查共享状态和跨函数重入;
- 将管理员有意保留的能力与权限绕过混为一谈;
- 未考虑代理、初始化器和存储布局;
- 未复核代币的非标准返回值、转账税或回调行为。
推荐对每个发现记录:影响资产、攻击前置条件、最短调用路径、可复现测试、严重性依据、修复补丁以及修复后的负向测试。这样比单纯给出扫描器截图更有审计价值。
## 十、风险与注意事项
1. **重入锁不是万能方案**:若两个入口使用不同锁,或某个入口未加锁,共享状态仍可能被跨函数重入。
2. **升级合约需要存储布局审查**:引入基类可能改变存储结构。修复代理合约时,应核对所用代理模式和库版本,并执行升级验证。
3. **代币不一定遵循标准行为**:部分 ERC-20 不返回布尔值,部分资产具有转账税、rebasing 或回调。应采用经过审查的安全封装,并按实际到账额设计记账逻辑。
4. **预言机与闪电贷会放大业务漏洞**:重入之外,若份额定价依赖可瞬时操纵的现货价格,还需验证价格来源、时间窗口和流动性假设。
5. **拒绝服务也要测试**:接收方可能故意回滚。如果系统采用批量推送付款,一个失败地址可能阻塞整个批次。提款式结算通常更容易隔离失败。
6. **不要在主网做探索性测试**:复现应使用单元测试、本地 Anvil、专用测试网或书面授权的靶场,并避免使用真实私钥和真实资产。
## 十一、常见问题
### Q1:只遵循 Checks-Effects-Interactions 就足够吗?
不一定。它能解决大量直接重入,但复杂合约还可能存在跨函数重入、只读重入以及外部依赖读取中间状态等问题。高价值合约通常应结合互斥锁、明确状态机和不变量测试。
### Q2:所有外部函数都应添加 `nonReentrant` 吗?
不建议机械添加。应先识别共享状态和外调边界,再确定互斥域。过度添加会增加 gas、组合限制和维护成本,也可能掩盖错误的状态更新顺序。
### Q3:为什么测试回调时看到旧余额就值得报告?
因为它证明了不可信代码执行期间,关键授权状态仍未生效。这是明确的设计缺陷证据。最终严重性仍需结合资产规模、可达性、后续回滚和其他入口判断。
### Q4:静态分析没有报警是否代表安全?
不是。静态分析通常难以完整理解业务不变量、经济模型、治理权限和跨协议组合。它适合扩大覆盖面,但不能替代人工建模、动态测试和独立复核。
### Q5:如何验证修复没有引入新问题?
保留原始复现用例,使其在修复版本上无法再观察到危险状态;同时补充正常流程、失败回滚、边界金额、多用户模糊测试和不变量测试。若是可升级系统,还要验证存储布局与初始化流程。
## 十二、总结
一套可执行的智能合约审计流程应从资产和信任边界开始,围绕关键不变量审查状态更新、权限控制与外部调用,再用本地测试证明问题根因。对于重入漏洞,核心不是简单搜索 `call`,而是判断不可信回调发生时,额度、份额、所有权或结算状态是否已经生效。
修复时应优先调整状态更新顺序,再使用重入锁提供纵深防御,并以单元测试、模糊测试和不变量测试固化安全目标。最终交付物应让开发者能够复现、修复并持续验证,而不是停留在一次性的漏洞描述上。