时间 2023 年 3 月 13 日
涉案 约 $1.97 亿
结局 ⭐ 攻击者数周后全额归还
背景 ⚠️ 该协议经过多轮审计,并设有高额漏洞赏金
类别 C5 经济与博弈假设失效

一、背景:借贷协议的健康度不变量

任何借贷协议都有一条核心不变量:

⭐ 对每个账户:抵押品价值 × 抵押率 ≥ 债务价值

违反它 ⟹ 账户"不健康" ⟹ 可以被清算

因此协议里每一个会改变账户余额或债务的函数,在结束前都必须验证这条不变量:

function borrow(...) external {
    ...
    checkLiquidity(msg.sender);   // ⭐ 检查健康度
}
function withdraw(...) external {
    ...
    checkLiquidity(msg.sender);   // ⭐ 检查健康度
}

二、漏洞在哪:一个"看起来无害"的函数

Euler 有一个 donateToReserves() 函数:用户可以把自己的一部分 eToken(存款凭证)捐给协议储备金。

function donateToReserves(uint subAccountId, uint amount) external {
    ...
    // 减少调用者自己的余额
    balances[account].balance -= amount;
    // 增加储备金
    reserveBalance += amount;

    // ⚠️⚠️ 没有 checkLiquidity(account)
}

开发者的推理是这样的

"这个函数只是让用户【减少自己的资产】,
 送给协议。用户自愿吃亏,
 ⟹ 这不可能对协议有害。"

⚠️ 这个推理漏掉了一件事:

⭐ 减少自己的抵押品,会让【自己的账户变得不健康】。

而一个不健康的账户不是"用户自己的问题"——
它是【协议的问题】,因为不健康的账户会被清算,
⟹ 而清算机制本身是有【经济激励】的。

⟹ 用户可以【主动把自己变成一块肥肉】,
   然后自己来吃。

三、⭐ 清算折扣:被利用的那个激励

要理解攻击,先要理解清算折扣为什么存在:

清算人替协议还掉坏账,需要一个激励 ⟹ 折扣买入抵押品。

⭐ 而折扣通常是【递增】的:
      账户略微不健康  ⟹ 小折扣(比如 2%)
      账户严重不健康  ⟹ ⚠️ 大折扣(比如 20%)

设计意图:坏账越严重,越要吸引清算人快点来。

⭐⭐ 攻击者的洞察:既然折扣随"有多不健康"递增, 那就把自己弄到【要多不健康有多不健康】,然后自己清算自己。

四、攻击复盘

一笔交易内:

① 闪电贷借入 3000 万 DAI

② 用 2000 万 DAI 在 Euler 存款 ⟹ 获得 eDAI(抵押品)

③ ⭐ 用协议的 mint 功能加杠杆
      mint 会【同时】给账户铸出 eToken(资产)和 dToken(债务),
      相当于"自己借给自己"
      ⟹ 反复操作,把资产和负债同时放大约十倍

④ 用剩下的 DAI 还掉一部分债务,再 mint 一轮
      ⟹ 进一步放大头寸

⑤ ⭐⭐ 调用 donateToReserves(),捐出一大笔 eDAI
      ⟹ 自己的抵押品骤降,债务不变
      ⟹ ⚠️ 账户瞬间【严重资不抵债】
      ⟹ 而【没有任何健康度检查阻止这一步】

⑥ 用第二个账户来清算第一个账户
      ⟹ 因为不健康程度极深,触发【最高档的清算折扣】
      ⟹ 清算人(也就是攻击者自己)以极低价格拿走抵押品

⑦ 还掉闪电贷,带走差额

每一步都在协议规则内。 没有重入、没有溢出、没有权限绕过——只是少了一次检查。

结局

攻击者在数周内全额归还了资金。⚠️ 但和第 6 篇一样,这不能算进安全设计。

五、为什么没被发现

① 这个协议做对了几乎所有事

⭐ 值得强调,因为它打破了"被黑 = 不重视安全"的直觉:

   □ 多轮独立审计       ✅
   □ 高额漏洞赏金        ✅
   □ 形式化验证的部分工作 ✅
   □ 代码质量很高        ✅

⚠️ 而漏洞是【一个函数漏了一行检查】。

② “只影响自己"是一个非常有说服力的错误直觉

⭐ 这个直觉之所以危险,是因为它听起来完全合理:

   "用户把自己的钱送人,关协议什么事?"

