时间 2021 年 10 月 27 日
涉案 约 $1.3 亿
类别 C4 外部数据假设失效
新形态 ⭐ 不经过任何 DEX 的价格操纵

一、背景:把"金库份额"当抵押品

Cream 是一个借贷协议。它允许用户把 yUSD——一个 Yearn 金库的份额代币——存进来作为抵押品。

要给 yUSD 估值,Cream 需要知道"一份 yUSD 值多少底层资产":

⭐ 份额价格 = 金库的总资产 / 金库的份额总量

           pricePerShare = totalAssets / totalSupply

这个公式本身完全正确,它就是份额代币的定义。

二、⭐ 漏洞在哪:totalAssets 可以在没人调用任何函数的情况下变大

这是本篇的核心,也是很多人第一次遇到时会愣一下的地方:

⭐⭐ 一个合约的代币余额,可以被【任何人】增加,
    而不需要调用它的任何函数。

    因为 ERC-20 的 transfer 是【代币合约】的函数,
    ⚠️ 被转入的那个合约完全不会被通知,
       更没有机会拒绝。

于是:

function totalAssets() public view returns (uint) {
    return token.balanceOf(address(this));   // ⚠️ 任何人都能让它变大
}

function pricePerShare() public view returns (uint) {
    return totalAssets() * 1e18 / totalSupply;
    //     ↑ 分子可被外部拉高    ↑ 分母【不变】
}

⭐⭐ 一次直接转账(“捐赠”),就把 pricePerShare 抬高了,而没有铸造任何新份额。

分子涨、分母不动 ⟹ 每一份现存份额都"变贵"了。

注意这和前面几篇的区别:

[第 12 篇](https://blog.ifcalm.org/posts/security/blockchain/12-bzx/) bZx:      在 DEX 上做一笔 swap 推动池子价格
[第 13 篇](https://blog.ifcalm.org/posts/security/blockchain/13-harvest-finance/) Harvest:  让池子失衡,制造两次估值的差
⭐ 本篇 Cream:  ⚠️ 【一次普通的 transfer】——
                 不需要 DEX、不需要滑点、不需要池子深度

这意味着操纵成本的计算方式完全不同:不再是 y(√k − 1),而是你想把份额价格抬高多少倍,就得捐多少钱——而这笔钱在攻击成功后会连本带利回到你手里(因为你也持有份额)。

三、攻击复盘

① 攻击者在 Cream 存入 yUSD 作为抵押品
      ⟹ 此时 Cream 记录:抵押品价值 = N × pricePerShare

② 闪电贷借入大量底层资产

③ ⭐ 把底层资产【直接转入】Yearn 金库合约
      ⟹ totalAssets 翻倍
      ⟹ totalSupply 不变
      ⟹ ⭐ pricePerShare 翻倍

④ Cream 重新读取 yUSD 的价格
      ⟹ ⚠️ 攻击者的抵押品价值【翻倍】

⑤ 按虚高的抵押品借出 Cream 里的其他资产

⑥ 赎回自己在金库里的份额
      ⟹ ⭐ 第 ③ 步捐进去的钱,按份额比例拿回来大部分

⑦ 还掉闪电贷,带走第 ⑤ 步借出的资产

第 ⑥ 步是这个攻击优雅(也可怕)的地方:捐赠不是沉没成本。攻击者持有金库份额,所以他捐进去的钱按比例又回到了他自己手里。 真实成本只是他不持有的那部分份额对应的比例。

⚠️ 推论:攻击者在金库里的份额占比越高,操纵成本越低。 如果他持有 90% 的份额,捐 100 万只真正损失 10 万。

四、为什么没被发现

① 公式是对的

⭐ pricePerShare = totalAssets / totalSupply
   这个公式在任何一份审计里都会被判定为"正确"。

⚠️ 因为它【确实是】份额价格的定义。

⟹ 漏洞不在公式里,在【分子的可写性】上 ——
   而"谁能改变分子"这个问题,
   不属于"公式对不对"这个问题的范围。

② 直接转账不产生任何可观察的协议事件

攻击者的第 ③ 步:
   token.transfer(vault, amount)

⟹ 触发的是【代币合约】的 Transfer 事件
⚠️ 金库自己没有任何函数被调用,
   没有 Deposit 事件,没有日志,
   ⭐ 金库根本"不知道"自己变有钱了。

⟹ 任何监听金库自身事件的监控,全部看不见这一步。

③ 又是跨协议的组合

Yearn 的 pricePerShare —— 对 Yearn 自己的用户是正确的
   (捐赠让所有份额持有人一起受益,没有不公)

⚠️ 但 Cream 把它当成了【不可操纵的价格源】。

⭐ 一个对 A 协议无害的性质,
   被 B 协议当成安全假设时,就成了漏洞。

五、防御

① 内部记账,不要用 balanceOf

// ⚠️ 危险:外部可写
function totalAssets() public view returns (uint) {
    return token.balanceOf(address(this));
}

// ✅ 安全:只有经过我的函数的资产才算数
uint256 private _totalAssets;

function deposit(uint amount) external {
    token.transferFrom(msg.sender, address(this), amount);
    _totalAssets += amount;      // ⭐ 显式记账
    _mint(msg.sender, shares);
}

代价:直接转入的资产会变成"孤儿",需要一个 sweep() 之类的函数来处理。但这个代价远小于漏洞。

② 用"虚拟份额/虚拟资产"抵消

// ⭐ OpenZeppelin ERC-4626 的做法:在分子分母上各加一个偏移量
function _convertToShares(uint256 assets) internal view returns (uint256) {
    return assets * (totalSupply() + 10 ** _decimalsOffset())
                  / (totalAssets() + 1);
}

原理:加上虚拟的份额和资产之后,攻击者要把份额价格抬高 N 倍,需要捐的钱变成 N × 10^offset 量级——offset 取 6 就意味着操纵成本上升一百万倍。这不是消除漏洞,是把它的成本抬到不划算

⚠️ 详见第 19 篇——同一个数学问题的另一面。

③ 作为"读取方":不要相信别人的份额价格

这是 Cream 真正该做的事。

如果你要给一个"金库份额代币"估值:

   ❌ 直接读它的 pricePerShare
   ✅ 自己算:底层资产的独立价格 × 你能赎回的底层数量
   ✅ 或者:给这个价格套一个 TWAP / 变动上限

⭐ 关键是问一句:
   "这个数字,谁能改?改一次要多少钱?"

④ 变动率限制

// ⭐ 一个通用的护栏:任何价格在一个区块内的变动不得超过 X%
uint256 newPrice = oracle.price();
uint256 maxMove  = lastPrice * MAX_MOVE_BPS / 10_000;
require(_absDiff(newPrice, lastPrice) <= maxMove, "price moved too fast");

⚠️ 它挡不住"缓慢的操纵",也会在真实剧烈行情中误伤——但它能挡住本案这种"一个区块内翻倍"的形态。当作纵深防御的一层。

六、⭐ 举一反三

核心命题:谁能改变这个数?

⭐⭐ 对每一个你依赖的数值,问一遍:“除了我的函数,还有谁能改变它?”

⚠️ 在 EVM 里,下面这些量都可以被【外部无声地】改变:

   · address(this).balance            ⟹ ⭐ 可被 selfdestruct 强制打钱
                                          (即使你没有 receive 函数!)
   · token.balanceOf(address(this))   ⟹ 任何人 transfer 进来(本篇)
   · 一个 NFT 集合的地板价            ⟹ 自买自卖
   · block.timestamp                  ⟹ ⚠️ 出块者可在一定范围内漂移
   · blockhash / prevrandao           ⟹ 出块者可以选择不出这个块
   · ⭐ 任何"读外部合约状态"的量        ⟹ 那个合约的所有用户

address(this).balance 那一条特别值得注意selfdestruct(以及区块奖励接收)可以向任何地址强制打入 ETH,接收方无法拒绝、也没有回调。任何基于"合约余额恰好等于我记录的数"的逻辑,都会被这一招破坏。

同一个模式:捐赠攻击的其它形态

· ⭐ ERC-4626 第一存款人   ⟹ [第 19 篇](https://blog.ifcalm.org/posts/security/blockchain/19-erc4626-inflation/)
· ⭐ Euler 的自我清算      ⟹ [第 18 篇](https://blog.ifcalm.org/posts/security/blockchain/18-euler-finance/)(用捐赠把自己变成可清算)
· 奖励池被稀释:往奖励合约转账 ⟹ 改变每份奖励
· 手续费分账:往分账合约转账 ⟹ 改变分配比例
· ⚠️ 用 balanceOf 判断"是否已收到付款" ⟹ 别人的转账被当成你的付款

一条通用的设计规则

⭐⭐ 协议的内部状态,必须只能通过协议自己的函数改变。

任何直接读取"外部可写的量"(余额、外部合约状态、区块变量)来做决策的地方,都必须先问:改变它需要多少钱?

⭐ 实践做法:
   ① 内部记账变量(_totalAssets)作为唯一真相
   ② balanceOf 只在【入账时】用来核对实际到账
      (见[第 2 篇](https://blog.ifcalm.org/posts/security/blockchain/02-lendfme-erc777/)的余额差记账)
   ③ 两者的差额通过显式的 skim()/sweep() 处理,
      而不是自动计入

关于"操纵成本"的一个反直觉点

⭐ 捐赠攻击的成本不是"捐了多少钱",
   而是"捐的钱里有多少比例不会回到我手上"。

⟹ 攻击者持有的份额占比 p:
      真实成本 ≈ 捐赠额 × (1 − p)

⚠️ 所以:
   ① 份额高度集中的金库,操纵成本极低
   ② ⭐ 一个空的、或者只有攻击者一个存款人的金库,
      操纵成本【趋近于零】——这正是第一存款人攻击。

七、本案小结

  • 价格操纵没有动用任何 DEX:攻击者直接往 Yearn 金库 transfer 了一笔底层资产,pricePerShare 立刻翻倍。
  • ⭐⭐ 根因:一个合约的代币余额可以被任何人增加,而不需要调用它的任何函数。 ERC-20 的 transfer 是代币合约的函数,被转入的合约完全不会被通知,更没有机会拒绝
  • pricePerShare = totalAssets / totalSupply 的分子可被外部拉高,分母不动——每一份现存份额都"变贵"了,而没有铸造任何新份额。 -⭐ 捐赠不是沉没成本:攻击者也持有份额,捐进去的钱按比例又回到他手里。真实成本 ≈ 捐赠额 × (1 − 他的份额占比)——份额越集中,操纵越便宜;只有他一个存款人时成本趋近于零。
  • ⚠️ 直接转账不产生任何协议事件:金库没有任何函数被调用、没有 Deposit 事件、金库根本"不知道"自己变有钱了。监听金库自身事件的监控全部看不见。
  • 公式是对的——漏洞不在公式里,在分子的可写性上,而"谁能改变分子"不属于"公式对不对"的范围。
  • 对 Yearn 无害的性质(捐赠让所有份额持有人一起受益,没有不公),被 Cream 当成安全假设时就成了漏洞。
  • 防御:内部记账变量作为唯一真相、虚拟份额偏移把成本抬高几个数量级、作为读取方自己算价格而不是相信别人的 pricePerShare、加单区块变动率上限。 -⭐ 核心检查:对每一个依赖的数值问"除了我的函数,还有谁能改变它?" 特别注意 address(this).balance ——selfdestruct 可以向任何地址强制打入 ETH,接收方无法拒绝也没有回调

思考题

  1. 为什么一个合约无法拒绝别人给它转 ERC-20 代币?这和 ETH 转账有什么区别?
  2. 攻击者持有金库 60% 的份额,捐入 1000 万。他的真实成本是多少?如果持有 95% 呢?
  3. pricePerShare 的公式是正确的。请精确说明"漏洞不在公式里"是什么意思。
  4. 用内部记账重写 totalAssets()。直接转入的资产会怎样?你打算怎么处理它们?
  5. 虚拟份额偏移(_decimalsOffset)为什么能把操纵成本抬高几个数量级?请做一个具体的量化。
  6. 作为 Cream,你应该怎么给 yUSD 估值?写出你的方案并指出它引入的新依赖。
  7. selfdestruct 可以向任何地址强制打入 ETH。请构造一个因此被破坏的合约逻辑(提示:address(this).balance == recorded)。
  8. 单区块变动率上限能挡住本案,挡不住什么?请给出一个绕过它的攻击变体。
  9. 列出你系统里所有"读取外部可写的量"的地方,逐个回答"改变它需要多少钱"。
  10. 本篇、第 18 篇第 19 篇都属于"捐赠攻击"。在读后两篇之前,先自己推测:怎样用捐赠把自己变成可以被清算的?