| 时间 | 2020 年 11 月 26 日 |
| 规模 | 约 $8900 万仓位被清算 |
| 攻击者 | ⭐ 没有 |
| 代码 bug | ⭐ 没有 |
| 类别 | C4 外部数据假设失效 |
一、背景:这一篇为什么排在这里
前面十三篇都有一个攻击者。这一篇没有。
⚠️ 2020 年 11 月 26 日发生的事:
· 预言机的每一个节点都正常工作
· 每个节点报告的价格都是真实的
· 聚合逻辑正确
· Compound 的清算逻辑正确执行
· 没有任何一行代码有 bug
⭐ 然后约 8900 万美元的仓位被清算了,
其中很大一部分本不该被清算。
如果你只有时间读这个档案里的三篇,这应该是其中之一。 因为它揭示的失效模式,无法通过"把代码写对"来避免。
二、发生了什么
Compound 当时用一个价格源(Open Price Feed)为抵押品估值,其中 DAI 的价格来自若干中心化交易所。
DAI 是一个稳定币,正常情况下 ≈ $1.00
2020-11-26,在某个交易所上,
⚠️ DAI 的价格在短时间内冲到约 $1.30
⟹ 原因不是 DAI 脱锚了,
⭐ 是那个交易所的 DAI 交易对【流动性不足】,
一笔较大的买单就把价格推上去了。
Compound 的价格源忠实地读到了这个数:
⟹ 所有以 DAI 计价的【借款】,价值被高估 30%
⟹ 大批仓位的抵押率瞬间跌破清算线
⟹ 清算机器人按设计执行清算
⟹ ⭐ 约 8900 万美元的仓位被清算,
借款人损失了清算罚金
价格在很短时间内回落。但清算是不可逆的。
三、⭐ 漏洞在哪:一条从未被写下来的假设
这是本篇的核心。 整个系统里有一条假设,所有人都默认它成立,而它从来没有出现在任何文档、注释或审计报告里:
⭐⭐ 「预言机报告的价格」 ≈ 「该资产的可清算价值」
拆开看,这条假设其实包含了好几层:
① 报告的价格是真实成交出来的 ✅ 这一层成立
② 该成交发生在一个有足够深度的市场 ⚠️ 这一层【不成立】
③ 该价格能代表整体市场共识 ⚠️ 这一层【不成立】
④ 按该价格清算,抵押品确实能卖出去 ⚠️ 这一层【不成立】
⟹ 系统只验证了第 ①层,
⭐ 而做决策时用到了第 ②③④ 层。
⭐⭐ 这就是"抽象泄漏"的完整形态:
接口说的是
getPrice() returns (uint)——一个数。 调用方需要的是"这个资产值多少钱"——一个判断。 两者之间的差距,就是所有没被写下来的假设。
四、为什么这类失效特别难防
① 它不会触发任何告警
⚠️ 想象你在事发当时看监控面板:
预言机节点在线率 100% ✅
价格更新延迟 正常 ✅
节点间价格偏离 很小 ✅(大家都读到了同一个真实价格)
清算逻辑执行成功率 100% ✅
合约调用无异常 ✅
⭐ 每一个指标都是绿的。
而系统正在做错误的事。
② 测试无法覆盖它
你可以测:
□ 价格上涨 ⟹ 清算逻辑正确 ✅
□ 价格下跌 ⟹ 清算逻辑正确 ✅
□ 预言机返回 0 ⟹ 拒绝 ✅
□ 预言机停止更新 ⟹ 拒绝 ✅
⚠️ 但你无法测:"预言机返回了一个真实的、
但不具代表性的价格" ——
⭐ 因为在代码看来,它和一个正常价格【完全没有区别】。
③ 责任无法归属
交易所:我如实撮合了订单,价格是真的
预言机:我如实报告了交易所的价格
Compound:我按预言机的价格执行了写好的规则
清算人:我执行了协议允许的操作
⭐ 每一方都做了它该做的事。
⚠️ 而借款人损失了钱。
五、防御
⭐ 既然不能靠"把代码写对",就只能靠"承认价格可能不可信,并为此留出动作空间"。
① 多源 + 偏离检测
uint256 p1 = sourceA.price();
uint256 p2 = sourceB.price();
uint256 p3 = twap30min.price();
// ⭐ 取中位数,而不是平均数(平均数会被一个离群值拉走)
uint256 median = _median(p1, p2, p3);
// ⭐ 如果任意一源偏离中位数超过阈值 ⟹ 判定为【市场异常】
require(_maxDeviation(p1, p2, p3, median) < DEVIATION_LIMIT, "abnormal market");
⚠️ 注意"独立"这个词的分量:
五个数据源,如果它们都从同一个聚合 API 取数
⟹ ⭐ 等于一个数据源。
⟹ 检查你的数据源在【底层】是不是真的独立:
不同的交易所、不同的地域、不同的做市商群体、
⚠️ 以及不同的数据供应商。
② 异常时暂停,而不是照单全收
⭐⭐ 返回一个错的值,比拒绝返回危险得多。
⭐ 合约必须能表达"我现在不知道价格"这个状态:
□ 偏离超阈值 ⟹ 暂停清算
□ 价格更新超时 ⟹ 暂停清算
□ 价格变动超过 X%/分钟 ⟹ 暂停清算并告警
⟹ 暂停期间:
✅ 允许用户【补充抵押品】(帮他们自救)
✅ 允许用户【还款】
❌ 禁止清算、禁止新增借款
③ 清算要有时间维度
⭐ 不要"价格一跌破线就立刻清算",而是:
要求"抵押率持续低于清算线 N 个区块"才可清算
⟹ 瞬时的价格尖刺自动被过滤掉,
而真实的、持续的下跌照样会触发清算。
⚠️ 代价:真实暴跌时清算晚了 N 个区块 ⟹ 坏账风险上升。
⭐ N 的选择是一个必须显式做出的取舍,不是默认值。
④ 分档清算,而不是一次清算完
⚠️ 一次性清算全部仓位,会把瞬时错误放大成永久损失。
⭐ 更好的设计:
· 每次最多清算仓位的一小部分(比如 50%)
· 清算折扣随抵押率恶化程度【渐进】上升,
而不是一个固定的大折扣
⟹ 价格尖刺造成的损失被限制在一小部分,
而真正资不抵债的仓位仍会被逐步清完。
⑤ 上架资产时评估"价格的可信度"
⭐ 上架一个抵押品之前,除了问"它值多少钱",还要问:
□ 它在各个市场上的【深度】是多少?
□ 把它的价格推动 10% 需要多少钱?
□ ⚠️ 我协议里该资产的敞口,和上面那个数比,谁大?
□ 它有没有历史上的脱锚/闪崩记录?
□ 极端行情下,它真的能被卖出去吗?
六、⭐ 举一反三
核心命题
⭐⭐ 每个组件都符合规范,而系统整体的行为是错的——因为规范里那个隐含的假设从来没有被写下来。
这是整个档案里最难防的一类失效,也是唯一一类无法通过"更仔细地写代码"解决的。
找出你自己系统里的这类假设
⭐ 方法:对每一个外部接口,写出两句话 ——
① 它【承诺】了什么?(看文档/接口签名)
② 我【用它来做】什么决策?
⚠️ 两者之间的差,就是你在默认的假设。
把它们逐条写下来,然后逐条问:"它什么时候会不成立?"
几个常见的例子:
| 接口承诺 | 你实际用它做的判断 | ⚠️ 中间的假设 |
|---|---|---|
getPrice() 返回一个数 |
这个资产值多少钱 | 该价格有代表性、有深度 |
balanceOf() 返回余额 |
这个人有多少钱 | 余额不会突变、不会被冻结 |
block.timestamp |
现在几点 | ⚠️ 出块者不会操纵它(可漂移十几秒) |
transfer() 返回 true |
钱到账了 | 没有手续费、没被黑名单拦 |
| 签名验证通过 | 这个人授权了这件事 | 他签的时候知道自己在签什么(见 第 23 篇) |
| 交易上链了 | 这件事已成定局 | 不会被重组 |
| ZK 证明通过 | 数据是对的 | ⭐ 只证明了计算,没证明输入 |
同一类失效的其它真实形态
· ⭐ 稳定币短时脱锚 ⟹ 大规模误清算(本篇)
· 某交易所停牌 ⟹ 最后成交价被冻结在一个陈旧的数上
· ⚠️ 抵押品在链上有价格、但根本没有买家 ⟹ 清算无法完成
· 一个 LP 代币"值 100 美元",但赎回时会因滑点只剩 60
· NFT 地板价被一笔自买自卖拉高 ⟹ NFT 借贷协议超额放款
· ⭐ 极端行情下所有资产相关性趋近 1 ⟹ "分散化"的抵押品组合同时崩塌
一条心态上的调整
⭐ 大多数安全工作在问:"有没有人能骗过我的检查?"
⚠️ 这一篇要求你问一个不同的问题:
⭐⭐【如果没有人骗我,我的检查全部通过,
我还是可能做错事吗?】
⟹ 这个问题的答案几乎总是"是",
而它指向的地方,就是你系统里最没有防御的部分。
七、本案小结
- ⭐⭐ 没有攻击者,没有代码 bug。 预言机的每个节点正常工作、报告的价格真实、聚合正确、清算逻辑正确——然后约 $8900 万仓位被清算,其中很大一部分本不该被清算。
- 成因:DAI 在某个交易所因流动性不足被一笔较大买单推到约 $1.30,价格源忠实地读到了它。 -⭐ 一条从未被写下来的假设:「预言机报告的价格」≈「该资产的可清算价值」。系统只验证了"价格是真实成交出来的",而做决策时用到了"有深度、有代表性、能卖得掉"这三层。
- 抽象泄漏的完整形态:接口说的是
getPrice() returns (uint)——一个数;调用方需要的是"这个资产值多少钱"——一个判断。两者之间的差距就是所有没被写下来的假设。 - ⚠️ 它不会触发任何告警:节点在线率、更新延迟、节点间偏离、清算成功率——每一个指标都是绿的,而系统正在做错误的事。
- ⚠️ 测试无法覆盖它:你无法测"预言机返回了一个真实但不具代表性的价格",因为在代码看来它和正常价格完全没有区别。
- 责任无法归属:交易所、预言机、Compound、清算人,每一方都做了它该做的事,而借款人损失了钱。 -⭐ 防御的方向不是"把代码写对",是"承认价格可能不可信并留出动作空间":多源 + 偏离检测(且数据源必须底层真独立)、异常时暂停而不是照单全收、清算要求持续 N 个区块、分档清算而非一次清完。 -⭐ 返回一个错的值比拒绝返回危险得多——合约必须能表达"我现在不知道价格"这个状态。 -⭐ 心态调整:不要只问"有没有人能骗过我的检查",要问"如果没人骗我、检查全过,我还是可能做错事吗?" 答案几乎总是"是",而它指向系统里最没有防御的部分。
思考题
- 把"预言机报告的价格 ≈ 该资产的可清算价值"拆成四层,说明哪几层在本案中不成立。
- 为什么说这次事故"不会触发任何告警"?请设计一个能发现它的监控指标。
- 为什么"测试无法覆盖它"?如果一定要写一个测试,你会测什么?
- 中位数比平均数好在哪?三个数据源里有一个离群值时,两者分别给出什么结果?
- “五个数据源都从同一个聚合 API 取数等于一个数据源。“请给出检查数据源真实独立性的三个具体方法。
- 设计"异常时暂停"的完整状态机:什么条件进入暂停、暂停时允许什么操作、什么条件退出。
- “要求抵押率持续 N 个区块低于清算线”——N 取多大?请分别论证 N 太小和 N 太大的后果。
- 分档清算(每次最多清算 50%)会不会让协议在真实暴跌时来不及清完?请量化分析。
- 用本篇的表格方法,检查你系统里的三个外部接口:它承诺了什么、你用它做什么判断、中间的假设是什么。
- “如果没有人骗我、我的检查全部通过,我还是可能做错事吗?"——请对你自己的协议认真回答这个问题,写出至少两个场景。