查询 25 次,耗时 0.7378 秒

从一次本地靶场实验掌握智能合约重入风险:原理、审计路径与可验证修复

xiaobinz08

New member

xiaobinz08 Rep
0
0
0
Rep
0
xiaobinz08 Vouches
Vouches
0
1 MONTH
1 1 MONTH OF SERVICE
xiaobinz08 获得
0
0
0
获得
0
xiaobinz08 凭证
0
0
0
凭证
0
主题
8
点赞
0
HakB
1 月
在社区成长 1 月
LEVEL 1 1 XP
 
一、背景与问题边界
重入(Reentrancy)是智能合约审计中最典型、也最容易被误判的一类漏洞。它并不只发生在某个函数向外部地址转账的场景,任何“合约在状态更新完成前调用不可信外部代码”的路径,都可能形成重入窗口。外部代码既可能是普通合约的 fallback/receive,也可能来自 ERC777、ERC1155、闪电贷回调、AMM 回调或代理合约钩子。

本文只使用本地 Foundry 项目和自建测试合约进行验证,不针对任何真实链上合约、地址或协议发起操作。目标是建立一套可复现的审计方法:先识别外部调用,再确认状态更新顺序,最后通过攻击者模型和不变量测试验证修复是否有效。

二、漏洞原理
典型错误代码如下:
contract VulnerableVault {
mapping(address => uint256) public balances;

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

function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, InsufficientBalance());
(bool ok, ) = msg.sender.call{value: amount}();
require(ok, TransferFailed());
balances[msg.sender] -= amount;
}
}

withdraw 先通过 call 把控制权交给 msg.sender,再减少 balances。若 msg.sender 是恶意合约,其 receive 函数可以在第一次 withdraw 尚未执行完毕时再次调用 withdraw。第二次调用读取到的仍是旧余额,于是同一笔账面余额可能被重复使用。这里的核心不是 call 本身必然危险,而是“交互(Interaction)发生在状态检查和状态生效之间”,导致合约暂时处于违反业务不变量的中间状态。

需要关注的业务不变量包括:用户可提取金额不能超过其内部余额;金库总资产应覆盖所有账户余额;一次成功提款后,提款人的余额必须立即减少;失败的外部转账不能造成内部账本变化。

三、前置条件与审计假设
1. 合约存在可被外部调用的状态修改函数,例如 withdraw、claim、redeem 或 refund。
2. 函数内部存在对外部地址的转账、任意 call、delegatecall、safeTransfer 回调,或调用未知实现的接口。
3. 外部调用发生时,关键余额、份额、债务或 nonce 尚未更新。
4. 攻击者能够部署一个本地恶意接收合约,并让该合约在回调中重新进入目标函数。
5. 资产数量、gas、权限等条件允许第二次调用成功。审计时不能只看到“存在外部调用”就直接定性,还要验证可达性、状态影响和经济后果。

四、本地实践环境
建议使用 Foundry。创建实验项目并安装常用测试库:
forge init reentrancy-lab
cd reentrancy-lab
forge build
forge test -vvv

实验只在本地 Anvil 或 Forge 测试环境运行:
anvil

不要把实验私钥、测试脚本或攻击合约用于真实网络。若项目已有权限控制、代理、升级模块或特殊编译器版本,应使用与生产配置一致的 solc 版本复现。

五、分步骤构造最小可复现实验
第一步,编写带缺陷的金库。为了避免复杂依赖,只使用原生 ETH 和 mapping。测试中先由普通账户存入 10 ether,再由攻击合约存入 1 ether。攻击合约在 receive 中记录回调次数,并在仍有目标余额时重新进入 withdraw。

示例攻击合约的防御测试版本如下:
contract ReentryProbe {
VulnerableVault public vault;
uint256 public rounds;

constructor(VulnerableVault target) {
vault = target;
}

function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw(msg.value);
}

receive() external payable {
rounds += 1;
uint256 available = address(vault).balance;
if (available >= 1 ether && rounds < 3) {
vault.withdraw(1 ether);
}
}
}

这里的 rounds < 3 只是为了让本地测试有限、可读,不代表现实攻击一定只有三次回调。测试断言不应只验证攻击合约收到资金,还应验证金库总资产、用户账面余额和预期不变量。

第二步,编写 Foundry 测试。测试流程通常是:给普通用户和攻击合约分配 ETH;调用 deposit;记录目标合约余额;执行受控的 attack;检查是否发生多次 receive;最后比较 address(vault).balance 与所有 balances 之和。

可采用以下命令定位单个测试:
forge test --match-test testReentrancy -vvvv

第三步,确认根因而不是只确认结果。建议在 withdraw 的外部调用前后增加事件,记录 msg.sender、balances[msg.sender]、address(this).balance 和 amount。通过 -vvvv 查看调用栈,确认第二次 withdraw 发生在第一次 withdraw 尚未执行 balances 减法之前。若只看最终余额,可能把普通业务错误、初始化错误或测试资金配置错误误判为重入。

