时间 2020 年 4 月 19 日
涉案 约 $2500 万
结局 攻击者几乎全额归还
类别 C1 控制流假设失效

一、背景:一个被信任的 fork

Lendf.Me 是 dForce 旗下的借贷协议,代码 fork 自 Compound——当时以太坊上最被信任的借贷协议之一,经过多轮审计,运行了很久没出事。

它的核心逻辑非常直白:

supply(token, amount)      存入抵押品
borrow(token, amount)      按抵押品价值借出别的资产
withdraw(token, amount)    取回抵押品

同时,Lendf.Me 支持上架多种代币作为抵押品。其中一个是 imBTC——一个由 Tokenlon 发行的比特币锚定币。

⚠️ imBTC 不是普通的 ERC-20,它是 ERC-777。 这个区别就是整起事故。

二、漏洞在哪

ERC-777 做了什么

ERC-777 是 ERC-20 的超集,向下兼容,但增加了两个"钩子":

转账发生时,ERC-777 会主动【回调】相关方:

   tokensToSend(...)      ⟹ 回调【转出方】   ⚠️ 在扣款【之前】
   tokensReceived(...)    ⟹ 回调【接收方】   在到账之后

这两个钩子的地址通过 ERC-1820 注册表查找 ——
⭐ 而【任何人都可以为自己注册一个钩子合约】。

注意 tokensToSend 的时机:它在转账真正完成之前触发,回调的对象是转出方——也就是攻击者自己。

Lendf.Me 的 supply 顺序

function supply(address token, uint amount) {
    // ① 把代币从用户转进合约
    doTransferIn(msg.sender, token, amount);
    //     └─ 内部是 token.transferFrom(msg.sender, this, amount)
    //        ⚠️ 对 ERC-777 而言,这里会回调 msg.sender

    // ② 更新内部记账
    supplyBalance[msg.sender][token] += amount;
}

这又是一次"先交互、后记账"——和 The DAO 结构完全一样。区别在于:

⚠️ The DAO 的外部调用是【显式的 ETH 转账】,写代码的人看得见。
⭐ Lendf.Me 的外部调用藏在 transferFrom 里面 ——
   看上去只是"把代币收进来"这么一个平平无奇的动作。

三、攻击复盘

攻击者事先在 ERC-1820 里为自己注册了一个 tokensToSend 钩子。

初始:攻击者在 Lendf.Me 里有一笔正常的 imBTC 抵押

调用 supply(imBTC, 1)
  │
  ├─ ① doTransferIn ⟶ imBTC.transferFrom(攻击者, Lendf.Me, 1)
  │        │
  │        └─ ERC-777 触发 tokensToSend(攻击者, ...)
  │             │  ⭐ 控制权到了攻击者手里,
  │             │     而 supplyBalance【尚未加上本次的 1】
  │             │
  │             └─ 攻击者在钩子里调用 withdraw(imBTC, 全部)
  │                  ⟹ 按【旧的、未更新的】记账取走全部抵押品
  │                  ⟹ 记账被清零,但代币已经取回自己手里
  │
  └─ ② supplyBalance[攻击者][imBTC] += 1
         ⚠️ 外层继续执行,把这个 1 加回到【已经被清零的】余额上

单次循环的净效果:代币回到了攻击者手里,而协议记录的抵押品余额没有归零。 重复足够多次之后:

⭐ 协议账本上:攻击者有巨额 imBTC 抵押
   实际金库里:几乎没有攻击者的 imBTC

⟹ 用这份虚高的抵押品,把池子里【其他所有资产】借空。

结局:攻击者取走约 $2500 万等值资产,随后在几天内几乎全额归还。归还原因未公开,普遍推测与暴露了 IP 有关。

四、为什么没被发现

这一节是本篇真正的价值所在。

① fork 一份安全的代码,不等于得到一份安全的代码

Compound 的代码是安全的 —— 但它的安全性建立在一个前提上:

   ⭐【上架的代币遵守 ERC-20 的语义:
      transferFrom 只是改两个数字,不执行任何第三方代码。】

Compound 当时上架的资产由团队严格筛选,这个前提成立。
⚠️ Lendf.Me 沿用了代码,却【自己上架了一个不满足前提的代币】。

