查询 25 次,耗时 0.1970 秒

从本地复现到系统修复:Solidity 重入漏洞的原理、审计方法与 Foundry 实践

180647004

New member

180647004 Rep
0
0
0
Rep
0
180647004 Vouches
Vouches
0
1 MONTH
1 1 MONTH OF SERVICE
180647004 获得
0
0
0
获得
0
180647004 凭证
0
0
0
凭证
0
主题
9
点赞
0
HakB
1 月
在社区成长 1 月
LEVEL 1 1 XP
 
一、背景

重入漏洞是智能合约中最典型、影响也最持久的一类安全问题。它并不只是“重复调用 withdraw”这么简单,其根本原因是:合约在自身状态尚未稳定时,将控制权交给了外部地址,而外部地址可以在原调用结束前再次进入当前合约或关联协议。

现代 DeFi 系统通常包含金库、借贷池、质押、奖励分发、价格预言机及 ERC-777 回调等多个组件,因此审计时不能只检查单个函数。跨函数重入、跨合约重入和只读重入,往往比传统提款重入更隐蔽。

本文仅在本地 Foundry 环境中构造授权实验,不涉及任何真实协议或未授权目标。

二、漏洞原理

以提款函数为例,如果执行顺序如下:

1. 读取用户余额;
2. 向用户地址发送 ETH;
3. 最后才把用户余额清零。

第二步执行低级 call 时,控制权会转移到接收方。如果接收方是合约,其 receive 或 fallback 函数可以再次调用提款函数。因为原调用尚未执行余额清零,重入调用仍能读取旧余额,于是同一份余额可能被重复提取。

危险模式可以概括为:读取旧状态 → 外部交互 → 更新状态。

安全设计通常遵循 Checks-Effects-Interactions,也就是 CEI:

1. Checks:检查权限、余额、参数和协议状态;
2. Effects:先完成内部记账和状态更新;
3. Interactions:最后再调用外部合约。

需要注意,CEI 很重要,但并非万能。如果多个入口共享同一状态、回调期间存在其他可调用函数,或者外部协议依赖中间状态,仍需配合重入锁、状态机及架构级隔离。

三、实验前置条件

建议在隔离目录中完成以下操作:

1. 安装 Git;
2. 安装 Foundry;
3. 使用本地测试账户,不连接主网或真实资产;
4. 确认所有测试代码均属于自己或已获得明确授权。

初始化项目:

mkdir reentrancy-lab
cd reentrancy-lab
forge init
forge test

目录中主要使用:

src/:存放实验合约;
test/:存放测试与重入探针;
foundry.toml:配置 Solidity 编译器、模糊测试次数等。

四、构造存在缺陷的本地金库

创建 src/VulnerableVault.sol:

pragma solidity ^0.8.24;

contract VulnerableVault {
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);

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

credit[msg.sender] = 0;
}

function totalAssets() external view returns (uint256) {
return address(this).balance;
}
}

这里的关键问题不是 call 本身,而是状态更新发生在 call 之后。外部地址在回调期间再次进入 withdraw 时,credit[msg.sender] 仍为原值。

五、使用 Foundry 在本地复现

创建 test/Reentrancy.t.sol。下面的 ReentryProbe 只是本地测试探针,用于验证控制流是否可以重入:

pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {VulnerableVault} from "../src/VulnerableVault.sol";

