| 时间 | 2022–2023 年,多起,持续出现 |
| 涉案 | 单起数百万到数千万美元不等,累计数千万 |
| 典型受害者 | 把 Curve LP 当抵押品的借贷协议 |
| 类别 | C1 控制流假设失效 |
一、背景:一个"绝对安全"的函数
先看一个 view 函数。它不改任何状态,不转任何钱:
function getVirtualPrice() external view returns (uint) {
return (poolBalanceETH + poolBalanceToken) * 1e18 / lpTotalSupply;
}
⭐ 直觉上它不可能出事:view 意味着不能写状态,编译器都替你保证了。很多人因此把这类函数当作"可信的价格源"直接用来定价抵押品。
这个直觉是错的。 而且错得非常隐蔽——因为问题不在这个函数里。
二、漏洞在哪
移除流动性时发生了什么
以一个含原生 ETH 的 Curve 风格池子为例,remove_liquidity 的顺序是:
@external
@nonreentrant('lock') # ⭐ 写函数有锁
def remove_liquidity(_amount: uint256, _min_amounts: uint256[2]):
total_supply: uint256 = self.totalSupply
# ① 先销毁 LP 代币
self.totalSupply = total_supply - _amount
self.balanceOf[msg.sender] -= _amount
# ② 再把底层资产退还给用户
for i in range(2):
value: uint256 = self.balances[i] * _amount / total_supply
if i == 0:
# ⚠️ 原生 ETH:这一行【把控制权交给了 msg.sender】
raw_call(msg.sender, b"", value=value)
else:
ERC20(self.coins[i]).transfer(msg.sender, value)
# ③ 直到这里才更新池子的内部余额记录
self.balances[i] -= value
注意第 ② 步和第 ③ 步的顺序。 在 ETH 被打给用户的那一刻:
totalSupply 已经【减少】了 ← 第 ① 步做完了
balances[] 还没【减少】 ← 第 ③ 步还没执行
⟹ ⭐ 此时读 virtual_price = (balances 之和) / totalSupply
分子是旧的(大),分母是新的(小)
⟹ 算出来的价格【被显著高估】
锁为什么没用
Curve 的写函数有 @nonreentrant('lock')。但 get_virtual_price() 是 view 函数,没有加锁。
⚠️ 锁挡住了:在 remove_liquidity 中途再次调用 remove_liquidity
✅ 锁没挡住:在 remove_liquidity 中途【读取】 get_virtual_price
⭐ 因为读取从来不被认为是危险的动作。
三、攻击复盘
受害者不是 Curve。 是某个把 Curve LP 代币当抵押品、并用 get_virtual_price() 给它定价的借贷协议。
攻击者合约 A
① A 向 Curve 池存入流动性,拿到 LP 代币
② A 把一部分 LP 代币存入【借贷协议】作为抵押
③ A 调用 Curve 的 remove_liquidity 取回另一部分
│
├─ Curve 销毁 LP,totalSupply ↓
├─ Curve 把 ETH 打给 A ──────┐
│ │ ⭐ 控制权到了 A
│ ▼
│ A.fallback()
│ └─ 调用【借贷协议】的 borrow()
│ └─ 借贷协议去读 Curve 的
│ get_virtual_price()
│ ⚠️ 读到的是【膨胀后的】价格
│ ⟹ A 的抵押品被高估
│ ⟹ 借出远超其真实价值的资产
│ ┌──
└─ Curve 更新 balances[] ◀───┘ (太晚了,钱已经借走)
④ 交易结束。Curve 的账目完全正确,一分钱没少。
⭐ 亏的是借贷协议。
⭐⭐ 这是整件事最需要记住的地方:
被重入的是 Curve,受损失的是读 Curve 的第三方。
Curve 的开发者审计自己的代码,怎么审都是对的——它的不变量在函数返回时确实成立。问题在于函数执行到一半时不成立,而外部可以在那一刻观察。
四、为什么没被发现
① view 这个关键字给了错误的安全感
⚠️ view 保证的是【这个函数不写状态】。
它完全不保证【这个函数读到的状态是自洽的】。
⭐ 编译器检查的是"你有没有写",
而漏洞在于"别人写了一半"。
② 不变量的"时点"从来没被写下来
任何池子都有一条隐含的不变量:
virtual_price = Σ balances / totalSupply 是有意义的
⚠️ 但这句话缺一个限定词:它只在函数边界上成立,在函数内部不成立。而文档、注释、审计报告通常都不写这个限定,因为写代码的人默认"没人会在函数中间来读"。
③ 它跨越了两个团队
Curve 的团队:我的写函数有锁,我的账目最终是对的 ✅
借贷协议团队:我读的是一个被广泛信任的协议的 view 函数 ✅
⭐ 两边各自都没做错,漏洞长在他们中间的缝里。
⚠️ 而这条缝不属于任何一方的审计范围。
五、防御
分两种身份,防御方式完全不同。
如果你是"被读的一方"(写池子的人)
① 把状态更新全部放在外部调用之前
# 先把 balances 全部更新完
for i in range(N):
self.balances[i] -= value[i]
# 再统一转账
for i in range(N):
transfer(msg.sender, value[i])
⭐ CEI 在这里同样是根本解——不留出"中间状态可被观察"的窗口。
② 给关键的 view 函数也加锁
@external
@view
@nonreentrant('lock') # ⭐ 读也上锁
def get_virtual_price() -> uint256:
...
⚠️ 注意这会让合法的跨协议读取在重入期间 revert——这正是想要的效果:宁可读不到,也不要读到错的。
如果你是"读的一方"(更常见的处境)
⚠️ 你无法修改被读协议的代码,只能自保。
① 先检查对方是不是正处在锁定状态
许多池子提供了可以触发锁检查的函数。调用它,如果对方正在重入中,你的交易会一起 revert:
// 调用一个带 nonreentrant 锁的【无害】函数来探测锁状态
// Curve 上常用的是 withdraw_admin_fees / claim_admin_fees
ICurvePool(pool).withdraw_admin_fees(); // 处于重入中 ⟹ revert
uint price = ICurvePool(pool).get_virtual_price();
⭐ 原理:借用对方写函数上的那把锁,来给自己的读操作加保护。
② 不要直接用 LP 的 virtual_price 定价
更稳的做法:
用底层资产的独立价格源(如外部预言机)
+ 池子的份额比例
自己算 LP 的价值,而不是相信池子自报的数字。
⚠️ 代价:更复杂、更贵、也引入了预言机自身的问题(见 Unit 4)。
③ 在自己这边也加重入锁
如果你的 borrow() 有 nonReentrant,攻击者就无法在 Curve 的回调里进入它。
⚠️ 但这只挡住了"从外部回调进入我"的路径,挡不住攻击者用两笔独立交易分别操作——所以它是补充,不是替代。
六、⭐ 举一反三
核心命题
⭐⭐ 任何"执行到一半"的状态,只要外部能观察到,就是攻击面。
而在 EVM 里,“外部能观察到"的条件极低:只要你在中途做过一次外部调用。
这个模式还长在哪里
① 任何比值型的读数
凡是形如 A / B 的对外读数,都要问:
⭐ A 和 B 会不会在同一个函数里【被分两步更新】?
· LP 的 virtual price = 储备 / 份额
· 存款凭证的兑换率 = 总资产 / 总份额 ⚠️ ERC-4626 就是这个形状
· 抵押率 / 健康度 = 抵押价值 / 债务
· 质押奖励的累计指数 = 累计奖励 / 总质押
② 事件与回调里的读数
⚠️ 在一个回调函数里读取调用方的状态,
按定义就是在读【中间状态】——
因为回调发生时,调用方的函数还没执行完。
⟹ 在 onERC721Received / tokensReceived / fallback 里
读取调用方的任何数据,都必须假设它是不自洽的。
③ 多步操作的中间态
· 闪电贷进行中:借出去的钱不在池子里 ⟹ 池子的"总资产"读数偏低
· 清算进行中:抵押品已扣、债务未清 ⟹ 健康度读数异常
· 批量操作循环中:处理了 3 个、还剩 7 个 ⟹ 任何汇总量都是错的
④ 跨链消息的中间态
源链已扣、目标链未到账的那段时间里,总供应量的读数是错的。任何依赖"全链总量"的逻辑都要考虑这个窗口。
一条可以随身带的检查
对你合约里【每一个 public / external 的 view 函数】问:
① 它读的那几个状态变量,
有没有可能在某个函数里被【分开、非原子地】更新?
② 那个函数里有没有外部调用?
—— 有的话,中间状态就是可观察的。
③ ⭐ 如果有人在最坏的时刻读它,会得到什么值?
那个值会让谁做出错误决策?
⚠️ 反过来,对你读的【每一个外部 view 函数】问:
我凭什么相信此刻它是自洽的?
与"审计范围"的关系
⭐ 只读重入是"组合性漏洞"的典型:
单独审计 Curve ⟹ 通过
单独审计借贷协议 ⟹ 通过
两者组合 ⟹ 漏洞
⟹ 和 [第 2 篇](https://blog.ifcalm.org/posts/security/blockchain/02-lendfme-erc777/) 是同一个结构性问题:
⚠️ 漏洞长在两份代码之间,而审计是【按合约】划范围的。
七、本案小结
- ⭐⭐
view保证的是"这个函数不写状态”,完全不保证"它读到的状态是自洽的"。 编译器检查的是你有没有写,漏洞在于别人写了一半。 - 成因:池子在
remove_liquidity里先减totalSupply、再转账(交出控制权)、最后才减balances——于是中途读到的比值被显著高估。 -⭐ 受害者不是被读的协议。 Curve 的账目完全正确、一分钱没少;亏钱的是读它的借贷协议。 - 写函数上的锁挡不住读:
nonreentrant拦住了重复进入写函数,但view函数没有锁,而读取从来不被认为是危险动作。 - ⚠️ 不变量缺了时点限定:
virtual_price = Σbalances / totalSupply只在函数边界成立,函数内部不成立——而这个限定从来没被写进文档。 - 作为被读方的防御:CEI(不留观察窗口)+ 给关键 view 函数也加锁。宁可读不到,也不要读到错的。
- 作为读方的防御:先调用对方一个带锁的无害函数探测锁状态(借用对方写函数的锁保护自己的读),或干脆不用池子自报的数字定价。 -⭐ 一般化命题:任何"执行到一半"的状态,只要外部能观察到,就是攻击面。 而在 EVM 里,可观察的条件极低——只要中途做过一次外部调用。
- 凡是形如
A / B的对外读数都要审一遍:LP 虚拟价格、ERC-4626 兑换率、健康度、奖励指数——问A和B会不会被分两步更新。 - ⚠️ 在回调里读调用方的状态,按定义就是在读中间状态——必须假设它不自洽。
思考题
- 为什么
view修饰符给不了任何重入方面的保证?请从 EVM 的角度说明STATICCALL到底限制了什么。 - 在
remove_liquidity的中途,virtual_price是被高估还是低估?如果把顺序改成"先减balances后减totalSupply“呢?两种情况分别对谁有利? - 为什么说"受害者不是 Curve”?请从 Curve 团队的视角论证:他们的代码在什么意义上是正确的。
- “调用一个带锁的无害函数来探测锁状态”——这个技巧的前提是什么?如果被读的协议没有提供任何带锁的外部函数,你还能怎么办?
- 给一个 ERC-4626 金库的
convertToAssets()做审查:什么情况下它会返回一个中间态的错值? - 在
onERC721Received回调里读取 NFT 市场的挂单状态,可能读到什么不自洽的东西?构造一个具体场景。 - 闪电贷进行中,池子的"总资产"读数偏低。请举出一个会因此做出错误决策的具体协议逻辑。
- 给"被读方"和"读方"分别写一份三条以内的检查清单。为什么两份清单不一样?
- 只读重入和 第 2 篇 的 ERC-777 重入,在"漏洞归属"上有什么共同点?这对审计的组织方式意味着什么?
- 假设你在做一个借贷协议,必须支持某种 LP 代币作抵押。请写出你的定价方案,并明确说出这个方案仍然依赖哪些外部前提。