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