⚠️ 而在借贷协议里,
   【账户的健康度是协议的公共状态,不是用户的私事】。

⟹ 一般化:
   在一个有【共享风险池】的系统里,
   ⭐ 没有任何操作"只影响自己"。

③ 检查散落在各个函数里,而不是在一个地方强制

⚠️ Euler 的做法:每个函数【自己记得】调用 checkLiquidity。

⟹ 这是一个"约定",不是一个"机制"。
   ⭐ 而约定的可靠性取决于【所有人永远不忘】。

⟹ 十几个函数里漏一个,就够了。

六、防御

① 用架构强制检查,而不是靠记忆

// ⭐ 修饰符:任何可能让账户变差的函数都必须挂上
modifier healthCheck(address account) {
    _;                              // 先执行函数体
    require(_isHealthy(account), "unhealthy");   // ⭐ 结束前强制检查
}

function donateToReserves(uint amount) external healthCheck(msg.sender) { ... }
function withdraw(uint amount)         external healthCheck(msg.sender) { ... }
function borrow(uint amount)           external healthCheck(msg.sender) { ... }

更彻底的做法:把所有外部入口统一收敛到一个"路由"合约,由路由在每次调用结束后无条件做健康检查。这样新增函数时不可能忘记——因为检查不在函数里,在它外面。

一般原则:让"正确"成为默认,让"绕过"需要显式声明。 反过来(默认不检查、需要时手动加)在长期一定会漏。

② 列出"能让账户变差"的完整清单

⭐ 逐个列出所有会降低健康度的操作:

   □ 提取抵押品
   □ 增加借款
   □ ⭐ 转移 eToken 给别人
   □ ⭐ 捐赠 / 销毁自己的份额     ← 本案
   □ 切换抵押品类型
   □ 改变账户的抵押品配置
   □ ⚠️ 任何减少 balance 的操作,无论理由是什么

⟹ 每一条后面都必须跟着一次健康检查。

③ 清算折扣要有上限,且不能自我清算

// ⭐ 折扣封顶
uint discount = _min(computeDiscount(healthFactor), MAX_DISCOUNT);

// ⭐ 禁止同一实体既是被清算人又是清算人
require(liquidator != borrower, "self-liquidation");

⚠️ 注意第二条挡不住"用两个地址”——它只是提高门槛。真正的防御是第 ① 条:不让账户能被主动推到深度不健康。

④ 头寸变化的幅度限制

⭐ 单笔交易内,一个账户的健康度不应该发生剧烈跳变。

   □ 单笔交易内抵押品减少不得超过 X%
   □ 单笔交易内不得从"健康"直接变成"深度不健康"
   □ ⚠️ 深度不健康的账户,清算需要延迟一个区块

⟹ 最后一条特别有效:它让"自己弄坏自己再自己清算"
   ⭐ 无法在一笔原子交易内完成,
      从而暴露给其他清算人竞争。

⑤ 不变量测试

// ⭐ 这条不变量能直接抓住本案
function invariant_noUserCanBecomeUnhealthyVoluntarily() public {
    // 对每个账户,在任意一系列操作之后:
    assertTrue(protocol.isHealthy(account) || _wasUnhealthyFromPriceMove());
}

// ⭐ 更简单也更强的一条
function invariant_selfLiquidationNeverProfits(...) public {
    // 同一实体控制的账户,
    // 经过任意操作序列之后,净资产不得增加
}

七、⭐ 举一反三

核心命题一:不存在"只影响自己"的操作

⭐⭐ 在任何有共享风险池的系统里,一个账户的状态是公共状态。

