时间 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 个区块、分档清算而非一次清完。 -⭐ 返回一个错的值比拒绝返回危险得多——合约必须能表达"我现在不知道价格"这个状态。 -⭐ 心态调整:不要只问"有没有人能骗过我的检查",要问"如果没人骗我、检查全过,我还是可能做错事吗?" 答案几乎总是"是",而它指向系统里最没有防御的部分。

思考题

  1. 把"预言机报告的价格 ≈ 该资产的可清算价值"拆成四层,说明哪几层在本案中不成立。
  2. 为什么说这次事故"不会触发任何告警"?请设计一个发现它的监控指标。
  3. 为什么"测试无法覆盖它"?如果一定要写一个测试,你会测什么?
  4. 中位数比平均数好在哪?三个数据源里有一个离群值时,两者分别给出什么结果?
  5. “五个数据源都从同一个聚合 API 取数等于一个数据源。“请给出检查数据源真实独立性的三个具体方法。
  6. 设计"异常时暂停"的完整状态机:什么条件进入暂停、暂停时允许什么操作、什么条件退出。
  7. “要求抵押率持续 N 个区块低于清算线”——N 取多大?请分别论证 N 太小和 N 太大的后果。
  8. 分档清算(每次最多清算 50%)会不会让协议在真实暴跌时来不及清完?请量化分析。
  9. 用本篇的表格方法,检查你系统里的三个外部接口:它承诺了什么、你用它做什么判断、中间的假设是什么。
  10. “如果没有人骗我、我的检查全部通过,我还是可能做错事吗?"——请对你自己的协议认真回答这个问题,写出至少两个场景。