一、一个统一的视角

一行 Solidity 代码,读起来和你在任何一门语言里写过的函数调用一模一样。

它不是。 它把控制权整个交了出去,交给一段你没读过、而且可以在返回之前回头调用你的代码。

⚠️ 常见的漏洞清单(重入、溢出、访问控制……)是一份记忆列表,它教不会你发现的漏洞。

打个比方

这些漏洞的共同形状,是在国外开车

方向盘、油门、刹车都在你熟悉的位置,操作手感也一样——正因如此,你会自动地按老习惯做事。而路的规则变了。出事的不是那些你知道自己不会的动作,是那些你以为自己会、于是没有再想一遍的动作

这一讲用另一个框架:

每一类漏洞,都对应「开发者脑中的心智模型」与「EVM 实际语义」之间的一个缺口。

它们不是"粗心",是【抽象泄漏】——一个看起来熟悉的语法,背后是完全不同的执行语义。

按"哪种假设失效了"来分,恰好是五类。

二、① 控制流假设失效

泄漏点

recipient.call{value: amount}("");

⚠️ 它看起来像一次普通的函数调用,实际上是:

把控制权完整地交给一段【你无法预知的代码】, 并且它可以在返回之前,回头调用你的任何函数。

在传统编程里,调用一个函数后控制权会正常返回。在这里,被调用方是一个对手。

经典案例:The DAO

2016 年 6 月,The DAO 合约被利用,约 360 万 ETH 被抽走。

漏洞代码的逻辑顺序是:
   ① 检查余额
   ② ⭐ 转账(把控制权交给攻击者)
   ③ 把余额清零              ← 这一步永远轮不到

攻击者的 fallback 函数在第 ② 步里【再次调用提款】,
此时余额还没清零 ⟹ 检查通过 ⟹ 循环抽干。

后果远超资金损失:以太坊社区通过硬分叉回滚了这次攻击,导致链永久分裂为 ETH 和 ETC。这是"代码即法律"在现实中第一次、也是最著名的一次让位于社会共识。

防御

⭐ 检查-生效-交互(Checks-Effects-Interactions)
   ① 检查条件
   ② 【先】更新自己的状态
   ③ 【最后】才与外部交互

⟹ 即使被重入,状态已经更新,重复提款会在检查处失败。

加上重入锁(nonReentrant)作为第二道防线。

只读重入:一个更隐蔽的变体

⭐ 即使一个 view 函数不修改状态,它读到的也可能是【中间状态】。

场景:
   协议 A 在转账过程中,其状态已被部分更新(比如已扣除代币但未更新储备)
   ⟹ 此时攻击者回调协议 B,B 去读 A 的 getPrice()
   ⟹ B 读到的是一个【不一致的、暂时错误的】价格

教训:view 修饰符保证"我不改状态",但它完全不保证"我读到的状态是一致的"。

三、② 权限与身份假设失效

// ❌ 灾难性错误
require(tx.origin == owner);
⭐ tx.origin  = 发起整条调用链的那个 EOA
   msg.sender = 【直接】调用者

攻击:
   ① 诱导 owner 调用攻击者的合约(比如一个看起来无害的领奖页面)
   ② 攻击合约转手调用受害合约
   ⟹ tx.origin 仍然是 owner ⟹ 检查通过

规则:权限检查永远用 msg.sendertx.origin 几乎没有正当用途。

这一类的其他成员,前面几讲都遇到过:

⚠️ 未初始化的实现合约(第 24 讲的 Parity)
ecrecover 失败时返回零地址而非报错(第 7 讲)
签名缺少 nonce / chainID / 域分隔符 ⟹ 可重放(第 7、11 讲)
delegatecall 下 msg.sender 的含义改变(第 24 讲)

共同点:开发者以为自己在检查"某个身份",实际检查的是别的东西。

四、③ 数值假设失效

① 溢出(Solidity 0.8.0 前不自动检查)
   ⚠️ 而 unchecked 块和内联汇编里,今天仍然不检查。

② 精度截断
   EVM 只有整数除法。
   ❌ (a / b) * c        —— 先除后乘,精度丢失
   ✅ (a * c) / b        —— 先乘后除
   但要注意乘法本身可能溢出

③ 舍入方向 —— 最容易被忽略的一条

第三条值得展开,因为它是一个通用原则:

整数除法总要舍入。⚠️ 舍向哪边不是无关紧要的细节。

规则:永远向【对协议有利】的方向舍入。

   用户存款换份额  ⟹ 向下取整(少给用户)
   用户赎回付份额  ⟹ 向上取整(多收用户)

若方向搞反,攻击者可以反复执行"存入-赎回",
   每次白赚一个最小单位——积少成多,且完全合法。

五、④ 外部数据假设失效——预言机

这直接对应第 1 讲的边界:链能保证数据一致,不能保证数据为真。