contract ReentryProbe {
VulnerableVault public immutable vault;
uint256 public chunk;
uint256 public callbacks;

constructor(VulnerableVault target) {
vault = target;
}

function run() external payable {
chunk = msg.value;
vault.deposit{value: msg.value}();
vault.withdraw();
}

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

contract ReentrancyTest is Test {
VulnerableVault vault;
ReentryProbe probe;
address depositor = address(0xBEEF);

function setUp() public {
vault = new VulnerableVault();
probe = new ReentryProbe(vault);

vm.deal(depositor, 5 ether);
vm.prank(depositor);
vault.deposit{value: 5 ether}();
}

function test_LocalReentrancyReproduction() public {
vm.deal(address(this), 1 ether);
probe.run{value: 1 ether}();

assertEq(address(probe).balance, 4 ether);
assertEq(address(vault).balance, 2 ether);
}
}

执行:

forge test -vvv

如需观察更完整的调用轨迹:

forge test --match-test test_LocalReentrancyReproduction -vvvv

初始情况下,其他测试账户存入 5 ETH,探针再存入 1 ETH,金库合计为 6 ETH。由于回调最多重入三次,探针总共收到 4 ETH,金库剩余 2 ETH。这个断言说明同一笔内部记账被重复使用,而不是依赖肉眼查看日志。

六、第一层修复:先更新状态,再进行交互

将提款逻辑修改为:

function withdraw() external {
uint256 amount = credit[msg.sender];
require(amount > 0);

credit[msg.sender] = 0;

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

修复后,第一次进入 withdraw 时余额会先被清零。接收方即使在回调中再次调用,第二次调用也会因余额为零而回滚。外部转账如果失败,整个交易回滚,之前的清零操作也会随交易一起撤销,因此不会凭空丢失用户余额。

修改完成后重新运行:

forge test -vvvv

原来的复现测试通常会因为重入调用回滚而失败。这时不应简单删除测试,而应把它改造成安全回归测试,验证:

1. 重入探针不能获得超过自身存款的资金;
2. 其他存款人的资金不减少;
3. 失败交易不会破坏内部记账;
4. 正常用户仍然能够完成提款。

七、第二层修复:增加重入锁

对于包含多个外部入口的资金合约,建议再增加互斥锁。下面是便于理解的最小实现:

uint256 private lockState = 1;

modifier nonReentrant() {
require(lockState == 1);
lockState = 2;
_;
lockState = 1;
}

function withdraw() external nonReentrant {
uint256 amount = credit[msg.sender];
require(amount > 0);
credit[msg.sender] = 0;

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

生产项目更适合使用经过充分审阅、版本固定的安全库,而不是随意复制网上代码。引入依赖时应锁定版本、检查变更记录,并确认代理合约场景下的存储布局和初始化方式。

重入锁也不能代替 CEI。锁主要限制调用路径,而 CEI 让状态在外部观察前进入一致状态。两者结合通常比单独使用其中一种更稳妥。

八、审计时如何系统定位重入风险

第一步:枚举所有外部交互点。

重点搜索 call、delegatecall、staticcall、send、transfer,以及 ERC-20、ERC-721、ERC-777 的转账或回调接口。不要因为调用的是“标准代币”就默认安全,恶意或非标准代币可以在 transfer、transferFrom 或钩子中执行任意逻辑。

第二步:标记外部调用前后读写的状态。

例如余额、份额、债务、抵押率、奖励索引、总供应量、价格缓存和清算标志。如果这些状态在外部调用后才更新,就需要进一步判断回调能否利用旧值。

第三步:建立共享状态的函数集合。

假设 withdraw 有 nonReentrant,但 claimReward 没有,而两个函数都依赖 userShares,那么回调可能从 withdraw 转入 claimReward,形成跨函数重入。审计不能只问“能否再次进入当前函数”,还要问“能否进入任何读取同一状态的函数”。

第四步:检查只读入口。

view 函数不会写状态,但它可能在协议处于中间状态时返回失真的价格、份额或资产总量。如果另一个协议在回调期间读取该值并据此放贷或兑换,就可能出现只读重入。防御方法包括避免在外部调用前暴露中间状态、对敏感读取增加状态阶段检查,以及不要把可操纵的瞬时值直接作为预言机。

第五步:检查代理与初始化。

可升级合约中的锁变量必须正确初始化,并保持存储布局兼容。实现合约构造函数设置的变量,不一定会自动出现在代理存储中。应使用适合代理模式的初始化函数和对应安全库。

九、辅助静态分析与测试策略

可以在本地环境安装 Slither 后执行:

python3 -m venv .venv
source .venv/bin/activate
pip install slither-analyzer
slither .

静态分析告警只是线索,不等于漏洞结论。审计人员需要确认:

1. 外部调用是否真的可由攻击者控制;
2. 回调路径是否能够到达敏感入口;
3. 重入时读取的状态是否仍有经济价值;
4. 权限、暂停状态或锁是否覆盖所有路径;
5. 回滚与异常代币行为是否会改变结论。

还应增加模糊测试和不变量测试。适合金库的不变量包括:

内部用户余额总和不应超过合约可支配资产;
任意用户不能提取超过其已确认份额的资产;
一次失败提款不应改变用户余额;
回调前后总资产与总负债关系应保持协议定义的一致性;
暂停状态下禁止的资金路径不能通过其他函数绕过。

可在 foundry.toml 中提高模糊测试次数,例如:

[fuzz]
runs = 1000

然后执行:

forge test --fuzz-runs 1000

十、风险与注意事项

1. 不要把 transfer 的 2300 gas 限制当成长期安全边界。Gas 成本和执行环境可能变化,而且代币回调、跨函数路径并不依赖传统 ETH transfer。

2. 不要只给 withdraw 加锁。赎回、借款、领取奖励、清算、迁移和紧急退出等函数可能共享相同状态。

3. nonReentrant 函数通常不能直接互相调用。常见设计是让外部入口加锁,再把核心逻辑放入 private 或 internal 函数,但仍需检查内部函数是否会进行外部调用。

4. 使用 pull payment 可以降低主动向大量地址推送资金的风险,但领取函数本身仍需采用 CEI 和失败隔离。

5. delegatecall 会在调用方存储上下文中执行外部代码,风险不仅是重入,还包括任意存储修改。除非存在严格白名单和清晰的升级边界,否则应高度谨慎。

6. 测试中的探针只应部署在本地链、私有靶场或明确授权的测试网环境。即使怀疑真实协议存在问题,也应遵循其漏洞披露和赏金计划规则。

十一、常见问题

问:只使用 OpenZeppelin 的 ReentrancyGuard 就一定安全吗?
答:不一定。它能阻止受同一锁保护的同步重入,但无法自动修复未加锁入口、错误记账、预言机操纵、异步跨链消息或逻辑层面的重复结算。仍需结合 CEI、权限控制和不变量测试。

问:view 函数为什么也会与重入有关?
答:view 不能修改状态,但可以在协议状态尚未完成更新时暴露临时值。外部协议如果信任该临时值,就可能作出错误的经济决策。

问:call 是否应该全部禁止?
答:不是。call 是发送 ETH 和调用外部合约的常用机制,问题在于调用前后的状态管理、目标是否可信、返回值是否处理,以及回调期间开放了哪些入口。

问:先清零再 call,如果 call 失败会不会导致用户资产丢失?
答:如果随后使用 require 检查返回值并让交易整体回滚,清零操作也会回滚。真正需要关注的是某些代码是否捕获失败后继续执行,或者把失败状态错误地记录为成功。

问:静态工具没有告警,是否可以认为没有重入风险?
答:不能。复杂的跨合约、代理、回调和经济组合路径可能超出自动规则的识别能力。工具适合扩大覆盖面,但不能替代人工数据流分析与动态验证。

十二、总结

重入漏洞的核心是“在状态未稳定时交出控制权”。有效防御不能停留在把某一行代码向上移动,而应形成完整流程:枚举外部交互点,追踪共享状态,分析所有可回调入口,采用 CEI 与重入锁,覆盖跨函数和只读重入,再通过本地复现、回归测试、模糊测试及不变量测试验证修复。

对于资金类智能合约,最重要的审计问题始终是:外部代码获得控制权时,当前状态是否已经一致;如果此时调用另一个公开入口,是否能够重复使用旧余额、旧份额或临时价格。围绕这两个问题建立调用图和状态读写表,通常能够比单纯搜索 withdraw 或 call 更早发现真实风险。
 
顶部