六、推荐修复方式
方法一是遵循 Checks-Effects-Interactions(检查—状态更新—外部交互)模式:
function withdraw(uint256 amount) external {
uint256 current = balances[msg.sender];
require(current >= amount, InsufficientBalance());
balances[msg.sender] = current - amount;
(bool ok, ) = msg.sender.call{value: amount}();
require(ok, TransferFailed());
}

这会使重入时读取到的余额已经减少。对于更复杂的业务,应先明确状态机,再决定哪些状态必须在外部调用前落地。若转账失败,整个交易会回滚,余额也会恢复到调用前状态。

方法二是在确有必要时使用 OpenZeppelin ReentrancyGuard:
contract SafeVault is ReentrancyGuard {
function withdraw(uint256 amount) external nonReentrant {
uint256 current = balances[msg.sender];
require(current >= amount, InsufficientBalance());
balances[msg.sender] = current - amount;
(bool ok, ) = msg.sender.call{value: amount}();
require(ok, TransferFailed());
}
}

锁只能阻止同一执行上下文中的特定重入,不能替代正确的状态更新,也不能自动覆盖跨函数重入、跨合约调用、代理实现差异或业务层重复领取。使用时应检查所有相关入口是否共享同一状态和锁策略。

方法三是减少主动调用不可信合约的次数。若业务允许,可采用用户主动提取、异步结算或 pull payment 模式,而不是在一次复杂操作中向多个外部地址推送资产。

七、审计检查清单
1. 全局搜索 call、delegatecall、send、transfer、safeTransfer、onERC721Received、onERC1155Received、tokensReceived 和自定义 callback。
2. 对每个外部调用标注调用目标是否可控、是否可能是合约、是否能回调当前合约或相关模块。
3. 沿调用图检查所有可达入口,而不是只看当前函数。例如 withdraw 可能通过 claim、redeem、emergencyWithdraw 间接共享余额。
4. 检查余额、份额、债务、nonce、累计奖励和已领取标记是否在交互前更新。
5. 检查跨函数重入:A 函数调用外部合约后,回调进入 B 函数,B 函数可能读取同一组尚未稳定的状态。
6. 检查只读重入:view 函数本身不能写状态,但若被价格预言机、抵押率计算或另一个协议在回调期间读取,可能返回不一致快照。
7. 检查代理合约的存储布局、修饰器位置和实现合约版本,避免修复只部署在未实际使用的实现上。
8. 用测试验证修复前后不变量,至少覆盖单次提现、失败提现、重复提现、回调重入、零金额、最大金额和不同接收地址类型。

八、风险与注意事项
重入漏洞的严重性取决于可盗取资产、权限边界和可重复程度。若函数只修改非资产状态,可能是中低风险;若能重复提取 ETH、ERC20、份额或奖励,通常需要高优先级处理。不要仅依赖 transfer 或 send 的固定 gas 行为来防重入,这种做法会带来兼容性问题,也不能覆盖所有回调路径。

修复后要注意 gas 变化、合约接收地址兼容性和升级存储布局。对于 ERC20,某些代币行为并不完全标准,safeTransfer 解决的是返回值兼容问题,不等于自动解决业务重入。对于 ERC777、ERC1155 和 NFT 接收钩子,必须把回调视为正常控制流,而不是异常情况。

九、常见问题
问:使用 ReentrancyGuard 后是否可以不遵循 CEI?
答:不建议。锁是额外防线,CEI 负责让状态在交互前达到一致,二者解决的问题层次不同。

问:只有给 msg.sender 转账才会重入吗?
答:不是。任意外部合约调用、代币回调、NFT 接收钩子、闪电贷回调和代理调用都可能形成重入路径。

问:把 balances 减法放在 call 后面,但加上 nonReentrant,是否安全?
答:在该入口被正确加锁且所有相关入口共享有效锁时,直接重入可能被阻止,但仍不如先更新状态清晰。还要检查跨函数路径和其他模块是否存在同类窗口。

问:测试没有复现资金损失,是否可以判定无漏洞?
答:不能。可能是测试资金不足、回调条件不满足、权限配置不同或只测了单一入口。应结合调用图、状态快照、不变量和符号执行或模糊测试继续验证。

十、总结
重入审计的关键不是机械寻找 call,而是回答三个可验证问题:外部控制权何时交出;交出前哪些状态仍未落地;回调能否沿任意入口改变资产或权限相关结果。实践中应先在本地构造最小恶意接收合约,再用详细调用栈确认时序,最后通过 CEI、ReentrancyGuard、异步结算和不变量测试形成分层防御。对于生产项目,修复报告应明确受影响函数、可达路径、前置条件、资产影响、修复提交和回归测试结果,避免只写“增加防重入锁”而缺少可复核证据。
 
顶部