| 形态 | ⭐ 一个反复出现的模式,不是单一事故 |
| 首次广泛讨论 | 2021 年(Compound / Cream 系代码) |
| 仍在发生 | ⚠️ 每年都有新协议因它损失资金 |
| 类别 | C5 经济假设失效 + C3 数值假设失效 |
一、背景:份额金库的两个公式
所有"存入资产、拿到份额"的金库(ERC-4626 是它的标准)都基于这两个公式:
⭐ 存入: 份额 = 存入金额 × 总份额 / 总资产
⭐ 取出: 金额 = 份额 × 总资产 / 总份额
两个公式互为逆运算,看起来完美对称。问题出在两个地方:
① ⚠️ 整数除法【向下取整】—— 除不尽的部分被丢掉
② ⚠️ 总资产可以被【直接转账】改变([第 15 篇](https://blog.ifcalm.org/posts/security/blockchain/15-cream-finance/)的捐赠攻击)
二、攻击复盘:一个能手算的例子
假设一个全新的、空的 USDC 金库(USDC 是 6 位小数)。
初始状态: 总份额 = 0 总资产 = 0
━━━ 第 1 步:攻击者存入 1 wei ━━━
金库是空的,第一笔存款通常按 1:1 铸造份额
⟹ 攻击者获得 1 份额
状态: 总份额 = 1 总资产 = 1
━━━ 第 2 步:攻击者【直接转账】1 万 USDC 给金库 ━━━
⭐ 不调用 deposit,只是一次普通的 transfer
⟹ 没有铸造任何新份额
状态: 总份额 = 1 总资产 = 10,000,000,001
(1 万 USDC = 1e10 wei)
⚠️ 现在 1 份额 ≈ 1 万 USDC
━━━ 第 3 步:受害者存入 1 万 USDC ━━━
份额 = 10,000,000,000 × 1 / 10,000,000,001
= 0.99999999990...
⭐⭐ 向下取整 ⟹ 【0 份额】
状态: 总份额 = 1 总资产 = 20,000,000,001
⟹ ⚠️ 受害者存了 1 万美元,拿到 0 份额。
━━━ 第 4 步:攻击者赎回他那 1 份额 ━━━
金额 = 1 × 20,000,000,001 / 1 = 全部
⟹ ⭐ 攻击者取走 2 万 USDC(自己的 1 万 + 受害者的 1 万)
⭐⭐ 攻击者的成本是那 1 万 USDC 的"捐赠"——而它在第 4 步全额回到了他手里。 真实成本只有 Gas。
一个更隐蔽的变体:部分损失
⚠️ 不需要让受害者拿到 0 份额,攻击也成立。 如果受害者拿到 1 份额(而不是应得的 2 份额),他就损失了一半:
⭐ 所以"第一存款人攻击"这个名字有误导性 ——
它的一般形态是:
【任何时候,只要 总资产/总份额 的比值足够大,
后续存款人的舍入损失就足够大。】
三、为什么这个模式如此顽固
① 公式本身是对的
⭐ shares = assets × totalSupply / totalAssets
—— 这在任何金融教科书里都是正确的份额计算。
⚠️ 出问题的是它在【整数运算 + 可被外部改变的分母】
这个环境里的行为。
② 它只在"金库为空或接近空"时可利用
⚠️ 于是它在测试里几乎不可能被发现:
测试通常从"金库里已经有一些资产"开始,
或者只测一个存款人的正常流程。
⭐ 而攻击窗口恰恰是【部署后、第一个真实用户到来之前】
那几分钟到几小时。
③ 它被反复重新引入
⚠️ 这个模式在 2021 年就被公开分析过,
OpenZeppelin 在 ERC-4626 实现里做了防护,
⭐ 但每年仍然有新协议中招 ——
原因通常是:
· 自己实现了份额逻辑,没用现成的库
· 用了库,但重写了 _convertToShares
· fork 了一份旧代码([第 2 篇](https://blog.ifcalm.org/posts/security/blockchain/02-lendfme-erc777/)的问题)
· ⭐ "我们的金库不会是空的" —— 一个部署时的假设
四、防御
① 虚拟份额 / 虚拟资产(推荐)
这是 OpenZeppelin ERC-4626 的做法:
function _convertToShares(uint256 assets, Math.Rounding rounding)
internal view returns (uint256)
{
return assets.mulDiv(
totalSupply() + 10 ** _decimalsOffset(), // ⭐ 虚拟份额
totalAssets() + 1, // ⭐ 虚拟资产
rounding
);
}
⭐ 原理:金库在数学上永远"不为空"——它总有 10^offset 个虚拟份额和 1 个虚拟资产。
取 offset = 6 时,重算上面的例子:
第 1 步:攻击者存 1 wei
份额 = 1 × (0 + 1e6) / (0 + 1) = ⭐ 1,000,000 份额
(而不是 1 份额)
第 2 步:要让受害者的份额归零,
攻击者需要把"每份额对应的资产"抬高 100 万倍
⟹ ⭐ 捐赠额需要是原来的 10⁶ 倍
⟹ 攻击成本从"1 万美元"变成"100 亿美元",
⚠️ 而且这笔钱在攻击中是【真实暴露】的
(受害者存款前的那段时间里,任何人都可以来分走它)
⭐ 注意这不是"消除漏洞",是"把成本抬到不划算"。 这个区别很重要——offset 太小仍然可能被攻击。
② 部署时铸造"死份额"
constructor() {
// ⭐ 部署时存入一笔最小金额,把份额铸给零地址(永久锁定)
_mint(address(0xdead), 1000);
// 或者由部署者存入一笔不会被取出的种子资金
}
⚠️ 代价:这笔钱是真金白银且永久损失。但它彻底消灭了"金库为空"这个状态。
③ 最小存款额 + 最小铸造份额
function deposit(uint256 assets, address receiver) public returns (uint256) {
uint256 shares = previewDeposit(assets);
require(shares > 0, "zero shares"); // ⭐ 底线
require(shares >= MIN_SHARES, "too few shares"); // ⭐ 更强
...
}
⭐ require(shares > 0) 是最低限度的防线——它把"静默损失全部本金"变成"交易失败"。任何份额金库都应该有这一行。
④ 内部记账(同第 15 篇)
uint256 private _totalAssets; // ⭐ 只有经过我的函数的资产才算数
⚠️ 这直接消灭了捐赠这一步。但要注意:如果金库的策略会产生收益(利息、手续费),_totalAssets 需要有一个受控的更新路径。
⑤ 那条往返不变量(同第 13 篇)
function invariant_depositThenRedeemNeverProfits(uint256 amt) public {
uint256 balBefore = asset.balanceOf(user);
uint256 shares = vault.deposit(amt, user);
vault.redeem(shares, user, user);
assertLe(asset.balanceOf(user), balBefore); // ⭐
}
⭐ 配合 fuzz 从"空金库"状态开始跑,能直接抓住本案。 关键是初始状态必须包含"空"——这正是大多数测试套件缺失的。
五、⭐ 举一反三
核心命题一:舍入方向必须始终对协议有利
⭐⭐ 在任何"资产 ⟷ 份额"的转换里,舍入方向不是风格问题,是安全属性。
⭐ 四个方向,一个都不能错:
deposit (给出份额) ⟹ 向【下】取整 ← 少给用户
mint (收取资产) ⟹ 向【上】取整 ← 多收用户
withdraw (收取份额) ⟹ 向【上】取整 ← 多收用户
redeem (给出资产) ⟹ 向【下】取整 ← 少给用户
⚠️ 任何一个方向写反,都会让"存入再取出"变得有利可图,
⟹ 攻击者用极小额反复调用,把池子慢慢抽干。
⭐ 这叫舍入攻击,损失是累积的、无声的。
记忆方法:所有的"零头"都应该留在协议里。
核心命题二:空状态与边界状态
⭐ 每一个有状态的系统,都要单独测试它的【边界状态】:
· 完全为空(0 用户、0 资产、0 份额) ← 本案
· 只有 1 个用户
· ⚠️ 最后一个用户退出的那一刻
· 达到某个上限时
· 所有人同时退出
⚠️ 而绝大多数测试从"一个已经正常运转的系统"开始,
⭐ 这恰好跳过了最危险的那几个状态。
⭐ 实践:写测试时,把 setUp() 里预置的状态删掉,看有多少测试还能跑。跑不了的那些,说明它们从来没测过边界。
核心命题三:除法的分母是谁控制的
⭐ 对每一个除法,问三个问题:
① 分母可能是 0 吗?
② ⚠️ 分母能被谁改变?改变一次多少钱?
③ ⭐ 商向哪个方向取整?取整损失落在谁头上?
⟹ 第 ② 问会命中所有捐赠攻击([第 15 篇](https://blog.ifcalm.org/posts/security/blockchain/15-cream-finance/)、本篇)
第 ③ 问会命中所有舍入攻击
核心命题四:库的价值在于它记住了你会忘的事
⭐ OpenZeppelin 的 ERC-4626 实现里那个 _decimalsOffset,
看起来只是一个奇怪的常量。
⚠️ 它是【一次事故的化石】——
有人踩了坑,分析了,把解法固化进了库。
⟹ 所以:
⭐ 每一次你"重写"一个标准库的实现,
都是在丢掉你不知道存在的那些修复。
⟹ 如果必须重写,先去读那个库的
git 历史和 issue —— 每一个奇怪的设计背后
通常都有一起事故。
六、本案小结
- 不是单一事故,是一个从 2021 年至今仍在造成损失的模式。
- ⭐⭐ 攻击只要三步:往空金库存 1 wei 拿到 1 份额 → 直接转账捐入 1 万代币(不铸份额)→ 下一个存款人的份额向下取整成 0。攻击者赎回 1 份额取走全部。
- 攻击者的捐赠成本在最后一步全额回到手里,真实成本只有 Gas。
- ⚠️ “第一存款人攻击"这个名字有误导性:不需要让受害者拿到 0 份额,只要
总资产/总份额的比值足够大,后续存款人的舍入损失就足够大——拿到 1 份额而非应得的 2 份额,就损失了一半。 - 公式本身是对的——出问题的是它在"整数运算 + 可被外部改变的分母"这个环境里的行为。
- ⚠️ 它只在"金库为空或接近空"时可利用,所以测试里几乎不可能被发现——测试通常从"已有资产"开始。攻击窗口是部署后、第一个真实用户到来之前那几分钟。
- 防御:虚拟份额偏移(OZ 的
_decimalsOffset,offset=6 把成本抬高 100 万倍)、部署时铸造死份额、require(shares > 0)这条最低防线、内部记账、从空金库开始跑往返不变量。 -⭐ 舍入方向不是风格问题,是安全属性:deposit/redeem 向下、mint/withdraw 向上——所有零头都应该留在协议里。写反任何一个,都会让往返有利可图。 - 每个有状态的系统都要单独测边界状态:完全为空、只有一个用户、最后一个用户退出、达到上限。把
setUp()里预置的状态删掉,看有多少测试还能跑。 -⭐ 每一次"重写"标准库的实现,都是在丢掉你不知道存在的那些修复。 库里那些奇怪的常量通常是一起事故的化石。
思考题
- 手算一遍完整攻击,写出每一步的
totalSupply和totalAssets。为什么第 3 步会向下取整成 0? - 如果受害者存的是 3 万而不是 1 万,他会拿到几份额?损失比例是多少?
_decimalsOffset()取 6 时,攻击者需要捐多少钱才能让 1 万美元的存款归零?请算出这个数。- 虚拟份额"不是消除漏洞,是把成本抬到不划算”。offset 取多少才算够?这取决于什么?
- 写出
deposit / mint / withdraw / redeem四个方向的正确舍入,并各举一个写反的后果。 require(shares > 0)挡住了什么、挡不住什么?- 用内部记账消灭捐赠这一步。如果金库的策略会产生利息收益,
_totalAssets该怎么更新才安全? - 把你项目的
setUp()里预置的状态删掉。有多少测试跑不了?这说明了什么? - 对你代码里的每一个除法,回答本篇的三个问题(分母能否为 0、谁能改、往哪取整)。
- 去读 OpenZeppelin ERC-4626 的提交历史,找出至少一个"奇怪的设计",并查出它对应的是哪起事故或哪个 issue。