时间 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 兑换率、健康度、奖励指数——问 AB 会不会被分两步更新。
  • ⚠️ 在回调里读调用方的状态,按定义就是在读中间状态——必须假设它不自洽。

思考题

  1. 为什么 view 修饰符给不了任何重入方面的保证?请从 EVM 的角度说明 STATICCALL 到底限制了什么。
  2. remove_liquidity 的中途,virtual_price 是被高估还是低估?如果把顺序改成"先减 balances 后减 totalSupply“呢?两种情况分别对谁有利?
  3. 为什么说"受害者不是 Curve”?请从 Curve 团队的视角论证:他们的代码在什么意义上是正确的。
  4. “调用一个带锁的无害函数来探测锁状态”——这个技巧的前提是什么?如果被读的协议没有提供任何带锁的外部函数,你还能怎么办?
  5. 给一个 ERC-4626 金库的 convertToAssets() 做审查:什么情况下它会返回一个中间态的错值?
  6. onERC721Received 回调里读取 NFT 市场的挂单状态,可能读到什么不自洽的东西?构造一个具体场景。
  7. 闪电贷进行中,池子的"总资产"读数偏低。请举出一个会因此做出错误决策的具体协议逻辑。
  8. 给"被读方"和"读方"分别写一份三条以内的检查清单。为什么两份清单不一样?
  9. 只读重入和 第 2 篇 的 ERC-777 重入,在"漏洞归属"上有什么共同点?这对审计的组织方式意味着什么?
  10. 假设你在做一个借贷协议,必须支持某种 LP 代币作抵押。请写出你的定价方案,并明确说出这个方案仍然依赖哪些外部前提。