// ❌ 用现货价格做预言机
uint price = pool.reserve1() * 1e18 / pool.reserve0();

AMM 的现货价格是【可以被一笔交易改变】的。

闪电贷:它不是漏洞

闪电贷:⭐ 在【同一笔交易内】借出任意金额,只要在交易结束前还清。
        由于 EVM 的原子性(第 21 讲),不还就整笔回滚 ⟹ 出借方零风险。

⭐⭐ 关键认识:闪电贷本身不是漏洞。它做的事是——

移除了「攻击者需要有很多钱」这个【隐含的、从未被明说的】防御。

⭐ 在闪电贷出现之前,很多协议的安全性实际上依赖于
   "操纵这个池子需要几千万美元,没人会为了偷一百万这么干"。

闪电贷之后:任何"只要有足够资金就能做"的攻击,
   现在【任何人】都能免费做一次。

这是全课最值得记住的一个模式:

一个从未被明确写下的假设,往往是最危险的假设——因为没有人会去审查它。

防御

① ⭐ TWAP(时间加权平均价)
   操纵均价需要在【多个区块】里维持错误价格
   ⟹ 而这会持续暴露在套利者面前,成本极高

② 多源预言机 + 中位数
③ 检查价格偏离度,异常时暂停
④ 但要注意:预言机本身就是一个信任点(第 1 讲)

六、⑤ 经济与博弈假设失效

这一类最难防,因为代码完全正确——错的是机制设计。

例一:ERC-4626 第一存款人攻击

金库的份额计算:份额 = 存入金额 × 总份额 / 总资产

攻击:
   ① 攻击者第一个存入 1 wei ⟹ 获得 1 份额
   ② ⭐ 【直接转账】1000 ETH 给金库合约(不通过存款函数)
      ⟹ 总资产 = 1000 ETH + 1 wei,总份额 = 1
   ③ 受害者存入 100 ETH:
      份额 = 100e18 × 1 / 1000e18 = 0.1 ⟹ 整数除法向下取整 = 【0 份额】
   ④ 攻击者赎回他那 1 份额,取走全部资产

三个假设同时失效:舍入方向(第四节)、“资产只能通过存款函数进入”、“总份额不会小到失去精度”。

防御:虚拟份额偏移,或在部署时由协议方存入一笔无法赎回的初始份额。

例二:治理攻击

① 闪电贷借入大量治理代币
② 在同一笔交易里投票通过一个"把国库转给我"的提案
③ 还款

⭐ 防御:投票权按【快照】计算,且提案执行必须有时间锁(第 24 讲)。

例三:清算激励不足

⚠️ 极端行情下,清算奖励不足以覆盖清算者的 Gas 和滑点成本
⟹ 没有人愿意清算
⟹ 头寸继续恶化 ⟹ 协议产生坏账 ⟹ 储户承担损失

注意这里没有任何"漏洞"。代码按设计运行,是设计本身对极端情况的假设错了。

七、一个可以直接用的检查方法

⭐ 这个方法只做一件事,就是"在国外开车"的解药:对每一处你觉得熟悉、因此没有再想一遍的地方,强制问一遍"这里的规则和我习惯的一样吗"。 前面五类,就是五个最常出事的路口。

对代码里的每一个外部调用,问三个问题:

① ⭐ 控制权交出去了吗?
   交出去之后,我的哪些状态还没更新?
   对方能在这个窗口里调用我的哪些函数?(包括 view 函数)

② 我读到的数据,对方能影响吗?
   价格、余额、总量——哪些是攻击者能在同一笔交易里改变的?

③ 如果攻击者有【无限资金】,这里还安全吗?
   ⟹ 这一问直接检验第五节那个"隐含的资金门槛"假设。

再加一个贯穿全课的问题:

④ ⭐ 这个设计依赖谁不作恶?
   升级密钥持有者(第 24 讲)、预言机、桥的验证者(第 30 讲)、
   排序器(第 27 讲)、DA 委员会(第 29 讲)……

八、形式化验证能保证什么

⭐ 形式化验证能证明:【实现】符合【规范】。
它不能证明:【规范】本身是对的。

⭐⭐ 这与第 28 讲的电路 bug 完全同构:

ZK 证明保证"电路被正确执行",⚠️ 不保证"电路正确实现了 EVM"
形式化验证保证"代码符合规范",不保证"规范符合意图"

⟹ 两者都把问题【向上推了一层】,而没有消除它。

⚠️ 第六节那三个例子——第一存款人、治理攻击、清算激励不足——全部会通过形式化验证,因为代码确实做了规范说它该做的事。

九、全课收束

三十三讲走到这里,回头看会发现只有三条主线。

① 识别信任假设

