1
月
在社区成长 1 月
LEVEL 1
1 XP
## 一、背景:重入问题本质上不是“重复调用”,而是状态一致性失效
在以太坊智能合约中,向外部地址转账、调用其他合约、执行代币回调,都会把控制权暂时交给不受当前合约控制的代码。如果关键状态尚未完成更新,外部代码就可能在中间状态下重新进入原函数或其他相关函数,破坏余额、份额、债务等会计约束。
因此,重入审计不能只搜索 `call{value: ...}`,也不能简单地认为加上 `nonReentrant` 就已解决问题。工程上应当同时检查:
1. 外部调用前后,合约状态是否始终满足业务不变量;
2. 是否存在跨函数重入,例如从 `withdraw` 回调进入 `transferShare`;
3. 是否存在跨合约重入,例如金库、策略合约和价格模块相互调用;
4. 只读函数是否会在中间状态下返回错误数据,进而影响其他协议;
5. 原生币、ERC-777、带钩子的 NFT 或非标准 ERC-20 是否引入额外回调面;
6. 失败后的回滚、重试和资金领取机制是否清晰。
本文只针对本地实验、授权代码库和防御性审计,不涉及对真实目标的未授权测试。
## 二、漏洞原理与典型触发条件
一个存在风险的提款流程通常具有以下顺序:
```solidity
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;
}
```
问题在于:转账发生时,`balanceOf[msg.sender]` 尚未减少。若接收方是合约,其 `receive` 或 `fallback` 可以再次进入 `withdraw`。在原调用完成状态更新之前,后续调用仍然观察到旧余额。
漏洞成立通常需要以下前置条件:
- 函数中存在对不可信地址的外部调用;
- 外部调用发生在关键状态更新之前,或者部分状态已更新、其他关联状态尚未更新;
- 回调路径能够重新进入同一函数或共享同一状态的其他函数;
- 合约中有足够资金或可被错误铸造、转移的资产;
- 权限、暂停、额度等控制没有阻断该调用链。
需要注意,外部调用并不限于原生币转账。`token.transfer` 在标准 ERC-20 中通常不回调,但审计时不能默认任意地址都严格遵循标准;ERC-777、ERC-721 安全转移、ERC-1155 接收回调以及协议自定义钩子都可能产生重入窗口。
## 三、建立审计对象与业务不变量
在阅读代码前,先把合约看作一个状态机,而不是函数集合。对于简单金库,可以定义:
- 用户负债:`liability[user]`,表示用户可领取金额;
- 总负债:`totalLiability`;
- 实际资产:`address(this).balance`;
- 核心偿付不变量:`totalLiability <= address(this).balance`;
- 记账不变量:所有用户负债之和等于 `totalLiability`;
- 提款后置条件:成功提款后,用户负债和总负债都减少相同金额;
- 失败后置条件:外部转账失败时,交易整体回滚,负债不得减少。
这里使用“小于等于”而不是“等于”,因为其他合约可以通过 `selfdestruct` 等历史或兼容路径强制向目标地址发送 ETH,普通转账入口并不是合约余额增加的唯一来源。强制收到的 ETH 不应自动增加某个用户的可领取额度。
## 四、分步骤进行人工审计
### 第 1 步:枚举所有外部交互点
重点搜索:
```text
.call(
.call{
delegatecall
staticcall
transfer(
safeTransfer
safeTransferFrom
safeMint
onERC721Received
onERC1155Received
```
不要只检查提款函数。存款后的通知、奖励领取、清算、份额转移、管理员救援和策略再平衡都可能形成回调入口。
### 第 2 步:为每个交互点画调用前后状态
记录:
- 调用前读取了哪些状态;
- 调用前写入了哪些状态;
- 外部地址是否由用户控制;
- 回调期间哪些公开函数仍可访问;
- 回调结束后还会写入哪些状态;
- 外部调用失败时是回滚、忽略还是记录待领取余额。
如果外部调用之后仍有关键会计状态更新,应被视为高优先级审计项。
### 第 3 步:检查跨函数共享状态
假设 `withdraw` 使用了重入锁,但 `transferCredit` 没有使用。如果回调期间能够调用 `transferCredit` 修改同一份余额,单函数重入锁仍可能不足。应建立“状态变量—读写函数”矩阵,例如:
```text
liability: deposit 写;withdraw 读写;transferCredit 读写;emergencyExit 读写
totalLiability: deposit 写;withdraw 写;emergencyExit 写
```
凡是共享关键状态的外部函数,都应被放在同一个重入域中分析。
### 第 4 步:检查调用结果和回滚语义
低级调用不会自动抛出接收方错误,必须检查返回值。与此同时,不应在转账失败后静默吞掉错误并永久扣减用户余额。若业务要求即使转账失败也继续执行,应采用待领取账本,并提供独立的重试或改收款地址流程。
### 第 5 步:检查只读重入
`view` 函数不会修改本合约状态,但它可能在某个外部调用的中间阶段被其他协议读取。如果资产余额已变化、份额供应量尚未变化,`pricePerShare()` 可能短暂返回异常价格。防御方式包括先完成全部内部记账、避免在中间状态调用外部合约,以及让依赖方使用抗操纵的定价与时间窗口。
## 五、加固实现:CEI、重入锁与拉取式支付
下面是一个用于本地实验的简化金库。它先完成检查和状态更新,再进行外部交互,并使用重入锁提供第二层保护:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeEtherVault is ReentrancyGuard {
mapping(address => uint256) public liability;
uint256 public totalLiability;
error ZeroAmount();
error InsufficientLiability();
error EtherTransferFailed();
event Deposited(address indexed account, uint256 amount);
event Withdrawn(address indexed account, address indexed receiver, uint256 amount);
function deposit() external payable {
if (msg.value == 0) revert ZeroAmount();
liability[msg.sender] += msg.value;
totalLiability += msg.value;
emit Deposited(msg.sender, msg.value);
}
function withdraw(uint256 amount, address payable receiver) external nonReentrant {
if (amount == 0) revert ZeroAmount();
if (liability[msg.sender] < amount) revert InsufficientLiability();
liability[msg.sender] -= amount;
totalLiability -= amount;
(bool ok, ) = receiver.call{value: amount}("");
if (!ok) revert EtherTransferFailed();
emit Withdrawn(msg.sender, receiver, amount);
}
function solvent() external view returns (bool) {
return totalLiability <= address(this).balance;
}
}
```
这个设计包含三层考虑:
1. Checks:检查金额和可领取额度;
2. Effects:先减少用户负债与总负债;
3. Interactions:最后向接收方转账;
4. `nonReentrant`:防止未来维护时无意引入跨路径重入。
重入锁不是 CEI 的替代品。只依赖锁会让安全性绑定到修饰器覆盖范围,新增函数时很容易漏加;只依赖 CEI 也可能在复杂跨合约业务中遗漏状态关联。两者结合更适合资金合约。
允许用户指定 `receiver` 可以处理某些调用方无法接收 ETH 的情况,但前提是提款额度始终从 `msg.sender` 扣除,不能错误地从 `receiver` 扣除,否则可能产生越权提款。
## 六、使用 Foundry 建立本地验证环境
前置条件:已安装 Git 与 Foundry,测试只在本地链或授权仓库中进行。
```bash
mkdir vault-audit && cd vault-audit
forge init
forge install OpenZeppelin/openzeppelin-contracts --no-commit
forge build
forge test -vvv
```
建议固定依赖版本,不要让审计结果依赖浮动的主分支。可以在 `foundry.toml` 中配置编译器版本:
```toml
[profile.default]
src = "src"
test = "test"
solc_version = "0.8.24"
optimizer = true
optimizer_runs = 200
```
基础单元测试至少覆盖:
- 正常存款和提款;
- 零金额输入;
- 超额提款;
- 接收方拒收 ETH;
- 多用户交错操作;
- 同一用户分批提款;
- 回调尝试重入;
- 每次状态变化后的偿付不变量。
一个关键测试应验证转账失败会完整回滚:
```solidity
contract RejectEther {
receive() external payable {
revert("reject");
}
}
```
测试流程是:用户先存款,随后将 `RejectEther` 作为接收方调用提款;预期交易回滚,并确认用户负债、总负债和金库余额均保持不变。这里验证的不是错误字符串,而是失败恢复后的状态完整性。
## 七、模糊测试与不变量测试
单元测试只能覆盖预先想到的路径。资金合约更适合增加 fuzz 与 invariant 测试。
对于模糊测试,应限制输入范围,避免大量无意义溢出或余额不足样本:
```solidity
uint256 amount = bound(rawAmount, 1 wei, 100 ether);
```
对于状态不变量,可构造一个 Handler,让多个模拟用户随机执行 `deposit`、`withdraw` 和失败提款,然后持续检查:
```solidity
function invariant_vaultIsSolvent() public view {
assertLe(vault.totalLiability(), address(vault).balance);
}
```
还应在 Handler 中维护影子账本,用测试侧模型累计每个用户的理论负债,再与链上 `liability` 和 `totalLiability` 对比。仅检查金库余额并不充分,因为错误实现可能同时错误减少用户负债与总负债,看起来仍然“偿付充足”,但用户权益已经丢失。
执行命令示例:
```bash
forge test --match-path test/SafeEtherVault.t.sol -vvvv
forge test --match-test testFuzz_Withdraw -vvv
forge test --match-contract VaultInvariantTest -vvv
```
若要观察执行路径,可以在本地测试失败后查看 trace。不要直接在公链上通过反复试探交易进行漏洞验证;应复制经过授权的代码和状态条件,在隔离环境复现。
## 八、静态分析与人工复核
可在本地安装并运行 Slither:
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install slither-analyzer
slither . --exclude-dependencies
```
静态分析结果需要人工分类:
- 真阳性:外部调用后更新用户余额;
- 条件性风险:调用目标固定但可升级,当前安全不代表未来安全;
- 假阳性:外部调用失败后必然整体回滚,且关键状态已提前更新;
- 设计风险:管理员可替换策略合约,从而扩大回调面。
工具的价值是扩大覆盖面,不是代替业务语义。扫描器通常不知道“总负债必须等于用户负债之和”,也无法自动判断某个价格在回调中被读取是否会影响下游清算。
## 九、风险与注意事项
1. 不要使用 `transfer` 的 2300 gas 限制作为安全边界。Gas 规则会变化,且拒收问题会降低可用性。推荐检查返回值的低级 `call`,同时通过 CEI 和重入锁保障状态。
2. 可升级合约必须检查初始化。遗漏 `ReentrancyGuard` 对应的初始化方式、实现合约未禁用初始化器、存储布局冲突,都可能让正确逻辑失效。
3. 代理升级会改变信任边界。当前外部调用目标安全,不代表升级后仍无回调能力。
4. 不要假定代币完全遵循 ERC-20。应使用兼容非标准返回值的安全封装,并针对转账税、余额变化和回调型代币单独建模。
5. 暂停机制只能止损,不能修复账本。暂停后仍需有经过审计的恢复、赔付或退出路径。
6. 管理员救援函数不得提走用户负债对应资产。可救援额度最多应基于“实际资产减总负债”的可证明盈余。
7. 事件不能代替状态。链下系统可以依赖事件做索引,但最终权益判断必须以合约状态和明确的不变量为准。
8. 修复后必须补充回归测试。否则后续重构、Gas 优化或新增回调接口可能重新引入同类问题。
## 十、常见问题
### 1. 使用 `nonReentrant` 后是否可以把转账放在状态更新之前?
不建议。锁只能覆盖被正确修饰且处于同一锁域的入口,无法自动解决中间状态暴露、只读重入和跨合约状态不一致。仍应遵守 CEI。
### 2. 所有外部函数都要加重入锁吗?
不需要机械添加。应围绕共享状态和调用图确定重入域。过度添加会阻止合法的内部组合调用,也可能掩盖设计问题。资金流出、份额变化和依赖外部回调的入口应重点评估。
### 3. `view` 函数不会写状态,为什么也有风险?
它可能在交易执行的中间状态被外部协议调用并返回临时错误值。若该值用于抵押率、价格或份额兑换,就可能传播为经济损失。
### 4. 转账失败后先恢复余额,再返回是否可行?
理论上可以,但分支更多,容易遗漏关联状态。直接 `revert` 能原子回滚本次交易中的全部状态变化,通常更可靠。若业务不能回滚,应采用明确的待领取状态机,而不是临时补写余额。
### 5. 只做一次攻击合约测试是否足够?
不够。单次测试通常只覆盖同函数重入。还需覆盖跨函数、不同接收方、失败回调、多用户交错、代理升级后的实现,以及所有业务不变量。
## 十一、总结
重入漏洞的核心是外部控制流介入后,合约暴露了不完整或不一致的状态。有效审计应从资产、负债和权限模型出发,枚举外部调用,建立状态变量读写矩阵,再通过 CEI、重入锁、拉取式支付和清晰的失败语义进行分层防御。
实践中,最可靠的交付物不只是“发现某行代码存在重入”,而应包括:可复现的本地测试、明确的漏洞前置条件、受影响的不变量、最小修复方案、修复后的单元与不变量测试,以及对升级和异常代币行为的边界说明。只有把调用链、状态机和失败恢复一起验证,才能避免修复表面入口后,风险从另一个函数或合约边界重新出现。
在以太坊智能合约中,向外部地址转账、调用其他合约、执行代币回调,都会把控制权暂时交给不受当前合约控制的代码。如果关键状态尚未完成更新,外部代码就可能在中间状态下重新进入原函数或其他相关函数,破坏余额、份额、债务等会计约束。
因此,重入审计不能只搜索 `call{value: ...}`,也不能简单地认为加上 `nonReentrant` 就已解决问题。工程上应当同时检查:
1. 外部调用前后,合约状态是否始终满足业务不变量;
2. 是否存在跨函数重入,例如从 `withdraw` 回调进入 `transferShare`;
3. 是否存在跨合约重入,例如金库、策略合约和价格模块相互调用;
4. 只读函数是否会在中间状态下返回错误数据,进而影响其他协议;
5. 原生币、ERC-777、带钩子的 NFT 或非标准 ERC-20 是否引入额外回调面;
6. 失败后的回滚、重试和资金领取机制是否清晰。
本文只针对本地实验、授权代码库和防御性审计,不涉及对真实目标的未授权测试。
## 二、漏洞原理与典型触发条件
一个存在风险的提款流程通常具有以下顺序:
```solidity
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;
}
```
问题在于:转账发生时,`balanceOf[msg.sender]` 尚未减少。若接收方是合约,其 `receive` 或 `fallback` 可以再次进入 `withdraw`。在原调用完成状态更新之前,后续调用仍然观察到旧余额。
漏洞成立通常需要以下前置条件:
- 函数中存在对不可信地址的外部调用;
- 外部调用发生在关键状态更新之前,或者部分状态已更新、其他关联状态尚未更新;
- 回调路径能够重新进入同一函数或共享同一状态的其他函数;
- 合约中有足够资金或可被错误铸造、转移的资产;
- 权限、暂停、额度等控制没有阻断该调用链。
需要注意,外部调用并不限于原生币转账。`token.transfer` 在标准 ERC-20 中通常不回调,但审计时不能默认任意地址都严格遵循标准;ERC-777、ERC-721 安全转移、ERC-1155 接收回调以及协议自定义钩子都可能产生重入窗口。
## 三、建立审计对象与业务不变量
在阅读代码前,先把合约看作一个状态机,而不是函数集合。对于简单金库,可以定义:
- 用户负债:`liability[user]`,表示用户可领取金额;
- 总负债:`totalLiability`;
- 实际资产:`address(this).balance`;
- 核心偿付不变量:`totalLiability <= address(this).balance`;
- 记账不变量:所有用户负债之和等于 `totalLiability`;
- 提款后置条件:成功提款后,用户负债和总负债都减少相同金额;
- 失败后置条件:外部转账失败时,交易整体回滚,负债不得减少。
这里使用“小于等于”而不是“等于”,因为其他合约可以通过 `selfdestruct` 等历史或兼容路径强制向目标地址发送 ETH,普通转账入口并不是合约余额增加的唯一来源。强制收到的 ETH 不应自动增加某个用户的可领取额度。
## 四、分步骤进行人工审计
### 第 1 步:枚举所有外部交互点
重点搜索:
```text
.call(
.call{
delegatecall
staticcall
transfer(
safeTransfer
safeTransferFrom
safeMint
onERC721Received
onERC1155Received
```
不要只检查提款函数。存款后的通知、奖励领取、清算、份额转移、管理员救援和策略再平衡都可能形成回调入口。
### 第 2 步:为每个交互点画调用前后状态
记录:
- 调用前读取了哪些状态;
- 调用前写入了哪些状态;
- 外部地址是否由用户控制;
- 回调期间哪些公开函数仍可访问;
- 回调结束后还会写入哪些状态;
- 外部调用失败时是回滚、忽略还是记录待领取余额。
如果外部调用之后仍有关键会计状态更新,应被视为高优先级审计项。
### 第 3 步:检查跨函数共享状态
假设 `withdraw` 使用了重入锁,但 `transferCredit` 没有使用。如果回调期间能够调用 `transferCredit` 修改同一份余额,单函数重入锁仍可能不足。应建立“状态变量—读写函数”矩阵,例如:
```text
liability: deposit 写;withdraw 读写;transferCredit 读写;emergencyExit 读写
totalLiability: deposit 写;withdraw 写;emergencyExit 写
```
凡是共享关键状态的外部函数,都应被放在同一个重入域中分析。
### 第 4 步:检查调用结果和回滚语义
低级调用不会自动抛出接收方错误,必须检查返回值。与此同时,不应在转账失败后静默吞掉错误并永久扣减用户余额。若业务要求即使转账失败也继续执行,应采用待领取账本,并提供独立的重试或改收款地址流程。
### 第 5 步:检查只读重入
`view` 函数不会修改本合约状态,但它可能在某个外部调用的中间阶段被其他协议读取。如果资产余额已变化、份额供应量尚未变化,`pricePerShare()` 可能短暂返回异常价格。防御方式包括先完成全部内部记账、避免在中间状态调用外部合约,以及让依赖方使用抗操纵的定价与时间窗口。
## 五、加固实现:CEI、重入锁与拉取式支付
下面是一个用于本地实验的简化金库。它先完成检查和状态更新,再进行外部交互,并使用重入锁提供第二层保护:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeEtherVault is ReentrancyGuard {
mapping(address => uint256) public liability;
uint256 public totalLiability;
error ZeroAmount();
error InsufficientLiability();
error EtherTransferFailed();
event Deposited(address indexed account, uint256 amount);
event Withdrawn(address indexed account, address indexed receiver, uint256 amount);
function deposit() external payable {
if (msg.value == 0) revert ZeroAmount();
liability[msg.sender] += msg.value;
totalLiability += msg.value;
emit Deposited(msg.sender, msg.value);
}
function withdraw(uint256 amount, address payable receiver) external nonReentrant {
if (amount == 0) revert ZeroAmount();
if (liability[msg.sender] < amount) revert InsufficientLiability();
liability[msg.sender] -= amount;
totalLiability -= amount;
(bool ok, ) = receiver.call{value: amount}("");
if (!ok) revert EtherTransferFailed();
emit Withdrawn(msg.sender, receiver, amount);
}
function solvent() external view returns (bool) {
return totalLiability <= address(this).balance;
}
}
```
这个设计包含三层考虑:
1. Checks:检查金额和可领取额度;
2. Effects:先减少用户负债与总负债;
3. Interactions:最后向接收方转账;
4. `nonReentrant`:防止未来维护时无意引入跨路径重入。
重入锁不是 CEI 的替代品。只依赖锁会让安全性绑定到修饰器覆盖范围,新增函数时很容易漏加;只依赖 CEI 也可能在复杂跨合约业务中遗漏状态关联。两者结合更适合资金合约。
允许用户指定 `receiver` 可以处理某些调用方无法接收 ETH 的情况,但前提是提款额度始终从 `msg.sender` 扣除,不能错误地从 `receiver` 扣除,否则可能产生越权提款。
## 六、使用 Foundry 建立本地验证环境
前置条件:已安装 Git 与 Foundry,测试只在本地链或授权仓库中进行。
```bash
mkdir vault-audit && cd vault-audit
forge init
forge install OpenZeppelin/openzeppelin-contracts --no-commit
forge build
forge test -vvv
```
建议固定依赖版本,不要让审计结果依赖浮动的主分支。可以在 `foundry.toml` 中配置编译器版本:
```toml
[profile.default]
src = "src"
test = "test"
solc_version = "0.8.24"
optimizer = true
optimizer_runs = 200
```
基础单元测试至少覆盖:
- 正常存款和提款;
- 零金额输入;
- 超额提款;
- 接收方拒收 ETH;
- 多用户交错操作;
- 同一用户分批提款;
- 回调尝试重入;
- 每次状态变化后的偿付不变量。
一个关键测试应验证转账失败会完整回滚:
```solidity
contract RejectEther {
receive() external payable {
revert("reject");
}
}
```
测试流程是:用户先存款,随后将 `RejectEther` 作为接收方调用提款;预期交易回滚,并确认用户负债、总负债和金库余额均保持不变。这里验证的不是错误字符串,而是失败恢复后的状态完整性。
## 七、模糊测试与不变量测试
单元测试只能覆盖预先想到的路径。资金合约更适合增加 fuzz 与 invariant 测试。
对于模糊测试,应限制输入范围,避免大量无意义溢出或余额不足样本:
```solidity
uint256 amount = bound(rawAmount, 1 wei, 100 ether);
```
对于状态不变量,可构造一个 Handler,让多个模拟用户随机执行 `deposit`、`withdraw` 和失败提款,然后持续检查:
```solidity
function invariant_vaultIsSolvent() public view {
assertLe(vault.totalLiability(), address(vault).balance);
}
```
还应在 Handler 中维护影子账本,用测试侧模型累计每个用户的理论负债,再与链上 `liability` 和 `totalLiability` 对比。仅检查金库余额并不充分,因为错误实现可能同时错误减少用户负债与总负债,看起来仍然“偿付充足”,但用户权益已经丢失。
执行命令示例:
```bash
forge test --match-path test/SafeEtherVault.t.sol -vvvv
forge test --match-test testFuzz_Withdraw -vvv
forge test --match-contract VaultInvariantTest -vvv
```
若要观察执行路径,可以在本地测试失败后查看 trace。不要直接在公链上通过反复试探交易进行漏洞验证;应复制经过授权的代码和状态条件,在隔离环境复现。
## 八、静态分析与人工复核
可在本地安装并运行 Slither:
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install slither-analyzer
slither . --exclude-dependencies
```
静态分析结果需要人工分类:
- 真阳性:外部调用后更新用户余额;
- 条件性风险:调用目标固定但可升级,当前安全不代表未来安全;
- 假阳性:外部调用失败后必然整体回滚,且关键状态已提前更新;
- 设计风险:管理员可替换策略合约,从而扩大回调面。
工具的价值是扩大覆盖面,不是代替业务语义。扫描器通常不知道“总负债必须等于用户负债之和”,也无法自动判断某个价格在回调中被读取是否会影响下游清算。
## 九、风险与注意事项
1. 不要使用 `transfer` 的 2300 gas 限制作为安全边界。Gas 规则会变化,且拒收问题会降低可用性。推荐检查返回值的低级 `call`,同时通过 CEI 和重入锁保障状态。
2. 可升级合约必须检查初始化。遗漏 `ReentrancyGuard` 对应的初始化方式、实现合约未禁用初始化器、存储布局冲突,都可能让正确逻辑失效。
3. 代理升级会改变信任边界。当前外部调用目标安全,不代表升级后仍无回调能力。
4. 不要假定代币完全遵循 ERC-20。应使用兼容非标准返回值的安全封装,并针对转账税、余额变化和回调型代币单独建模。
5. 暂停机制只能止损,不能修复账本。暂停后仍需有经过审计的恢复、赔付或退出路径。
6. 管理员救援函数不得提走用户负债对应资产。可救援额度最多应基于“实际资产减总负债”的可证明盈余。
7. 事件不能代替状态。链下系统可以依赖事件做索引,但最终权益判断必须以合约状态和明确的不变量为准。
8. 修复后必须补充回归测试。否则后续重构、Gas 优化或新增回调接口可能重新引入同类问题。
## 十、常见问题
### 1. 使用 `nonReentrant` 后是否可以把转账放在状态更新之前?
不建议。锁只能覆盖被正确修饰且处于同一锁域的入口,无法自动解决中间状态暴露、只读重入和跨合约状态不一致。仍应遵守 CEI。
### 2. 所有外部函数都要加重入锁吗?
不需要机械添加。应围绕共享状态和调用图确定重入域。过度添加会阻止合法的内部组合调用,也可能掩盖设计问题。资金流出、份额变化和依赖外部回调的入口应重点评估。
### 3. `view` 函数不会写状态,为什么也有风险?
它可能在交易执行的中间状态被外部协议调用并返回临时错误值。若该值用于抵押率、价格或份额兑换,就可能传播为经济损失。
### 4. 转账失败后先恢复余额,再返回是否可行?
理论上可以,但分支更多,容易遗漏关联状态。直接 `revert` 能原子回滚本次交易中的全部状态变化,通常更可靠。若业务不能回滚,应采用明确的待领取状态机,而不是临时补写余额。
### 5. 只做一次攻击合约测试是否足够?
不够。单次测试通常只覆盖同函数重入。还需覆盖跨函数、不同接收方、失败回调、多用户交错、代理升级后的实现,以及所有业务不变量。
## 十一、总结
重入漏洞的核心是外部控制流介入后,合约暴露了不完整或不一致的状态。有效审计应从资产、负债和权限模型出发,枚举外部调用,建立状态变量读写矩阵,再通过 CEI、重入锁、拉取式支付和清晰的失败语义进行分层防御。
实践中,最可靠的交付物不只是“发现某行代码存在重入”,而应包括:可复现的本地测试、明确的漏洞前置条件、受影响的不变量、最小修复方案、修复后的单元与不变量测试,以及对升级和异常代币行为的边界说明。只有把调用链、状态机和失败恢复一起验证,才能避免修复表面入口后,风险从另一个函数或合约边界重新出现。