查询 32 次,耗时 0.2430 秒

智能合约重入漏洞实战审计:从调用链分析到 Foundry 本地验证与修复

ghhjxf

New member

ghhjxf Rep
0
0
0
Rep
0
ghhjxf Vouches
Vouches
0
2 MONTHS
2 2 MONTHS OF SERVICE
ghhjxf 获得
0
0
0
获得
0
ghhjxf 凭证
0
0
0
凭证
0
主题
6
点赞
0
HakB
2 月
在社区成长 2 月
LEVEL 1 1 XP
 
一、背景

重入漏洞是智能合约审计中必须优先排查的问题。它并不局限于“重复提款”这一种表现:只要合约在关键状态尚未完成更新时,把控制权交给外部地址,外部地址就可能通过回调再次进入当前函数或其他共享状态的函数,破坏原有业务假设。

从安全边界看,任何外部调用都应被视为不可信,包括普通 ETH 转账、低级 call、ERC777 hooks、ERC721/ERC1155 的安全转账回调、预言机回调、跨链消息接收函数,以及对未知实现合约的接口调用。即使前端或移动端限制了按钮点击次数,也无法阻止攻击者绕过 APP,直接构造链上交易。因此,重入防护必须落实在合约端。

本文仅使用本地 Foundry 环境进行验证,不涉及任何真实协议或未授权目标。

二、漏洞原理

典型提款逻辑可以抽象为三个步骤:

1. 检查调用者是否有余额;
2. 向调用者发送 ETH;
3. 将调用者余额清零。

问题出在第二步。Solidity 的低级 call 会把执行权交给接收方。如果接收方是合约,其 receive 或 fallback 函数可以再次调用提款函数。由于第三步还没有执行,原合约看到的余额仍然有效,于是会再次付款。

存在风险的简化代码如下:

pragma solidity ^0.8.24;

contract VulnerableVault {
mapping(address => uint256) public balances;

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

function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "no balance");

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

balances[msg.sender] = 0;
}
}

这里的核心缺陷不是使用 call 本身,而是“先外部交互、后更新状态”。在 call 执行期间,balances[msg.sender] 尚未归零。

三、漏洞成立的前置条件

并非看到外部调用就一定可以利用。审计时至少需要确认以下条件:

1. 调用目标可由攻击者控制,或者目标合约存在可触发的回调机制;
2. 外部调用发生在关键状态更新之前;
3. 重入后仍能通过权限、余额、时间或状态机检查;
4. 合约内存在足够的可转移资产,或重入能造成份额、奖励、投票权等账务异常;
5. 没有有效的互斥锁,也没有在所有相关入口执行 Checks-Effects-Interactions;
6. 重入路径没有因为 gas、调用深度、暂停状态等因素自然终止。

需要特别注意:攻击者第一次存入的金额可以很小,但如果提款金额根据内部余额计算,而合约中同时保存了其他用户资金,就可能出现用同一份余额记录多次提款的结果。

四、搭建本地审计环境

建议在隔离目录内使用 Foundry:

forge init reentrancy-lab
cd reentrancy-lab
forge build

将待测合约放入 src/VulnerableVault.sol。随后编写仅用于本地验证的测试攻击合约:

pragma solidity ^0.8.24;

import "../src/VulnerableVault.sol";

