1
月
在社区成长 1 月
LEVEL 50
100 XP
一、背景与审计目标
智能合约安全审计不能只依赖漏洞关键词扫描。对于托管 ETH、ERC-20 或份额凭证的合约,更有效的方法是先绘制资产流和状态机,再将安全假设转换为可执行测试。提现路径尤其关键,因为它同时涉及权限、余额核算、外部调用、回调重入、失败恢复和会计一致性。
本文以本地 Foundry 靶场中的 ETH 金库为例,演示如何发现并修复重入漏洞,并进一步建立适合日常审计的检查流程。所有测试仅面向本地代码和授权环境,不应对未获授权的链上合约进行探测或交易尝试。
审计目标包括:
1. 用户不能提取超过其账面余额的资产。
2. 任意外部调用发生时,关键状态不能处于可重复消费的中间状态。
3. 单次操作失败后,资产与账本应整体回滚,不产生半完成状态。
4. 合约持有资产应覆盖全部用户负债,即资产余额不小于可提现负债。
5. 修复措施不能破坏正常充值、提现和异常回滚流程。
二、漏洞原理
EVM 中的外部调用不是单纯的转账动作。使用 call 发送 ETH,或调用 ERC-777、带钩子的代币、未知适配器时,控制权可能暂时交给接收方。接收方可以在原函数尚未完成时再次调用当前合约。
一个典型的错误顺序是:
1. 读取用户余额。
2. 向用户发送资产。
3. 发送成功后才清零余额。
在第 2 步期间,攻击合约的 receive 函数可以再次调用 withdraw。由于第 3 步尚未执行,嵌套调用仍能读到原余额,于是同一份余额被重复使用。
这里需要注意一个边界条件:如果示例在每层调用结束后使用 Solidity 0.8 的安全算术执行重复减法,外层回退时可能因下溢而导致整笔交易回滚。为了准确复现经典重入,下面的缺陷版本采用“外部调用后统一清零”的写法。审计时不能因为某个 PoC 被算术检查意外阻止,就断言整个调用顺序是安全的;代码升级、unchecked、不同记账方式或其他回调入口都可能重新暴露问题。
三、前置条件
建议准备隔离的本地环境:
1. 已安装 Foundry,并能运行 forge、cast 和 anvil。
2. 使用独立目录,不导入生产私钥或真实助记词。
3. 固定 Solidity 与依赖版本,避免测试结果随依赖更新漂移。
4. 可选安装 Slither,用于入口点和外部调用的辅助扫描。
初始化命令:
forge init vault-audit-lab
cd vault-audit-lab
forge install OpenZeppelin/openzeppelin-contracts --no-commit
forge build
如果项目使用较新的 Git,安装依赖时也可以去掉 --no-commit,并按团队的子模块管理规则提交锁定信息。不要无条件复制安装参数到已有仓库。
四、建立本地缺陷合约
在 src/Vault.sol 中创建以下仅用于本地实验的合约:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Vault {
mapping(address => uint256) public balances;
function deposit() external payable {
require(msg.value > 0);
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0);
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
balances[msg.sender] = 0;
}
}
危险点不在 call 本身,而在于关键状态 balances 在外部调用之后才更新。审计报告应描述可违反的安全属性,而不只是写“存在重入”:同一用户余额在一次提现完成前可以被多个嵌套调用重复消费,进而侵占其他存款人的资产。
五、分步骤实践
步骤 1:枚举入口点和信任边界
先列出所有能够改变资产或账本的入口:
- deposit:接收 ETH,并增加调用者余额。
- withdraw:读取余额、发送 ETH、清零余额。
- receive/fallback:当前合约没有,但仍应检查继承层和代理实现。
- 管理员入口:当前示例没有;真实项目还要检查暂停、救援、升级和费用提取函数。
对 withdraw 继续标记:
- 外部调用目标由 msg.sender 控制。
- 转账金额依赖调用前读取的状态。
- 状态在外部调用后更新。
- 没有互斥锁。
可用 Slither 辅助确认,但扫描器只能提供线索:
slither . --exclude-dependencies
不要把“工具无告警”视为安全证明。代理、内联汇编、跨函数重入和业务级会计错误经常需要人工建模。
步骤 2:编写本地对抗合约
在 test/Vault.t.sol 中定义测试专用接收方:
contract Reenter {
Vault public immutable vault;
constructor(Vault target) {
vault = target;
}
function attack() external payable {
require(msg.value == 1 ether);
vault.deposit{value: 1 ether}();
vault.withdraw();
}
receive() external payable {
if (address(vault).balance >= 1 ether) {
vault.withdraw();
}
}
}
receive 每收到 1 ETH 就再次提现。条件使用目标合约剩余余额,避免最后一次尝试因余额不足导致整个调用链回滚。这只是本地最小复现,不包含真实地址、私钥或链上攻击流程。
步骤 3:构造可重复的单元测试
测试流程应显式准备受害者存款和攻击者本金:
function test_ReentrancyDrainsOtherDeposits() public {
Vault vault = new Vault();
Reenter receiver = new Reenter(vault);
vm.deal(address(this), 5 ether);
vault.deposit{value: 5 ether}();
vm.deal(address(receiver), 1 ether);
receiver.attack{value: 1 ether}();
assertEq(address(vault).balance, 0);
assertEq(address(receiver).balance, 6 ether);
assertEq(vault.balances(address(this)), 5 ether);
}
最后一个断言非常重要:金库链上余额已经为零,但账本仍声称原存款人拥有 5 ETH。这说明问题不仅是资金流出,还造成了资产与负债严重不一致。
运行:
forge test --match-test test_ReentrancyDrainsOtherDeposits -vvv
-vvv 可以显示调用轨迹。审计时应沿轨迹确认每一层 withdraw 读取到的余额、外部调用位置以及清零发生的时间,而不是只观察最终余额。
步骤 4:使用 CEI 与互斥锁修复
CEI 即 Checks-Effects-Interactions:先检查条件,再完成内部状态变化,最后进行外部交互。修复版本还可以加入 ReentrancyGuard,形成纵深防御:
// 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 balances;
function deposit() external payable {
require(msg.value > 0);
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0);
balances[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
}
}
如果发送失败,require 会回滚整个交易,因此提前清零的余额也会恢复。这个行为应通过测试确认,而不是凭直觉判断。
步骤 5:验证正常路径与失败恢复
修复后至少增加以下测试:
1. 用户存入 2 ETH 后只能取回 2 ETH。
2. 第二次提现因余额为零而失败。
3. 恶意接收方尝试递归时,不能提走其他用户资金。
4. 接收方主动拒绝 ETH 时,提现整体回滚,用户余额不丢失。
5. 多用户交错充值和提现后,合约资产仍覆盖账面负债。
拒收 ETH 的测试合约可以让 receive 直接 revert。调用 withdraw 后使用 vm.expectRevert,并检查 balances[user] 与调用前一致。
步骤 6:加入模糊测试与不变量
单元测试只能覆盖预先想到的序列。建议将 deposit、withdraw 和多个参与者封装为 Handler,再让 Foundry 随机生成调用序列。可定义以下属性:
P1:address(vault).balance >= ghostLiabilities。
P2:任一用户成功提现后,其账面余额应为零。
P3:一次失败的提现不能改变用户余额和总负债。
P4:除充值、提现及明确授权的管理操作外,合约资产不能无原因减少。
由于 mapping 无法直接枚举,测试 Handler 可以维护 ghostLiabilities:成功充值时增加,成功提现时减少。它是测试侧的影子会计值,不应混入生产逻辑。
foundry.toml 可设置:
[fuzz]
runs = 1000
[invariant]
runs = 256
depth = 64
fail_on_revert = false
随后执行:
forge test
forge test --match-test testFuzz -vv
forge test --match-contract VaultInvariant -vv
fail_on_revert 是否设置为 false 取决于 Handler 设计。如果 Handler 已约束无效输入,开启 true 更容易发现意外回滚;如果随机序列允许余额不足等预期失败,则应捕获并分类失败,避免噪声淹没真实问题。
六、从重入扩展到完整提现审计
真实项目中,提现路径还应检查:
1. 跨函数重入:withdraw 回调期间调用 transferShare、claim、borrow 或管理函数,可能绕过仅保护单个入口的锁。
2. 只读重入:外部协议在中间状态读取价格、份额或抵押率,即使不直接修改当前合约,也可能基于错误状态执行操作。
3. ERC-20 非标准行为:部分代币不返回布尔值,或转账收取手续费。应使用安全封装,并根据实际到账额记账。
4. 份额舍入:资产与份额转换的向上、向下取整方向必须明确,尤其是首笔存款、极小金额和接近零总供应量时。
5. 授权与签名:检查 nonce、deadline、chainId、验证合约地址和签名域,防止重放。
6. 代理升级:确认初始化函数只能执行一次,升级权限受控,存储布局兼容。
7. 紧急暂停:暂停应覆盖真正的资产出口,同时保留明确的恢复方案,避免管理员永久冻结用户资产。
七、风险与注意事项
1. 不要用 address(this).balance == totalLiabilities 作为通用不变量。合约可能通过强制转账、直接资助或其他非记账路径收到额外 ETH,更稳妥的关系通常是资产大于等于负债。
2. nonReentrant 不是业务逻辑正确性的替代品。多个未受保护入口、外部协议回调和错误会计仍可能造成损失。
3. CEI 也不是绝对规则。如果外部调用结果决定最终记账,必须设计明确的暂存状态、回滚语义或拉取式结算。
4. 测试环境要固定区块、编译器、依赖和分叉高度。若必须使用主网分叉,只做授权的防御验证,并避免广播交易。
5. 审计报告应记录可复现条件、受影响资产、最坏结果、修复建议和回归测试,而不是只给出漏洞名称。
6. 低级 call 转发可用 gas,接收方逻辑不可控。不要依赖固定 gas 成本作为主要重入防线。
八、常见问题
问:把 call 改成 transfer 是否足够?
答:不建议把固定 gas 限制当作核心安全机制。执行成本可能随协议规则变化,复杂接收方也可能因此无法收款。更可靠的方案是正确记账、CEI、必要的互斥锁和完整测试。
问:已经使用 nonReentrant,为什么还要先更新状态?
答:两者解决层次不同。CEI 保证状态机在外部交互前进入一致状态;互斥锁限制递归入口。组合使用更容易抵抗后续代码变化,也便于人工审计。
问:扫描器没有报告重入,是否可以上线?
答:不能。扫描器通常无法完整理解跨合约业务约束、代理实现、复杂汇编和经济攻击。上线判断至少还需要人工审查、测试、权限核验、依赖检查和部署配置复核。
问:如何判断 PoC 是真实漏洞而不是测试假象?
答:确认 PoC 没有使用生产环境不存在的权限或状态修改作弊码;确认资产变化来自公开可达入口;确认漏洞在目标编译器和依赖版本下可复现;再编写修复后的反向测试。vm.deal 可以用于准备本金,但不能用它直接修改被测合约余额后冒充攻击结果。
九、总结
高质量智能合约审计应从资产、负债、权限和状态转换出发。对于提现路径,核心问题是外部控制权转移前后,账本是否始终处于不可重复消费的一致状态。实践中可以先枚举入口和信任边界,再用本地对抗合约复现调用链,随后采用 CEI 与互斥锁修复,并通过失败恢复测试、模糊测试和不变量测试验证修复没有引入新问题。
最终交付物不应只是一个重入告警,而应包含明确的安全属性、最小复现、边界条件、修复代码和可持续运行的回归测试。这样才能把一次漏洞修复转化为长期有效的工程防线。
智能合约安全审计不能只依赖漏洞关键词扫描。对于托管 ETH、ERC-20 或份额凭证的合约,更有效的方法是先绘制资产流和状态机,再将安全假设转换为可执行测试。提现路径尤其关键,因为它同时涉及权限、余额核算、外部调用、回调重入、失败恢复和会计一致性。
本文以本地 Foundry 靶场中的 ETH 金库为例,演示如何发现并修复重入漏洞,并进一步建立适合日常审计的检查流程。所有测试仅面向本地代码和授权环境,不应对未获授权的链上合约进行探测或交易尝试。
审计目标包括:
1. 用户不能提取超过其账面余额的资产。
2. 任意外部调用发生时,关键状态不能处于可重复消费的中间状态。
3. 单次操作失败后,资产与账本应整体回滚,不产生半完成状态。
4. 合约持有资产应覆盖全部用户负债,即资产余额不小于可提现负债。
5. 修复措施不能破坏正常充值、提现和异常回滚流程。
二、漏洞原理
EVM 中的外部调用不是单纯的转账动作。使用 call 发送 ETH,或调用 ERC-777、带钩子的代币、未知适配器时,控制权可能暂时交给接收方。接收方可以在原函数尚未完成时再次调用当前合约。
一个典型的错误顺序是:
1. 读取用户余额。
2. 向用户发送资产。
3. 发送成功后才清零余额。
在第 2 步期间,攻击合约的 receive 函数可以再次调用 withdraw。由于第 3 步尚未执行,嵌套调用仍能读到原余额,于是同一份余额被重复使用。
这里需要注意一个边界条件:如果示例在每层调用结束后使用 Solidity 0.8 的安全算术执行重复减法,外层回退时可能因下溢而导致整笔交易回滚。为了准确复现经典重入,下面的缺陷版本采用“外部调用后统一清零”的写法。审计时不能因为某个 PoC 被算术检查意外阻止,就断言整个调用顺序是安全的;代码升级、unchecked、不同记账方式或其他回调入口都可能重新暴露问题。
三、前置条件
建议准备隔离的本地环境:
1. 已安装 Foundry,并能运行 forge、cast 和 anvil。
2. 使用独立目录,不导入生产私钥或真实助记词。
3. 固定 Solidity 与依赖版本,避免测试结果随依赖更新漂移。
4. 可选安装 Slither,用于入口点和外部调用的辅助扫描。
初始化命令:
forge init vault-audit-lab
cd vault-audit-lab
forge install OpenZeppelin/openzeppelin-contracts --no-commit
forge build
如果项目使用较新的 Git,安装依赖时也可以去掉 --no-commit,并按团队的子模块管理规则提交锁定信息。不要无条件复制安装参数到已有仓库。
四、建立本地缺陷合约
在 src/Vault.sol 中创建以下仅用于本地实验的合约:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Vault {
mapping(address => uint256) public balances;
function deposit() external payable {
require(msg.value > 0);
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0);
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
balances[msg.sender] = 0;
}
}
危险点不在 call 本身,而在于关键状态 balances 在外部调用之后才更新。审计报告应描述可违反的安全属性,而不只是写“存在重入”:同一用户余额在一次提现完成前可以被多个嵌套调用重复消费,进而侵占其他存款人的资产。
五、分步骤实践
步骤 1:枚举入口点和信任边界
先列出所有能够改变资产或账本的入口:
- deposit:接收 ETH,并增加调用者余额。
- withdraw:读取余额、发送 ETH、清零余额。
- receive/fallback:当前合约没有,但仍应检查继承层和代理实现。
- 管理员入口:当前示例没有;真实项目还要检查暂停、救援、升级和费用提取函数。
对 withdraw 继续标记:
- 外部调用目标由 msg.sender 控制。
- 转账金额依赖调用前读取的状态。
- 状态在外部调用后更新。
- 没有互斥锁。
可用 Slither 辅助确认,但扫描器只能提供线索:
slither . --exclude-dependencies
不要把“工具无告警”视为安全证明。代理、内联汇编、跨函数重入和业务级会计错误经常需要人工建模。
步骤 2:编写本地对抗合约
在 test/Vault.t.sol 中定义测试专用接收方:
contract Reenter {
Vault public immutable vault;
constructor(Vault target) {
vault = target;
}
function attack() external payable {
require(msg.value == 1 ether);
vault.deposit{value: 1 ether}();
vault.withdraw();
}
receive() external payable {
if (address(vault).balance >= 1 ether) {
vault.withdraw();
}
}
}
receive 每收到 1 ETH 就再次提现。条件使用目标合约剩余余额,避免最后一次尝试因余额不足导致整个调用链回滚。这只是本地最小复现,不包含真实地址、私钥或链上攻击流程。
步骤 3:构造可重复的单元测试
测试流程应显式准备受害者存款和攻击者本金:
function test_ReentrancyDrainsOtherDeposits() public {
Vault vault = new Vault();
Reenter receiver = new Reenter(vault);
vm.deal(address(this), 5 ether);
vault.deposit{value: 5 ether}();
vm.deal(address(receiver), 1 ether);
receiver.attack{value: 1 ether}();
assertEq(address(vault).balance, 0);
assertEq(address(receiver).balance, 6 ether);
assertEq(vault.balances(address(this)), 5 ether);
}
最后一个断言非常重要:金库链上余额已经为零,但账本仍声称原存款人拥有 5 ETH。这说明问题不仅是资金流出,还造成了资产与负债严重不一致。
运行:
forge test --match-test test_ReentrancyDrainsOtherDeposits -vvv
-vvv 可以显示调用轨迹。审计时应沿轨迹确认每一层 withdraw 读取到的余额、外部调用位置以及清零发生的时间,而不是只观察最终余额。
步骤 4:使用 CEI 与互斥锁修复
CEI 即 Checks-Effects-Interactions:先检查条件,再完成内部状态变化,最后进行外部交互。修复版本还可以加入 ReentrancyGuard,形成纵深防御:
// 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 balances;
function deposit() external payable {
require(msg.value > 0);
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0);
balances[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
}
}
如果发送失败,require 会回滚整个交易,因此提前清零的余额也会恢复。这个行为应通过测试确认,而不是凭直觉判断。
步骤 5:验证正常路径与失败恢复
修复后至少增加以下测试:
1. 用户存入 2 ETH 后只能取回 2 ETH。
2. 第二次提现因余额为零而失败。
3. 恶意接收方尝试递归时,不能提走其他用户资金。
4. 接收方主动拒绝 ETH 时,提现整体回滚,用户余额不丢失。
5. 多用户交错充值和提现后,合约资产仍覆盖账面负债。
拒收 ETH 的测试合约可以让 receive 直接 revert。调用 withdraw 后使用 vm.expectRevert,并检查 balances[user] 与调用前一致。
步骤 6:加入模糊测试与不变量
单元测试只能覆盖预先想到的序列。建议将 deposit、withdraw 和多个参与者封装为 Handler,再让 Foundry 随机生成调用序列。可定义以下属性:
P1:address(vault).balance >= ghostLiabilities。
P2:任一用户成功提现后,其账面余额应为零。
P3:一次失败的提现不能改变用户余额和总负债。
P4:除充值、提现及明确授权的管理操作外,合约资产不能无原因减少。
由于 mapping 无法直接枚举,测试 Handler 可以维护 ghostLiabilities:成功充值时增加,成功提现时减少。它是测试侧的影子会计值,不应混入生产逻辑。
foundry.toml 可设置:
[fuzz]
runs = 1000
[invariant]
runs = 256
depth = 64
fail_on_revert = false
随后执行:
forge test
forge test --match-test testFuzz -vv
forge test --match-contract VaultInvariant -vv
fail_on_revert 是否设置为 false 取决于 Handler 设计。如果 Handler 已约束无效输入,开启 true 更容易发现意外回滚;如果随机序列允许余额不足等预期失败,则应捕获并分类失败,避免噪声淹没真实问题。
六、从重入扩展到完整提现审计
真实项目中,提现路径还应检查:
1. 跨函数重入:withdraw 回调期间调用 transferShare、claim、borrow 或管理函数,可能绕过仅保护单个入口的锁。
2. 只读重入:外部协议在中间状态读取价格、份额或抵押率,即使不直接修改当前合约,也可能基于错误状态执行操作。
3. ERC-20 非标准行为:部分代币不返回布尔值,或转账收取手续费。应使用安全封装,并根据实际到账额记账。
4. 份额舍入:资产与份额转换的向上、向下取整方向必须明确,尤其是首笔存款、极小金额和接近零总供应量时。
5. 授权与签名:检查 nonce、deadline、chainId、验证合约地址和签名域,防止重放。
6. 代理升级:确认初始化函数只能执行一次,升级权限受控,存储布局兼容。
7. 紧急暂停:暂停应覆盖真正的资产出口,同时保留明确的恢复方案,避免管理员永久冻结用户资产。
七、风险与注意事项
1. 不要用 address(this).balance == totalLiabilities 作为通用不变量。合约可能通过强制转账、直接资助或其他非记账路径收到额外 ETH,更稳妥的关系通常是资产大于等于负债。
2. nonReentrant 不是业务逻辑正确性的替代品。多个未受保护入口、外部协议回调和错误会计仍可能造成损失。
3. CEI 也不是绝对规则。如果外部调用结果决定最终记账,必须设计明确的暂存状态、回滚语义或拉取式结算。
4. 测试环境要固定区块、编译器、依赖和分叉高度。若必须使用主网分叉,只做授权的防御验证,并避免广播交易。
5. 审计报告应记录可复现条件、受影响资产、最坏结果、修复建议和回归测试,而不是只给出漏洞名称。
6. 低级 call 转发可用 gas,接收方逻辑不可控。不要依赖固定 gas 成本作为主要重入防线。
八、常见问题
问:把 call 改成 transfer 是否足够?
答:不建议把固定 gas 限制当作核心安全机制。执行成本可能随协议规则变化,复杂接收方也可能因此无法收款。更可靠的方案是正确记账、CEI、必要的互斥锁和完整测试。
问:已经使用 nonReentrant,为什么还要先更新状态?
答:两者解决层次不同。CEI 保证状态机在外部交互前进入一致状态;互斥锁限制递归入口。组合使用更容易抵抗后续代码变化,也便于人工审计。
问:扫描器没有报告重入,是否可以上线?
答:不能。扫描器通常无法完整理解跨合约业务约束、代理实现、复杂汇编和经济攻击。上线判断至少还需要人工审查、测试、权限核验、依赖检查和部署配置复核。
问:如何判断 PoC 是真实漏洞而不是测试假象?
答:确认 PoC 没有使用生产环境不存在的权限或状态修改作弊码;确认资产变化来自公开可达入口;确认漏洞在目标编译器和依赖版本下可复现;再编写修复后的反向测试。vm.deal 可以用于准备本金,但不能用它直接修改被测合约余额后冒充攻击结果。
九、总结
高质量智能合约审计应从资产、负债、权限和状态转换出发。对于提现路径,核心问题是外部控制权转移前后,账本是否始终处于不可重复消费的一致状态。实践中可以先枚举入口和信任边界,再用本地对抗合约复现调用链,随后采用 CEI 与互斥锁修复,并通过失败恢复测试、模糊测试和不变量测试验证修复没有引入新问题。
最终交付物不应只是一个重入告警,而应包含明确的安全属性、最小复现、边界条件、修复代码和可持续运行的回归测试。这样才能把一次漏洞修复转化为长期有效的工程防线。