⭐⭐ 代码没被改错。被改错的是它运行的环境。

这是 fork 类事故的通用形态:你继承了代码,但没有继承它的前提清单——因为那份清单从来没被写下来过。

② 前一天,同一个洞刚被用过

⚠️ 2020 年 4 月 18 日——Lendf.Me 出事的前一天——Uniswap V1 的 imBTC 池刚刚被同一个 ERC-777 重入攻击,损失约 $30 万。

⟹ 同一个代币、同一个标准、同一类漏洞,
   在 24 小时内打穿了两个不同的协议。

⭐ 而且 ERC-777 的重入风险在 2019 年就有公开分析文章。

教训不是"没人知道",是"知道的人和写代码的人不是同一批人"。

③ 审计的边界问题

审计报告通常写的是"本次审计范围为 xx 合约"。而 Lendf.Me 的漏洞不在它的合约里——它在"合约 × 上架代币"这个组合里。

⚠️ 单独看 Lendf.Me 的代码:正常。
⚠️ 单独看 imBTC 的代码:符合 ERC-777 标准,正常。
⭐ 把两者放在一起:漏洞。

⟹ 组合性风险不属于任何单个审计的范围。

五、防御

① 仍然是 CEI

function supply(address token, uint amount) external nonReentrant {
    // Effects 先行
    supplyBalance[msg.sender][token] += amount;

    // Interactions 最后
    doTransferIn(msg.sender, token, amount);
}

⚠️ 但这里有个新问题:先记账再收款,如果收款失败怎么办?答案是 doTransferIn 必须严格校验实际到账数量(见下一条),失败则整笔回滚。

② 用"实际余额差"记账,不要相信参数

function doTransferIn(address token, uint amount) internal returns (uint) {
    uint balBefore = IERC20(token).balanceOf(address(this));
    IERC20(token).transferFrom(msg.sender, address(this), amount);
    uint balAfter  = IERC20(token).balanceOf(address(this));
    // ⭐ 只承认真正到账的部分(after/before 在 Solidity 里是保留字,别直接用)
    return balAfter - balBefore;
}

这一条同时挡住了两类问题:转账中途被做手脚,以及转账手续费型代币(实际到账少于 amount)。

③ 全局重入锁,而不是单函数锁

⚠️ Lendf.Me 的重入是跨函数的:进入点是 supply,出手点是 withdraw只给 withdraw 加锁完全无效。

⭐ 规则:锁要覆盖【所有读写同一份记账的入口】。
   借贷协议里通常意味着 supply / withdraw / borrow / repay /
   liquidate 全部共用一把锁。

④ 上架代币时做兼容性审查

新增一个抵押品之前,逐条确认:
   ① 它是不是 ERC-777 / ERC-677 / ERC-1363(带回调)?
   ② 它的 transfer 会不会扣手续费?
   ③ 它会不会 rebase(余额自己变)?
   ④ 它的 transfer 返回 bool 吗?(USDT 不返回)
   ⑤ 它有没有暂停开关、黑名单?谁能按?
   ⑥ 它有没有多个入口地址?
   ⑦ 它的小数位是多少?(不都是 18)
   ⑧ ⭐ 它的合约可以升级吗?今天安全不代表明天安全。

六、⭐ 举一反三

更一般的教训:ERC-20 的心智模型是错的

大多数人写代码时脑子里的 ERC-20 是这样的:

transfer(to, amount)  ⟹  balances[from] -= amount
                          balances[to]   += amount
                          就这两行,不执行任何别的代码

⚠️ 这个模型对相当一部分真实代币不成立。 下面每一种都在真实世界造成过损失:

类型 打破了什么 后果
ERC-777 / 677 / 1363 ⭐ 转账会回调第三方代码 重入(本篇)
转账手续费型(如可开启费率的 USDT) 到账 ≠ 参数 记账虚高,池子被慢慢抽空
Rebasing(如 AMPL、stETH) 余额会自己变 记录的份额与实际余额脱钩
不返回 bool(USDT、BNB 老版) require(token.transfer(...)) 直接 revert 集成失败;用低层 call 又可能忽略失败
可暂停 / 黑名单(USDC、USDT) 转账可能被第三方冻结 清算路径卡死,坏账
多入口地址(TUSD 曾有两个) 同一份余额有两个 token 地址 白名单校验被绕过
小数位非 18(USDC 是 6,WBTC 是 8) 精度换算 相差 10¹² 倍的定价错误
可升级代币 今天的语义不是明天的语义 ⚠️ 审计结论有保质期
余额上限 / 转账上限 大额转账静默失败 清算无法完成
恶意代币 一切皆有可能 允许任意上架 = 允许任意代码执行

