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