contract LocalReentryProbe {
VulnerableVault public immutable vault;
uint256 public callbackCount;

constructor(address target) {
vault = VulnerableVault(target);
}

function run() external payable {
require(msg.value == 1 ether, "send 1 ether");
vault.deposit{value: 1 ether}();
vault.withdraw();
}

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

这个探针设置了明确的回调次数上限,避免本地测试因为无限递归而难以定位问题。它只用于证明控制流能够在余额清零前重新进入 withdraw。

五、分步骤验证

步骤 1:建立初始资产状态

在 Foundry 测试中,先让一个普通测试账户向金库存入 10 ether,再让探针合约存入 1 ether。这样可以验证漏洞影响的不只是攻击者自己的存款。

步骤 2:记录关键状态

调用前至少记录:

address(vault).balance
vault.balances(address(probe))
address(probe).balance

步骤 3:触发本地探针

使用 Foundry 的 vm.deal 为测试地址分配本地测试余额,然后调用 probe.run{value: 1 ether}()。执行:

forge test -vvvv

-vvvv 可以显示更详细的调用轨迹。审计时重点观察是否出现如下嵌套结构:

VulnerableVault.withdraw
→ LocalReentryProbe.receive
→ VulnerableVault.withdraw
→ LocalReentryProbe.receive

如果同一笔交易中 withdraw 被重复进入,并且每次读取到的 balances[probe] 仍为 1 ether,就证明状态更新顺序存在问题。

步骤 4:断言安全属性

不要只断言“攻击合约余额增加”,还应验证业务不变量:

1. 单个账户累计提款不得超过其已确认存款;
2. 所有用户可申领余额总和不得大于金库可用资产;
3. 一次提款执行后,用户内部余额应立即减少;
4. 任意外部回调期间,不应观察到可再次消费的旧状态。

可以进一步运行模糊测试:

forge test --fuzz-runs 10000
forge coverage

模糊测试适合覆盖不同存款金额、提款金额和调用顺序,但不能代替人工检查调用链。

六、修复方案

推荐同时使用“状态先更新”和“重入锁”,形成纵深防御。修复示例如下:

pragma solidity ^0.8.24;

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SafeVault is ReentrancyGuard {
mapping(address => uint256) public balances;

function deposit() external payable {
require(msg.value > 0, "zero deposit");
balances[msg.sender] += msg.value;
}

function withdraw(uint256 amount) external nonReentrant {
uint256 current = balances[msg.sender];
require(amount > 0 && amount <= current, "invalid amount");

balances[msg.sender] = current - amount;

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

这里采用了 Checks-Effects-Interactions:先检查参数,再减少余额,最后执行外部转账。若转账失败,require 会回滚整笔交易,余额扣减也会一并回滚。nonReentrant 则用于阻止同一重入保护域内的嵌套调用。

如果业务允许,更稳健的设计是 Pull Payment:合约只记录用户可领取金额,由用户主动领取;同时将记账、结算和外部交互拆分清楚。需要注意,Pull Payment 只能降低复杂调用链风险,本身仍要正确更新领取状态。

七、系统化审计方法

1. 资产梳理:列出 ETH、ERC20、NFT、份额、质押凭证、奖励和跨链映射资产。
2. 入口梳理:检查 external、public、receive、fallback,以及代理合约转发后的实际入口。
3. 外部调用清单:搜索 call、delegatecall、staticcall、transfer、send、safeTransfer、safeMint 和第三方接口调用。
4. 状态顺序检查:确认余额、nonce、订单状态、已领取标记和份额是否在外部调用前更新。
5. 交叉函数检查:withdraw 加锁并不代表安全。如果 claim、redeem、borrow 等函数共享同一状态且未加锁,仍可能发生跨函数重入。
6. 权限检查:确认管理员、升级者、暂停者、预言机更新者和跨链消息验证者的权限边界。
7. 代理检查:对于可升级合约,还要检查初始化函数是否可重复调用、存储布局是否冲突,以及修复是否实际部署到了代理正在使用的实现。
8. 不变量测试:围绕总资产、总份额、用户余额、债务与抵押率建立可执行断言,而不是只测试正常流程。

可使用 Slither 进行辅助扫描:

pipx install slither-analyzer
slither . --filter-paths "lib|test"

静态工具的告警只能作为线索。某些外部调用已经正确更新状态,会产生误报;而跨函数重入、经济状态重入和依赖业务语义的漏洞,也可能不被工具发现。

八、容易遗漏的重入变体

1. 跨函数重入:函数 A 外部调用期间进入函数 B,B 读取了 A 尚未更新的共享状态。
2. 跨合约重入:金库、策略、奖励和价格模块分散在多个合约中,单个合约看似安全,但整体调用链存在旧状态窗口。
3. 只读重入:回调期间调用 view 函数读取临时不一致的数据,其他协议再依据该数据进行定价或结算。view 不修改状态,并不表示其返回值在任意调用阶段都安全。
4. Token Hook 重入:ERC777 的发送/接收 hook,以及 ERC721/ERC1155 的安全接收回调,都可能把控制权交给接收方。
5. 闪电贷放大:闪电贷通常不是根因,但可能临时放大资金规模,使会计或价格计算缺陷造成更严重后果。

九、风险与注意事项

不要简单把 call 替换为 transfer 并认为问题已经解决。依赖固定 gas 补贴容易产生兼容性和可用性问题,正确方向仍是安全的状态更新顺序、明确的失败处理和必要的重入锁。

不要只在 withdraw 上添加 nonReentrant 后停止审计。需要检查所有共享同一账本的入口。OpenZeppelin ReentrancyGuard 的 nonReentrant 函数也不能直接互相调用;常见做法是让外部入口加锁,再调用 private 或 internal 的内部实现。

不要用 address(this).balance 直接代表全部可分配余额。合约可能收到强制转入的 ETH,内部负债也不一定等于链上余额。应维护独立、可验证的会计状态。

移动端或网页端的交易确认页面只能改善用户知情程度,不能构成合约安全控制。合约不能假设所有调用都来自官方 APP,也不能依赖前端隐藏按钮、限制频率或校验输入。

十、常见问题

问:Solidity 0.8 已有整数溢出检查,是否能防止重入?
答:不能。溢出检查解决的是算术越界,重入属于外部调用期间的控制流和状态一致性问题。

问:只有转账 ETH 才会重入吗?
答:不是。代币 hooks、NFT 安全转账、外部策略、预言机回调和自定义接口调用都可能触发回调。

问:使用 nonReentrant 后是否可以忽略 CEI?
答:不建议。CEI 能缩短不一致状态窗口,重入锁则提供额外保护。两者结合更便于审计,也能降低后续新增入口时引入缺陷的概率。

问:静态扫描没有告警是否代表安全?
答:不代表。工具难以完整理解业务状态机、跨合约会计关系和经济不变量,必须结合人工审计、单元测试、模糊测试与不变量测试。

十一、总结

重入漏洞的本质是:合约在关键状态尚未落定时,将执行权交给了不可信外部实体。有效审计不能只搜索某个函数名,而应沿着“资产—状态—外部调用—回调入口—共享状态”完整建模。实践中应优先执行 CEI,按业务边界使用 ReentrancyGuard,检查跨函数与跨合约重入,并通过 Foundry 调用轨迹、模糊测试和不变量断言验证修复。前端限制不能替代链上安全控制,所有关键约束都必须由合约自身强制执行。
 

132222222222222qaz

New member

132222222222222qaz Rep
0
0
0
Rep
0
132222222222222qaz Vouches
Vouches
0
1 MONTH
1 1 MONTH OF SERVICE
132222222222222qaz 获得
0
0
0
获得
0
132222222222222qaz 凭证
0
0
0
凭证
0
主题
7
点赞
0
HakB
1 月
在社区成长 1 月
LEVEL 1 1 XP
 
补充一个修复回归测试细节:当前探针在 receive 中直接调用 withdraw,加入重入锁后,内层调用失败会向外传播,导致整笔提款回滚。因此,“攻击交易回滚”只能证明该路径被阻断,不能证明正常提款仍可用。建议增加一个捕获重入失败的探针,用 try/catch 或低级 call 记录内层调用结果,让外层收款正常返回;断言内层重入失败、外层仅到账一次、用户账面余额正确扣减。同时单测接收方主动拒收 ETH 的情况,确认提款回滚后余额恢复,避免修复引入资金不可提取的问题。
 
顶部