同一个盲区的其它形态:

   · 用户自愿放弃收益 ⟹ 改变了别人的收益率
   · 用户捐赠 ⟹ 改变了份额价格([第 15 篇](https://blog.ifcalm.org/posts/security/blockchain/15-cream-finance/)、[第 19 篇](https://blog.ifcalm.org/posts/security/blockchain/19-erc4626-inflation/))
   · ⭐ 用户主动触发对自己不利的状态 ⟹ 触发了对协议不利的机制
   · 用户放弃投票 ⟹ 改变了法定人数的达成
   · ⚠️ 用户"自愿"接受极差的成交价 ⟹ 移动了池子价格,
        影响所有读这个价格的人

检查方法:对每个函数问,不是“它对调用者有没有害”,而是"它改变了哪些被别人读取的状态"。

核心命题二:递增的激励可以被反向利用

⭐ 任何"情况越糟、奖励越高"的机制,
   都在鼓励某人【把情况弄糟】。

   · 清算折扣随不健康程度递增   ⟹ 本案
   · 保险赔付随损失规模递增     ⟹ 制造损失
   · 救助基金按缺口大小拨付     ⟹ 扩大缺口
   · ⭐ 套利奖励随价格偏离递增   ⟹ 制造偏离
   · Gas 返还随消耗递增         ⟹ 无意义地烧 Gas

⟹ 设计任何激励时,都要问:
   ⭐【谁能人为制造出触发这个激励的条件?成本是多少?】

核心命题三:约定 vs 机制

⚠️ "每个函数都记得调用 checkLiquidity" —— 这是【约定】。
⭐ "所有函数都必须经过一个强制检查的路由" —— 这是【机制】。

⟹ 约定的失效概率随
      · 函数数量
      · 参与开发的人数
      · 项目存续时间
   ⭐ 单调上升,且【趋近于 1】。

⟹ 一般化:
   凡是"所有 X 都必须做 Y"的要求,
   ⭐ 都应该被实现成"不做 Y 就无法成为 X"的结构,
      而不是一条写在文档里的纪律。

这条原则的应用远不止安全:它是第 9 篇“SafeMath 漏了一行"的通解,也是第 1 篇跨函数重入的通解。

八、本案小结

  • 漏洞是一个函数漏了一行健康度检查。 没有重入、没有溢出、没有权限绕过——每一步都在协议规则内
  • ⭐⭐ “这个函数只让用户减少自己的资产,不可能有害"是一个非常有说服力的错误直觉。 在借贷协议里,账户的健康度是协议的公共状态,不是用户的私事
  • 攻击者的洞察:既然清算折扣随"有多不健康"递增,就把自己弄到要多不健康有多不健康,然后自己清算自己,吃掉最高档折扣。
  • ⚠️ 这个协议做对了几乎所有事:多轮独立审计、高额赏金、部分形式化验证、代码质量很高。它打破了"被黑 = 不重视安全"的直觉。 -⭐ 根因是"约定"而不是"机制”:每个函数自己记得调用 checkLiquidity,而约定的可靠性取决于所有人永远不忘——十几个函数里漏一个就够了
  • 防御核心:让"正确"成为默认,让"绕过"需要显式声明。 把所有入口收敛到一个强制做健康检查的路由,新增函数时不可能忘记,因为检查不在函数里,在它外面
  • “深度不健康的账户,清算需延迟一个区块"特别有效——它让"自己弄坏自己再自己清算"无法在一笔原子交易内完成,从而暴露给其他清算人竞争。 -⭐ 递增的激励可以被反向利用:任何"情况越糟、奖励越高"的机制,都在鼓励某人把情况弄糟。设计激励时必须问"谁能人为制造触发条件?成本多少?” -⭐ 凡是"所有 X 都必须做 Y"的要求,都应该被实现成"不做 Y 就无法成为 X"的结构,而不是一条写在文档里的纪律。这是 SafeMath 漏一行、重入锁漏一个函数的通解。

思考题

  1. 为什么"用户自愿减少自己的资产"在借贷协议里不是无害的?请精确说出它改变了哪个公共状态。
  2. 清算折扣为什么要随不健康程度递增?这个设计意图是什么?它怎么被反向利用了?
  3. modifier 和用"统一路由"两种方式强制健康检查,各有什么优缺点?
  4. 列出一个借贷协议里所有"会降低账户健康度"的操作。你能想到几条本篇没列出的?
  5. require(liquidator != borrower) 为什么挡不住这次攻击?它还有什么价值吗?
  6. “深度不健康的账户,清算需要延迟一个区块”——请论证它为什么有效,以及它在真实市场暴跌时的代价。
  7. 写出"自我清算永不获利"这条不变量的测试。它需要模拟什么?
  8. 举出三个"情况越糟、奖励越高"的机制(本篇之外),并分析各自能否被人为制造条件。
  9. “约定 vs 机制"这条原则,请应用到第 9 篇的 SafeMath 问题上:怎样把"每处算术都要用 SafeMath"变成一个机制?
  10. Euler 有多轮审计和高额赏金却仍然被打穿。这对你如何看待"审计通过"这件事有什么影响?