| 时间 | 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前后相减),既防中途做手脚,也自动兼容转账手续费型代币。 -⭐ 只要你的协议允许别人指定代币地址,你就允许了任意代码在你的关键路径上执行——这与"允许传入回调地址"完全等价。
思考题
tokensToSend和tokensReceived的触发时机不同,为什么攻击用的是前者?用后者能不能实现同样的攻击?- 把
supply按 CEI 重写。先记账再收款带来了什么新问题,怎么解决? - 为什么只给
withdraw加nonReentrant挡不住这次攻击?请画出攻击的调用栈并指出锁的位置。 - “用实际余额差记账"这个写法,对 rebasing 代币(余额自己变)还成立吗?为什么?
- 一个协议允许治理投票新增抵押品。请设计一份新增抵押品的审查清单,并说明哪几条必须由人工判断、不能自动化。
- USDT 的
transfer不返回 bool。写出两种错误的集成方式,说明各自会怎么坏。 - 假设你要 fork 一个借贷协议。请列出你会去问原作者的五个问题——都是那种答案不在代码里的问题。
- TUSD 曾经有两个合约地址指向同一份余额。如果一个协议用地址白名单做风控,这会怎么被绕过?
- “审计通过"要补一个什么下标才算准确?请给出一个具体场景,说明审计后系统的安全性如何被外部变化削弱。
- 对比 The DAO 和 Lendf.Me:两者的漏洞结构相同,但发现难度差很多。是什么造成了这个差别?这对你写代码时的注意力分配意味着什么?