形态 ⭐ 一个反复出现的模式,不是单一事故
首次广泛讨论 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() 里预置的状态删掉,看有多少测试还能跑。 -⭐ 每一次"重写"标准库的实现,都是在丢掉你不知道存在的那些修复。 库里那些奇怪的常量通常是一起事故的化石。

思考题

  1. 手算一遍完整攻击,写出每一步的 totalSupplytotalAssets。为什么第 3 步会向下取整成 0?
  2. 如果受害者存的是 3 万而不是 1 万,他会拿到几份额?损失比例是多少?
  3. _decimalsOffset() 取 6 时,攻击者需要捐多少钱才能让 1 万美元的存款归零?请算出这个数。
  4. 虚拟份额"不是消除漏洞,是把成本抬到不划算”。offset 取多少才算够?这取决于什么?
  5. 写出 deposit / mint / withdraw / redeem 四个方向的正确舍入,并各举一个写反的后果。
  6. require(shares > 0) 挡住了什么、挡不住什么?
  7. 用内部记账消灭捐赠这一步。如果金库的策略会产生利息收益,_totalAssets 该怎么更新才安全?
  8. 把你项目的 setUp() 里预置的状态删掉。有多少测试跑不了?这说明了什么?
  9. 对你代码里的每一个除法,回答本篇的三个问题(分母能否为 0、谁能改、往哪取整)。
  10. 去读 OpenZeppelin ERC-4626 的提交历史,找出至少一个"奇怪的设计",并查出它对应的是哪起事故或哪个 issue。