每一个机制,问一句"如果这个角色作恶会怎样":

   矿工作恶 ⟹ 能重排,不能偷币(第 16 讲)
   验证者作恶 ⟹ 被罚没(第 18 讲)
   排序器作恶 ⟹ 有强制包含通道(第 27 讲)
   ⚠️ 桥的多签作恶 ⟹ 全部资产损失(第 30 讲)
   DA 委员会扣数据 ⟹ 钱正确无误但取不出来(第 29 讲)
   升级密钥持有者作恶 ⟹ 审计过的代码明天就换掉(第 24 讲)

跨链桥几十亿美元的损失,全部来自没有人认真问过这句话。

② 理解权衡,而不是记结论

比特币 10 分钟出块 ⟸ 孤块率 ≈ 传播时间 / 出块间隔(第 16 讲)
n ≥ 3f+1          ⟸ f 被减两次:一次为活性,一次为安全性(第 19 讲)
Gas 必须存在      ⟸ 停机问题(第 22 讲)
EVM 难以 ZK 证明   ⟸ 256 位运算和 keccak 不是域运算(第 21、28 讲)

每一个"是什么"背后都有一个"为什么",而后者不会过期。

③ 知道边界在哪

⚠️ 链保证不了数据为真(预言机问题,第 1 讲)
链保证不了链外资产的法律效力
不可篡改是【经济成本】保证的,不是密码学(第 9、17 讲)
最终性是一个【足够贵的价签】,不是物理定律(第 20 讲)
ZK 保证电路被正确执行,不保证电路写对了(第 28 讲)
形式化验证保证实现符合规范,不保证规范正确(本讲)

⭐⭐ 最后这一组,是这门课最想留下的东西:

每一层"数学保证",都在某个地方交接给了人的判断。 ⭐ 工程能力的差别,不在于知道哪些东西被保证了, 而在于知道【保证在哪里停止】。

十、本讲小结

  • 漏洞不是粗心,是抽象泄漏:一个看起来熟悉的语法,背后是完全不同的执行语义。按"哪种假设失效"分成五类。
  • 控制流call 看起来是普通调用,实际是把控制权交给一个对手,且它能在返回前回头调用你。防御是"检查-生效-交互"。只读重入更隐蔽——view 保证"我不改状态",不保证"我读到的状态一致"。
  • The DAO 的后果超出资金:硬分叉导致 ETH/ETC 永久分裂,是"代码即法律"第一次让位于社会共识。
  • 权限tx.origin 是整条调用链的发起者,钓鱼可绕过。权限检查永远用 msg.sender 这一类还包括未初始化实现合约、ecrecover 返回零地址、签名缺 chainID。
  • ⭐⭐ 舍入方向是通用原则:永远向对协议有利的方向舍入。方向搞反,攻击者可反复"存入-赎回"每次白赚一个最小单位。
  • ⭐⭐ 闪电贷不是漏洞——它移除了"攻击者需要有很多钱"这个从未被明说的防御。 一个从未被写下的假设,往往是最危险的,因为没人会去审查它。
  • 经济类漏洞最难防,因为代码完全正确,错的是机制设计:ERC-4626 第一存款人攻击(三个假设同时失效)、闪电贷治理攻击、清算激励不足导致坏账。
  • 四个检查问题:控制权交出去时我的哪些状态还没更新?我读的数据对方能改吗?攻击者有无限资金时还安全吗?这个设计依赖谁不作恶?
  • 形式化验证证明"实现符合规范",不证明"规范正确"——与第 28 讲的电路 bug 完全同构。两者都把问题向上推了一层,而没有消除它。
  • 全课的三条主线:识别信任假设、理解权衡而非记结论、知道边界在哪。每一层"数学保证"都在某处交接给了人的判断——工程能力的差别,在于知道保证在哪里停止。

思考题

  1. 用"抽象泄漏"的框架解释重入:开发者脑中的模型是什么,EVM 的实际语义是什么?
  2. 写出一个有重入漏洞的提款函数,然后用 CEI 重写它。为什么重入锁只能算第二道防线?
  3. 构造一个只读重入的具体场景:协议 A 处于什么中间状态,协议 B 会读到什么错误的值?
  4. tx.origin 钓鱼攻击的完整步骤是什么?为什么 msg.sender 不受影响?
  5. 一个金库把"存款换份额"设成向上取整。构造一个套利循环,算出攻击者每次能白赚多少。
  6. 为什么说"闪电贷不是漏洞"?举一个"闪电贷之前安全、之后不安全"的具体协议设计。
  7. 完整推演 ERC-4626 第一存款人攻击,指出其中哪三个假设各自在哪一步失效。
  8. 一个 DAO 用"投票时的余额"计票。写出闪电贷治理攻击的步骤,以及快照机制如何阻止它。
  9. 清算激励不足导致坏账——这算不算"漏洞"?如果不算,该由谁负责?怎么在设计阶段发现它?
  10. 用第七节的四个问题,检查一个你熟悉的协议(或本课任何一讲提到的机制),写出你的分析。

正文到此结束。 接下来是四个实验、四套习题术语表参考资料