重入攻击进阶
基础模式(单函数内先外部调用再更新余额、检查-生效-交互模式(CEI)、基础互斥锁)参见 重入攻击。EVM 底层控制权转移机制参见 EVM 与内部状态。
攻击形态分类
重入攻击的本质是调用方在自身会计状态未达成一致性闭环前,将执行流交给了外部不可信地址。根据外部调用发生时受影响的边界,可细分为以下类型:
| 重入类型 | 执行上下文与暴露状态 | 常见利用途径 |
|---|---|---|
| 单函数重入 | 当前执行函数内部状态未更新完成 | 在 withdraw() 的 ETH 转账回调中再次调用当前 withdraw() |
| 跨函数重入 | 被调函数状态修改中途,同合约内未加锁或使用独立锁的其他相关函数仍可调用 | 在 withdraw() 的回调中调用 transfer() 转移尚未扣减的余额 |
| 跨合约重入 | 合约 A 的内部锁无法约束外部合约 B;合约 B 依赖合约 A 未闭环的状态或直接操作合约 C | 借贷市场在结算抵押物赎回时向外部转账,回调流进入另一个借贷市场利用脏状态借款 |
| 只读重入 | 写操作入口受到互斥锁保护,但暴露的状态查询视图函数(view)未加保护 | 流动性池在移除流动性执行中途,其 get_virtual_price() 暴露了暂态抵押率偏差 |
| 代币回调钩子重入 | 代币转账过程触发接收者回调扩展逻辑,隐式转移控制权 | ERC-777 tokensToSend/tokensReceived、ERC-721/1155 onERC*Received |
sequenceDiagram participant Attacker as 攻击者合约 participant Victim as 被攻击协议 (Victim) participant Oracle as 依赖协议 / 外部组件 Attacker->>Victim: 调用写操作 (如 remove_liquidity / withdraw) Victim->>Victim: 部分更新存储变量 (处于不一致中间态) Victim->>Attacker: 触发外部调用 (ETH transfer / ERC-777 hook) Note over Attacker: 捕获执行流 Attacker->>Oracle: 调用依赖 Victim 中间态的第三方协议 Oracle->>Victim: 调用 view 查询状态 (获取失真数据) Oracle-->>Attacker: 基于失真状态完成不当套利/清算 Attacker-->>Victim: 恢复执行 Victim->>Victim: 完成最终记账闭环
transfer 与 send 的 Gas 限制演进
Solidity 原生提供的 address.transfer() 和 address.send() 固定转发 2300 Gas 的调用津贴(Stipend)。在早期 EVM 规则下,2300 Gas 仅允许接收方触发基础日志(LOG),不足以支撑修改存储(SSTORE),曾被广泛当作简易的防重入手段。
然而,随着以太坊升级对操作码 Gas 成本的调整,依赖固定 Gas 津贴存在严重隐患:
- 合法交互因 OOG 失败:EIP-1884(Istanbul 硬分叉)将冷状态
SLOAD成本提升至 800 Gas,后续 EIP-2929(Berlin 硬分叉)进一步提升冷访问开销。接收方若为智能合约钱包(如 Gnosis Safe 或 ERC-4337 账户)或包含基本入账校验逻辑,执行即会消耗超过 2300 Gas,导致合法转账交易持续 Revert。 - 无法防范只读重入与跨合约调用:2300 Gas 虽然不足以写入存储,但足以执行基础查询(如读取已在内存或暖访问中的状态)并向其他无状态组件发起联动。
现代合约开发应统一使用底层 call 原语配合显式鉴权与状态保护:
// 存在拒绝服务风险的过时写法
payable(to).transfer(amount);
// 推荐的标准模式:显式使用 call 并遵循 CEI / 互斥锁约束
(bool success, ) = to.call{value: amount}("");
require(success, "ETH transfer failed");
跨函数状态一致性
检查-生效-交互(CEI)模式在单个函数内能有效保证状态先行落库。但在多个函数共享底层账本状态的系统中,若互斥锁粒度不足,攻击者可在外部调用回调中直接触发其他未锁定的操作接口。
// 漏洞示例:withdraw 具备互斥锁,但 transfer 未受保护
function withdraw() external nonReentrant {
uint256 balance = balances[msg.sender];
require(balance > 0, "Zero balance");
// 外部调用转移执行权
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
// 状态更新滞后,且未覆盖同合约其他入口
balances[msg.sender] = 0;
}
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
}
在该模型中,攻击者在 withdraw() 的回调中执行 transfer() 将余额转移给受控辅助地址,在回调完成后当前函数再次执行 balances[msg.sender] = 0,从而实现余额的双重提取。
防御规范:所有访问、修改或依赖同一业务账本的对外接口,必须受同一互斥状态(Mutex)或事务级控制流的保护;避免在同一合约中混合使用独立的互斥修饰器。
跨合约与只读重入(Read-Only Reentrancy)
机理分析
只读重入并非在回调中直接修改被攻击合约的状态,而是在被调合约处于暂时不平衡的瞬态时,读取其对外公开的计算型视图函数(view)。
以自动化做市商(AMM)为例,remove_liquidity 的标准实现通常包含:
- 销毁用户的 LP 代币;
- 计算并按比例向用户转出底层代币(触发代币转账或原生 ETH 发送);
- 调整流动性池内部记录的资产储备与相关参数。
在步骤 2 向接收方转移原生 ETH 时,合约执行流被转移。此时 LP 代币已被销毁,但池内的物理资产尚未完全转出,导致单位 LP 代币对应的净资产(如 Curve 的 get_virtual_price())被瞬时放大。若外部借贷协议或合成资产协议以此价格作为抵押品计价依据,攻击者即可在回调函数中发起借贷或杠杆操作,完成超额借款。
历史案例
- Fei Protocol / Rari Capital (2022-04):Fuse 借贷池在处理特定 cETH 赎回时向攻击者转出 ETH。在接收 ETH 的
receive()回调执行期间,内部市场记账尚未完成同步,攻击者利用该执行窗口向同一借贷网络中的其他借贷市场进行无抵押借贷,损失约 8000 万美元。 - Curve Finance / Conic Finance (2023-07):Conic 的
Omnipool实现依赖 Curve 池的get_virtual_price()评估储备资产价值。攻击者利用 Curve 池在移除流动性过程中的重入窗口,通过只读重入操纵价格函数,致使 Conic 遭到套利攻击,损失约 360 万美元。
防御模式:重入探测与视图互斥
对于被当作价格预言机使用的接口,必须防范重入调用:
-
瞬态重入锁保护视图函数(
nonReentrantView): 在写操作进入时设置重入状态位,在暴露的关键view函数中校验该状态,若检测到写操作未完成则直接回滚:// 基于 OpenZeppelin ReentrancyGuardTransient (EIP-1153) 的视图防重入 function getVirtualPrice() external view nonReentrantView returns (uint256) { return _calculateVirtualPrice(); } -
跨合约锁状态探测(External Mutex Probe): 若外部合约未提供
nonReentrantView,依赖方协议在读取其预言机价格前,可先尝试调用该外部合约的一个受互斥锁保护的写函数或特定检查接口;若由于外部锁处于锁定状态导致该调用回滚,则终止当前定价流程。
编译器实现缺陷导致的互斥失效
生产环境中必须将编译器版本及已知安全缺陷纳入威胁建模范围。即便合约源码逻辑严格遵循了安全修饰器规范,编译器代码生成错误仍可能导致安全机制完全失效。
Vyper @nonreentrant 存储槽冲突漏洞 (CVE-2023-37901)
2023 年 7 月,Curve Finance 的多个流动性池(pETH/ETH、msETH/ETH、alETH/ETH、CRV/ETH)遭到攻击,累计损失逾 4100 万美元。
根本原因:
在 Vyper 编译器版本 0.2.15、0.2.16 及 0.3.0 中,@nonreentrant 修饰器的状态生成逻辑存在缺陷。编译器未能为不同的互斥键(Key)或不同函数分配独立的存储槽,而是将全局统一的重入锁分配到了不同槽位,或者在生成跨函数锁时发生了存储槽重叠覆盖,导致本应互斥的函数能够相互重入。
该缺陷已在 Vyper 0.3.1 版本中修复。该事件表明,合约审计不能仅停留在静态源码语法审查,必须严格对照编译器版本的已发布漏洞通告(Release Notes / Advisory Notices)。
重入锁实现机制比较
| 方案 | 底层存储介质 | 适用运行环境 | 特性与开销评估 |
|---|---|---|---|
状态变量 bool 标记 | 持久化 Storage(Slot 0 等) | 任意 EVM 版本 | 手动管理容易因未处理异常产生脏状态;0 到 1 的状态翻转无法有效利用 Gas 退款优化。 |
OpenZeppelin ReentrancyGuard | 持久化 Storage(1 与 2 翻转) | Cancun 升级前主流实现 | 使用非零常数(NOT_ENTERED = 1, ENTERED = 2)避免存储槽从未分配到分配的冷写高额开销,充分利用 EIP-2200 暖访问退款机制。 |
OpenZeppelin ReentrancyGuardTransient | EIP-1153 瞬态存储(TSTORE/TLOAD) | Cancun 升级后支持网络 | 每次操作固定消耗 100 Gas,状态生命周期局限于当前交易执行上下文,交易结束自动释放,大幅降低执行成本并支持 nonReentrantView。 |
EIP-1153 瞬态存储行为边界
EIP-1153 引入的 TSTORE 与 TLOAD 极大地降低了重入防护的开销,但开发中需注意其执行语义:
- 执行域范围:瞬态存储在当前整笔交易(Transaction)上下文内全局有效,不随普通内部调用的返回而清空。
- 回滚语义:当发生调用执行 Revert 时,该调用上下文内执行的
TSTORE会随之回滚,这与持久化 Storage 的事务一致性保持一致。 - 隔离性:瞬态存储不具备天然的访问控制权限,跨上下文传递数据时需防范未鉴权写入。
安全检查清单
- 严格遵循 CEI 架构:所有外部调用发生前,本合约内的所有相关账本状态必须已完成写操作闭环。
- 统一互斥锁范围:共享同一账本状态的所有外部写入口必须使用同一把互斥锁。
- 视图函数定价防范:供外部依赖的计算型视图接口(尤其是 AMM 估值函数)必须具备防止中间态读取的重入限制或外部校验逻辑。
- 转账原语安全性:废除以
transfer()/send()的 2300 Gas 作为安全假设的防重入模式,改用受控底层call。 - 代币钩子风险评估:对涉及任意 ERC-20、ERC-777、ERC-721、ERC-1155 转账的交互逻辑,均应视为控制权转移点处理。
- 编译器安全版本核验:确认编译工具链版本不在已知重入漏洞影响区间内(如 Vyper 0.2.15/0.2.16/0.3.0)。
- 瞬态存储生命周期审计:在基于 EIP-1153 构建重入锁时,确保逻辑符合链上分叉支持情况,并隔离不同业务逻辑在瞬态槽位上的复用。