一、一个统一的视角
一行 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.sender。tx.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 完全同构。两者都把问题向上推了一层,而没有消除它。
- 全课的三条主线:识别信任假设、理解权衡而非记结论、知道边界在哪。每一层"数学保证"都在某处交接给了人的判断——工程能力的差别,在于知道保证在哪里停止。
思考题
- 用"抽象泄漏"的框架解释重入:开发者脑中的模型是什么,EVM 的实际语义是什么?
- 写出一个有重入漏洞的提款函数,然后用 CEI 重写它。为什么重入锁只能算第二道防线?
- 构造一个只读重入的具体场景:协议 A 处于什么中间状态,协议 B 会读到什么错误的值?
tx.origin钓鱼攻击的完整步骤是什么?为什么msg.sender不受影响?- 一个金库把"存款换份额"设成向上取整。构造一个套利循环,算出攻击者每次能白赚多少。
- 为什么说"闪电贷不是漏洞"?举一个"闪电贷之前安全、之后不安全"的具体协议设计。
- 完整推演 ERC-4626 第一存款人攻击,指出其中哪三个假设各自在哪一步失效。
- 一个 DAO 用"投票时的余额"计票。写出闪电贷治理攻击的步骤,以及快照机制如何阻止它。
- 清算激励不足导致坏账——这算不算"漏洞"?如果不算,该由谁负责?怎么在设计阶段发现它?
- 用第七节的四个问题,检查一个你熟悉的协议(或本课任何一讲提到的机制),写出你的分析。