
在现代去中心化金融(DeFi)生态中,以 Curve Finance、Uniswap V2/V3 等流动性池(Liquidity Pool)的实时资产比率作为预言机价格输入(Oracle Spot Price)的抵押借贷与衍生品协议屡见不鲜。然而,从 2022 年针对多个借贷金库的数千万美元闪电贷攻击开始,“只读重入(Read-only Reentrancy)”成为 DeFi 架构中最隐蔽且极具破坏力的杀手锏。
与传统的修改状态重入不同,流动性池的只读查询函数(如 Curve 的 get_virtual_price())通常带有 view 修饰符,不产生任何存储状态变更,因此开发者往往不会也不可能为其加装传统的状态修改型重入锁(nonReentrant)。但当用户在 Curve 池中调用 remove_liquidity_one_coin 提取流动性时,池子会先将底层代币转给调用者(触发接收者的外部回调钩子),随后才更新池内的总份额代币供应量(totalSupply)。
如果在此时的回调中,恶意合约以只读方式向第三方借贷协议发起清算或借款操作,借贷协议查询到的虚拟价格(Virtual Price)处于严重的虚高或虚低状态,从而直接掏空协议抵押金库。
本文将系统剖析只读重入的漏洞攻击链路,并给出利用**外部哨兵合约(External Sentinel)以及以太坊 EIP-1153 瞬态存储(Transient Storage)**构建的全局防重入架构。
漏洞机理复盘:虚假虚拟价格的形成闭环
在 Curve 等常数和/乘积混合做市商中,LP 代币的虚拟价格定义为:$$\\text{Virtual Price} = \\frac{\\text{池内受托管的总资产价值(Invariant } D)}{\\text{LP 代币的总发行量(Total Supply})}$$
当发生单币种流动性移除时,底层合约的执行时序如下:
在第 2 步发生时,攻击者获得了执行控制权。此时池子内的资产已经被转走,而分母 totalSupply 尚未被销毁,或者分子未被重新收敛,导致短时间内计算出的 get_virtual_price() 出现严重失真。攻击者利用这一受污染的只读数据,以极高的估值将垃圾抵押品抵押给下游借贷合约,借出大量主流代币后弃仓。
为什么单体防御会全面失效?
许多借贷协议开发者感到委屈:“我的借贷合约每个函数都加了 nonReentrant 锁,为什么还是被重入了?”原因在于:
破局之道:外部哨兵合约(External Sentinel)模式
既然无法修改第三方已部署的流动性池代码,我们就必须在查询预言机报价前,主动对目标池执行一次“试探性无害写入”。
在 Curve 池中,非只读方法(例如向自身地址存入 0 金额的流动性,或者调用带互斥锁的无害管理方法)都受到 Curve 本身私有重入锁的保护。如果当前正处于 Curve 的提款回调中,Curve 的私有锁必然处于“已锁定”状态。
利用这一特性,我们可以构建一个外部哨兵合约(External Sentinel):在下游借贷协议读取价格之前,哨兵合约强制向 Curve 池发起一次无害的带锁调用。若 Curve 处于重入状态,调用会立即回滚(Revert),从而使只读重入攻击在源头被无条件掐死。
架构实战:基于 EIP-1153 瞬态存储的全局状态锁与哨兵
在以太坊坎昆升级(Cancun Hardfork)后,EIP-1153 引入了原生的瞬态存储指令 TSTORE 与 TLOAD。瞬态存储的数据在事务结束后自动清空,单次读写仅消耗 100 Gas,使得构建超低成本的跨合约全局安全锁成为可能。
以下是完整的 Solidity 防御体系代码:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
interface ICurvePool {
function get_virtual_price() external view returns (uint256);
// 带有 nonReentrant 锁的无害试探函数(例如移除 0 金额流动性)
function remove_liquidity(uint256 amount, uint256[2] calldata min_amounts) external;
}
/// @notice 基于 EIP-1153 瞬态存储的高性能全局重入保护器
abstract contract TransientReentrancyGuard {
bytes32 private constant GLOBAL_LOCK_SLOT = keccak256("sentinel.oracle.transient.lock");
modifier nonReentrantTransient() {
bytes32 slot = GLOBAL_LOCK_SLOT;
assembly {
// 读取瞬态存储中的锁定标志
if tload(slot) {
// 标志非零,说明同一事务内发生非法递归
mstore(0x00, 0x82b42900) // Error: ReentrancyGuardReentrantCall()
revert(0x1c, 0x04)
}
// 写入锁定状态 1 (仅 100 Gas)
tstore(slot, 1)
}
_;
assembly {
// 退出后释放锁(事务结束即便不清零也会由 EVM 底层自动擦除)
tstore(slot, 0)
}
}
}
/// @notice 外部哨兵合约:专用于探测第三方池只读重入状态
contract CurveOracleSentinel {
error PoolUnderReentrancy();
/// @notice 安全获取 Curve 池虚拟价格的哨兵代理函数
function getSafeVirtualPrice(address curvePool) external returns (uint256) {
// 步骤 1:向目标池发起试探性无害调用以触发其内部重入检测
// 如果 Curve 池当前处于 remove_liquidity 的回调阶段,其内部锁未释放,此调用将必定 Revert
uint256[2] memory minAmounts;
try ICurvePool(curvePool).remove_liquidity(0, minAmounts) {
// 试探成功,说明 Curve 池当前未处于重入执行中
} catch {
// 若试探失败,说明 Curve 池内部锁被占用,当前发生只读重入攻击!
revert PoolUnderReentrancy();
}
// 步骤 2:在确认绝对安全后,方可读取其只读价格
return ICurvePool(curvePool).get_virtual_price();
}
}
/// @notice 下游受保护的借贷金库
contract SecureLendingVault is TransientReentrancyGuard {
CurveOracleSentinel public immutable sentinel;
address public immutable curvePool;
constructor(address _sentinel, address _curvePool) {
sentinel = CurveOracleSentinel(_sentinel);
curvePool = _curvePool;
}
/// @notice 抵押品借贷操作:受瞬态锁与外部哨兵双重保护
function borrow(uint256 collateralAmount) external nonReentrantTransient {
// 严禁直接调用 curvePool.get_virtual_price()!
// 必须强制经由哨兵合约执行“带探针的校验”
uint256 safePrice = sentinel.getSafeVirtualPrice(curvePool);
// 基于经过哨兵洗净后的价格进行安全借贷额度评估
uint256 borrowLimit = (collateralAmount * safePrice) / 1e18;
require(borrowLimit > 0, "Invalid borrow amount");
// 业务借贷逻辑…
}
}
哨兵机制的优势与综合防御矩阵
相较于传统的在主合约硬编码防重入,外部哨兵体系具有显著的架构优势:
只读重入撕碎了传统“View 函数绝对安全”的技术迷思。在现代多协议积木式组合的世界里,只有引入外部哨兵探针与瞬态全局状态互斥,才能真正堵住预言机生态中最隐蔽的暗门。






