欢迎订阅专栏:10分钟智能合约:进阶实战
跨合约重入:定义、原理与攻防实战
跨合约重入是指攻击者利用两个或以上独立合约之间的相互调用,在单笔交易中通过回调机制重新进入原始调用者或其他相关合约的函数,从而利用尚未更新的全局状态或跨合约共享状态执行非预期操作。
与单函数/跨函数重入局限于同一合约内部不同,跨合约重入的攻击面横跨多个合约的边界,在 DeFi 组合式架构中尤为常见且隐蔽。
1. 核心原理
跨合约重入的根源依然是状态更新滞后于外部调用,但脆弱点出现在合约间的信任假设上:
- 合约 A 调用合约 B 的某个函数,并假设合约 B 是“诚实的”或“无副作用的”。
- 合约 B 在被调用期间,回调合约 A 的另一个函数,该函数与合约 A 正在执行的逻辑共享同一状态变量。
- 由于合约 A 尚未完成状态更新,回调函数读取的是旧状态,从而执行未授权的操作。
关键特征:攻击者不一定直接部署攻击合约,而是利用一个已存在的、看似正常的第三方合约作为重入跳板。
2. 经典示例:ERC-777 与 Uniswap V1 流动性池攻击
2019 年,imBTC(ERC-777)与 Uniswap V1 池的组合被曝出跨合约重入漏洞,攻击者仅用 0.000001 ETH 就盗走了池中所有 imBTC。这是跨合约重入的教科书案例。
📌 背景设定
-
Uniswap V1 池:允许用户用 ETH 兑换 imBTC,核心函数
ethToTokenSwap执行:- 计算应输出的 imBTC 数量。
- 先向用户发送 imBTC(调用 imBTC 的
transfer)。 - 再扣除用户的 ETH。
-
imBTC(ERC-777):ERC-777 在转账时会回调接收方合约的
tokensReceived()钩子函数。如果接收方是合约,tokensReceived()会被自动调用。
🔥 攻击路径
sequenceDiagram
participant Attacker
participant UniswapV1
participant imBTC(ERC777)
participant AttackerContract
Attacker->>UniswapV1: ethToTokenSwap(极小ETH)
UniswapV1->>imBTC(ERC777): transfer(imBTC给攻击者)
imBTC(ERC777)->>AttackerContract: tokensReceived() 回调
AttackerContract->>UniswapV1: ethToTokenSwap(再次兑换) ⚠️ 重入
UniswapV1->>imBTC(ERC777): transfer(imBTC)
imBTC(ERC777)-->>AttackerContract: ...循环...
UniswapV1-->>Attacker: 完成首次兑换
- 攻击者调用 Uniswap V1 的
ethToTokenSwap,仅支付 0.000001 ETH。 - Uniswap 计算应得 imBTC,调用 imBTC 的
transfer向攻击者发送代币。 - **imBTC(ERC-777)**在执行转账时,调用攻击者合约的
tokensReceived()钩子。 - 在
tokensReceived()中,攻击者合约再次调用同一个 Uniswap 池的ethToTokenSwap。 - 由于第一次调用的状态(用户 ETH 扣款)尚未执行,Uniswap 认为攻击者仍然拥有极小的 ETH 余额,于是再次为其计算并发送 imBTC。
- 循环重复,直至池中 imBTC 被耗尽。
攻击结果:攻击者通过重入,用几乎为零的成本反复提取 imBTC,最终卷走池中所有流动性。
3. 攻击成功的关键条件
| 条件 | 说明 |
|---|---|
| 调用顺序错误 | 合约在发送资产之后才更新自身状态(如扣款)。 |
| 代币具有回调机制 | ERC-777、ERC-223、部分 ERC-721 实现,以及支持 safeTransfer 的 ERC-1155,均可能触发接收方钩子。 |
| 接收方是攻击者可编程合约 | 攻击者可在钩子函数中发起新的交易。 |
| 状态共享 | Uniswap 的状态变量(如 balanceOf、储备量)在两个调用间被共享,且未受重入锁保护。 |
4. 与其他重入类型的对比
| 维度 | 单函数重入 | 跨函数重入 | 跨合约重入 |
|---|---|---|---|
| 重入目标 | 同一个函数 | 同一合约的不同函数 | 不同合约(或同一合约但触发路径跨合约) |
| 回调入口 | receive() / fallback() | 外部调用的回调 | 代币转账钩子、预言机回调、跨合约交互 |
| 典型场景 | ETH 提款 | The DAO 拆分提案 | ERC-777 + 去中心化交易所 |
| 防御难度 | 低 | 中 | 高(需要跨合约协调) |
5. 防御措施
✅ 严格遵循 CEI(检查-生效-交互)模式
所有外部调用(包括向用户发送代币)必须放在函数最后,且在此之前完成所有状态更新。
Uniswap V1 的修复:将 transfer 移到扣款之后。
// 修复前(漏洞版)
function ethToTokenSwap(uint256 minTokens) public payable {
uint256 tokensOut = getTokenAmount(msg.value);
token.transfer(msg.sender, tokensOut); // ❌ 先转账
balances[msg.sender] += msg.value; // 后记账
}
// 修复后(安全版)
function ethToTokenSwap(uint256 minTokens) public payable {
uint256 tokensOut = getTokenAmount(msg.value);
balances[msg.sender] += msg.value; // ✅ 先更新状态
token.transfer(msg.sender, tokensOut); // ✅ 后外部调用
}
✅ 使用重入锁
即使遵循 CEI,仍建议对关键函数添加 nonReentrant 修饰符,防止意外重入。
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract UniswapV1Pool is ReentrancyGuard {
function ethToTokenSwap(...) public nonReentrant {
// ...
}
}
✅ 选择无回调的代币标准
- 优先使用 ERC-20(无转账钩子)而非 ERC-777。
- 若必须使用有钩子的代币,协议应明确声明风险,或在交互前检查接收方是否为合约并施加限制。
✅ 跨合约重入的协议级防御
对于多个合约构成的系统:
- 统一重入锁:通过共享的全局锁状态,确保整个协议在一笔交易中不可重入。
- 快照机制:在执行敏感操作前,对相关状态进行快照,操作完成后检查状态是否被意外篡改。
✅ 审计重点
审计时应特别关注:
- 任何先转账后更新状态的代码模式。
- 使用ERC-777/ERC-223/ERC-1155等带钩子代币的交互。
- 多个合约间相互调用的闭环路径。
6. 现代变体:跨合约只读重入
2022 年 Acala aUSD 攻击是跨合约重入的现代变体——只读重入。攻击者通过重入调用一个 view 函数,读取尚未更新的储备量,从而操纵价格预言机,最终 mint 出巨量 aUSD。
防御这种攻击需要更细粒度的状态管理,例如在重入锁生效时禁止读取某些关键状态,或引入瞬态存储(EIP-1153)来隔离未提交的更改。
总结
跨合约重入是重入攻击的高级形态,它突破了单一合约的边界,将攻击面延伸至 DeFi 的可组合性中。它的本质依然是状态更新滞后于外部调用,但攻击载体从简单的 ETH 转账升级为代币标准中隐含的回调机制。防御跨合约重入不仅需要单个合约的严谨编写,更需要在协议架构层面建立统一的防重入策略。
核心教训:任何对外部合约的调用,无论该合约看起来多么“安全”,都必须假设它可能在执行期间回调你的合约。先更新,后交互——这条铁律从未过时。