一条判据

⭐⭐ 只要你的协议允许别人指定代币地址,你就允许了任意代码在你的关键路径上执行。

这和"允许别人传入一个回调地址"在安全上是完全等价的——但前者看起来只是一个普通的 address token 参数。

应用到 fork 上

fork 任何代码之前,先列出【原作者做过但没写下来】的假设:

   · 它上架资产的流程是什么?谁审?
   · 它假设预言机的更新频率是多少?
   · 它假设治理响应速度是多快?
   · ⭐ 它有没有靠"我们只上架这几个币"来保证某些性质?

⚠️ 这些前提一条都不在代码里,但它们全都是安全性的一部分。

应用到"组合性"上

⭐ 你的协议的安全性 = 你的代码 ∩ 你依赖的每一个外部合约的行为

   而外部合约的集合,往往是【运行时才确定】的
   (用户上架、治理新增、别人来集成你)。

⟹ 所以"审计通过"这句话必须补上一个下标:
   在【审计当时的那组外部依赖】下通过。

七、本案小结

  • 代码 fork 自 Compound,一行没改错。 出问题的是团队自己上架了 ERC-777 格式的 imBTC。
  • ⭐⭐ ERC-777 在转账时回调转出方(tokensToSend),而钩子地址任何人都能给自己注册——于是一个看似平平无奇的 transferFrom 变成了控制权移交。
  • 和 The DAO 是同一个结构(先交互后记账),唯一的区别是外部调用藏在 transferFrom 里面,肉眼看不出来。
  • 重入是跨函数的:进入点 supply、出手点 withdraw。⚠️ 只给 withdraw 加锁完全无效。 -⭐ fork 一份安全的代码不等于得到一份安全的代码——你继承了代码,没继承它的前提清单,而那份清单从来没被写下来。
  • ⚠️ 前一天 Uniswap V1 的 imBTC 池刚被同一个洞打过,而 ERC-777 的重入风险 2019 年就有公开分析。问题不是没人知道,是知道的人和写代码的人不是同一批。
  • 漏洞不在任何单个合约里,它在"合约 × 上架代币"这个组合里——组合性风险不属于任何单次审计的范围。
  • 用实际余额差记账balanceOf 前后相减),既防中途做手脚,也自动兼容转账手续费型代币。 -⭐ 只要你的协议允许别人指定代币地址,你就允许了任意代码在你的关键路径上执行——这与"允许传入回调地址"完全等价。

思考题

  1. tokensToSendtokensReceived 的触发时机不同,为什么攻击用的是前者?用后者能不能实现同样的攻击?
  2. supply 按 CEI 重写。先记账再收款带来了什么新问题,怎么解决?
  3. 为什么只给 withdrawnonReentrant 挡不住这次攻击?请画出攻击的调用栈并指出锁的位置。
  4. “用实际余额差记账"这个写法,对 rebasing 代币(余额自己变)还成立吗?为什么?
  5. 一个协议允许治理投票新增抵押品。请设计一份新增抵押品的审查清单,并说明哪几条必须由人工判断、不能自动化。
  6. USDT 的 transfer 不返回 bool。写出两种错误的集成方式,说明各自会怎么坏。
  7. 假设你要 fork 一个借贷协议。请列出你会去问原作者的五个问题——都是那种答案不在代码里的问题。
  8. TUSD 曾经有两个合约地址指向同一份余额。如果一个协议用地址白名单做风控,这会怎么被绕过?
  9. “审计通过"要补一个什么下标才算准确?请给出一个具体场景,说明审计后系统的安全性如何被外部变化削弱。
  10. 对比 The DAO 和 Lendf.Me:两者的漏洞结构相同,但发现难度差很多。是什么造成了这个差别?这对你写代码时的注意力分配意味着什么?