Crypto OS
Technical Crypto OS第五阶段 · Infrastructure & Security

T30 · Audit

审计到底在审什么?

练习的能力
Builder
动手
为你的协议写出五条不变量,用 Fuzzing 让它们被自动检验。
AI Lab
让 AI 从代码反推不变量,自己判断哪些是真正的安全性质,哪些只是实现细节。

一个现实问题

一个团队准备上线,花了一笔不小的钱买审计。

三周后报告回来:17 个发现,2 个高危、5 个中危、10 个低危和信息级。团队认真修完了全部 17 条,审计方复核通过,报告最后一页写着所有问题已解决。他们把报告 PDF 放上官网,宣布「已通过审计」,上线。

三个月后,协议被搬空。

事后复盘时最刺眼的一点不是损失金额,而是:攻击路径在那份报告里一个字都没提。 不是审计方看漏了某一行代码——攻击根本没有用到任何代码漏洞。攻击者做的事是先在外部市场把一个抵押品的价格推上去,再用它借走远超其真实价值的资产。每一步调用都完全合法。

团队去问审计方,得到的回答也挑不出毛病:那份报告的范围写得清清楚楚,是「合约代码实现的正确性」,不包含「协议在极端市场条件下的经济假设」。范围就写在报告第 2 页,他们当时没细看。

于是问题变成了:那份钱到底买到了什么? 以及,如果 17 个发现全修了还是会被搬空,审计的价值到底在哪一部分?

思想实验

假设你是投资人,要在两个协议之间选一个投。两边都给你看了审计报告。

A 协议的报告:58 页,列了 23 条发现。分级齐全,每条都有代码位置、复现步骤、修复建议和「已解决」标记。读起来非常专业。

B 协议的报告:19 页,只列了 6 条发现,其中没有高危。但它前面有两样 A 的报告里没有的东西:

第一样,一份威胁模型。它写清楚了:这个协议假设谁可能来攻击(普通用户、大户、能借到无限资金的套利者、持有治理代币的人、依赖的外部协议本身)、这些人各自能做到什么、以及协议明确不防御哪些情况(比如「我们假设价格源在单个区块内不会被操纵超过 5%,如果超过,协议会产生坏账」)。

第二样,一份不变量清单。11 条,每一条都是一句「无论发生什么,这件事必须永远成立」的断言,而且每一条都配了一个可以自动跑的测试。比如「所有用户存款之和,永远不大于合约实际持有的资产」。

现在请你判断:哪一份报告让你更敢投?

大多数人的第一反应是 A——发现更多,看起来查得更细。但请再想一层:

  • A 的 23 条发现告诉你的是「这些地方曾经写错过,现在改了」。它描述的是过去。
  • B 的威胁模型告诉你的是「这个协议认为什么是危险的,以及它承认自己不防什么」。它描述的是边界。
  • B 的 11 条不变量告诉你的是「以后每改一行代码,这 11 件事都会被自动重新验证一遍」。它描述的是未来。

A 的报告在交付那一刻就开始过期——团队上线后改的每一行代码,都不在它的覆盖范围里。B 的那 11 条不变量会跟着代码库一直活下去。

你来决定

你的协议再过两个月上线。安全预算只够再做一件事。你选哪个?

观察结果

四个选项不是四选一,但它们确实在回答不同的问题。把它们按「覆盖哪一段时间」摆开,结构立刻就清楚了:

手段覆盖的时间窗能发现什么交付后还会不会失效
审计上线前的一个快照人能想到、且在范围内的问题你改的下一行代码就不在覆盖里
不变量 + Fuzzing从现在到永远违反你所声明性质的状态不失效,跟着代码库一起活
监控上线后每一刻已经发生的异常不失效,但需要有人真的去看
漏洞赏金上线后持续别人想到而你没想到的取决于赏金和攻击收益的比值

于是可以得到这一章最重要的一句话:

审计是一次快照,而你的协议要活很多年。

一份审计报告在交付那一刻价值最高,之后每过一天就衰减一点——因为代码在变、依赖在变、市场条件在变,而报告不会跟着变。

那么审计里真正不衰减的部分是什么

回头看 B 协议那份 19 页的报告。让它保值的不是那 6 条发现,而是威胁模型和不变量清单:前者定义了「什么算危险」,后者把这个定义变成了会自动运行的代码。它们是审计过程的副产品,却比发现列表活得久得多。

这就是为什么这一章的标题是「审计到底在审什么」,而答案是:

审计真正的产出是威胁模型与不变量,不是一份 PDF。

一份只给你发现列表的审计,你买到的是一次性的 bug 修复。一份帮你建立威胁模型和不变量的审计,你买到的是一套能持续运行的安全能力。价格可能一样。

建立模型

这一章给三样可以直接拿走用的东西:威胁模型的四问、不变量的三类写法、四层工具各自的能力边界。

一、威胁模型:四个必须回答的问题

威胁模型不是一篇散文,是四个问题的答案。写不出来就说明你还不知道自己在防什么。

问题要写出什么写不出来意味着
谁会来攻击?列出角色:普通用户、大户、能借到无限资金的套利者、治理代币持有人、你依赖的外部协议、你自己的多签持有人你会漏掉整类攻击者,尤其是最后两类
他们想拿走什么?用户存款、协议金库、LP 的流动性、治理权、还是仅仅让协议停摆你会只防资金,不防可用性
他们各自能做到什么?每个角色的能力边界:能调哪些函数、能出多少钱、能不能控制交易顺序、能不能等很多个区块你会低估「能借到无限资金」这一条,详见 T29
你明确不防什么?把放弃的假设写下来,而不是假装它不存在这是最重要的一问,下面单说

第四问是区分专业和业余的地方。

每个协议都有它不防的东西。不防私钥泄露、不防依赖的预言机整体失效、不防超过某个幅度的单区块价格操纵、不防监管冻结底层资产。这些放弃是合理的——防住一切等于什么都做不成。

不合理的是不把它们写下来。没写下来会产生三个后果:团队内部对边界的理解不一致;集成方以为你防了而其实没防;出事时无法判断这是「设计如此」还是「出了漏洞」。

写法很简单,一句话一条:

我们假设:价格源在单个区块内的偏离不超过 X%。
如果超过:协议会产生坏债,由保险基金承担,超出部分由 LP 按比例分摊。
我们不防:整个价格源被长时间操纵的情形。缓解手段是心跳检查与偏离熔断。

对照 T29 你会发现:威胁模型的第三问,就是 T29 那张攻击成本表的输入。 你列出的每个角色能力,都要能对应到那张表里「资金」和「时间」两项的取值。

二、不变量:三类写法

不变量是一句「无论发生什么,这件事必须永远成立」的断言。好的不变量有三个特征:用状态表达而不是用流程表达、能用一行代码检查、违反了一定意味着出事。

实践中绝大多数有用的不变量落在三类里。

第一类:会计不变量。 关于钱的守恒关系,最容易写也最值钱。

所有用户余额之和  <=  合约实际持有的资产
总供应量  ==  所有持有人余额之和
借出总额 + 池中余额  ==  存入总额 + 累计利息
每股价值  单调不减(除非发生了清算或坏账)

注意第一条用的是小于等于而不是等号——留出的差额是手续费和舍入残留。取整方向必须永远对协议有利,这条在 T28 讲过,这里把它变成了可自动检验的断言。

第二类:权限不变量。 关于谁能做什么。

只有管理员能改参数
参数永远在声明的区间内
合约暂停时,任何会改变余额的函数都无法成功
没有任何路径能让非清算人把别人的抵押品转走

第三类:状态机不变量。 关于状态之间的合法转移。

一个仓位要么健康,要么可被清算,不存在第三种状态
已关闭的仓位不能再被操作
每个订单只能被成交一次
健康度低于阈值的仓位,不可能还能继续借出

写不变量时最常见的错误,是把实现细节当成不变量。「内部数组长度等于用户数」不是安全性质,它只是你当前实现的一个事实——换一种实现它就不成立了,但协议一点也不会更危险。区分方法只有一条:

问自己:如果这条被违反了,有人会亏钱或者失去控制权吗?如果不会,它就不是安全不变量。

三、四层工具:各自能发现什么

把工具按「发现能力」和「需要你做多少事」排开,选择就不再靠感觉:

它能发现它发现不了你要付出什么
编译器与类型系统语法、类型、明显的未初始化任何逻辑问题几乎为零
静态分析已知模式:重入结构、未检查返回值、危险的低层调用你这个协议特有的逻辑错误装一个工具,然后花时间筛误报
Fuzzing违反你声明的不变量的状态组合你没写出来的性质写不变量,这是全部成本
形式化验证在给定模型下,某性质必然成立或不成立模型本身写错的地方很高,通常只用在最核心的几个函数

四层里,Fuzzing 的性价比最突出,原因是它的成本几乎全在「写不变量」这一步,而这一步的产出物本身就是资产——它同时是文档、是回归测试、是给集成方看的安全声明。

静态分析的正确用法是当成过滤器而不是判决书:它的误报率不低,你的工作是快速筛掉误报,而不是逐条辩论。

形式化验证不要一上来就用。它适合的场景很窄:一个函数很关键、逻辑不长、性质能被精确表达。比如「份额与资产的换算函数在任何输入下都不会让先存款的人吃亏」。

四、发现要分三类,不是分级

审计报告习惯按严重程度分级。但你在处理 AI 或工具给出的一堆发现时,更有用的是另一种分类:

  1. 真问题
  2. 误报
  3. 漏报
前两类看得见,第三类才是最危险的——它不在任何列表里
  • 真问题:确实存在,要修。
  • 误报:工具或模型认为有问题,实际没有。要能快速判断,不要花时间辩论。
  • 漏报:真实存在但没有被任何人发现。你无法直接统计它,只能通过「威胁模型里有没有一整类攻击者被忽略」来间接推断。

开头那个团队的问题,就是一次教科书式的漏报:17 条全是真问题,误报为零,看起来完美——但整个「能借到无限资金的套利者」这一类攻击者不在范围内。

它叫什么

威胁模型Threat Model

一份说明「谁可能攻击、想拿走什么、能做到什么、以及你明确不防什么」的文档。

它是审计的输入而不是输出。没有威胁模型的审计,等于让人在不知道你要防谁的情况下检查你的门锁。

不变量Invariant

一句「无论发生什么,这件事必须永远成立」的断言,用状态表达,可被自动检验。

判断它是不是安全不变量只有一条标准:违反了,有人会亏钱或失去控制权吗。

静态分析Static Analysis

不运行代码,按已知模式扫描源码或字节码。擅长发现重入结构、未检查的返回值、危险的低层调用这类有固定形状的问题。

用它当过滤器,不要当判决书——误报率不低。

Fuzzing模糊测试

自动生成大量随机输入与调用序列,试图找到违反不变量的状态。

有状态 Fuzzing(连续调用多个函数、保留状态)比无状态的强得多,因为真实漏洞几乎都出在调用序列上,而不是单次调用上。

形式化验证Formal Verification

用数学方法证明某个性质在给定模型下必然成立。

它的结论强度远高于测试,但它只在你写下的模型里成立——模型写错了,证明也是错的。适合范围很窄、逻辑很关键的函数。

监控与告警Monitoring

上线后持续观察链上状态,在异常出现时通知人。

它的价值不在于阻止攻击,而在于压缩「发生」到「发现」之间的时间。对有暂停开关的协议,这段时间几乎直接等于损失金额。

漏洞赏金Bug Bounty

公开承诺:找到并负责任地披露漏洞,可以获得报酬。

有效的前提是赏金上限与攻击收益可比,以及你有能力快速修复。

动手

动手为你的协议写五条不变量,并用 Fuzzing 自动检验它们你自己的合约 + 任意支持有状态 Fuzzing 的测试框架0 元。全程在本地环境,针对你自己写的合约,不要对任何真实部署的协议做任何操作

用 T22 或 T19 里你自己写过的那个协议。如果都没有,用 T7 的存取款合约也可以。

先写威胁模型的四问。 不要跳过这一步直接写不变量——不变量是从威胁模型里长出来的。

四个问题各写三到五行:谁会来攻击、想拿走什么、各自能做到什么、你明确不防什么。第四问至少写两条。

从三类里各挑一条,凑够五条不变量。 建议配比:两条会计类、两条权限类、一条状态机类。

每条写成一句断言,并在旁边注明「违反了会怎样」。写不出后果的那条,划掉重写——它多半是实现细节。

把五条翻译成断言函数。 每条一个函数,内部只做检查,不改状态。

会计类的那两条要特别注意比较方向:用小于等于还是等于,取决于是否存在手续费和舍入残留。方向写反了,Fuzzing 会立刻炸给你看——这是好事。

接上有状态 Fuzzing。 关键是让它连续调用多个函数并保留状态,而不是每次都从初始状态开始。真实漏洞几乎都藏在调用序列里。

先跑一个短回合,确认它真的在调你的函数(打印一下调用计数),再跑长回合。

故意破坏一条,确认它能被抓到。 这一步不能省。

把某个函数里的取整方向改反,或者把一处权限检查注释掉,重跑 Fuzzing。如果它还是全绿,说明你的不变量没有真正生效——可能是断言写在了不会被调用的地方,也可能是 Fuzzing 根本没碰到那条路径。

改回来之前,把失败时它给出的那条调用序列存下来,那是一个现成的回归测试。

记录三个数字:Fuzzing 跑了多少回合、覆盖了哪些函数、最短的失败序列有几步。

最后一个数字最有信息量:失败序列越短,说明这个漏洞越容易被真实攻击者碰到。

做完这一步,你手上就有了一份能跟着代码库一起活下去的安全资产。它也是毕业项目第五阶段要交的东西。

AI Lab

AI Lab让 AI 从代码反推不变量,你来区分安全性质与实现细节Level 2 · AI Copilot

把你的合约代码交给模型,任务分两步。

第一步,让它反推:

这是我的合约代码。请反推出它隐含的不变量——也就是那些
「无论发生什么都必须成立」的性质。

每条输出四个字段:
- 断言(用状态表达,不要用流程表达)
- 它依赖代码里的哪一处(给出函数名与行号)
- 如果被违反,会发生什么
- 你的判断:这是安全性质,还是仅仅是当前实现的一个事实

不确定的标注「不确定」,不要猜。

第二步,让它挑自己的毛病:

现在回到你刚才给出的清单,指出其中哪几条其实是实现细节而不是安全性质,
并说明理由。

第二步往往比第一步有价值。 模型在反推时倾向于把「当前代码恰好如此」写成不变量,比如「数组长度等于用户数」「某个映射的键总是非零」。这类断言全部成立,但一条都不保护资金。

你的工作是拿着右边的清单逐条过。过完以后统计一个比例:它给出的条目里,有多少是真正的安全性质。 这个比例值得你记下来——它会告诉你,在这类任务上模型能帮你到哪一步。

最后一条验证项最重要:把它的清单和你自己写的威胁模型对照。如果你的威胁模型里有「能借到无限资金的套利者」,而它给出的不变量里没有任何一条涉及单笔交易内的极端状态,那就是一个漏报。模型看得见代码,看不见你的威胁模型。

AI 说完之后,你必须自己验证

  • 每一条被判为「安全不变量」的,你都能说出「违反了谁会亏钱或失去控制权」
  • 被判为「实现细节」的,确认换一种实现它就不再成立,且协议并不会更危险
  • 它引用的函数名、变量名在你的代码里真实存在,没有编造
  • 会计类不变量的比较方向(等于还是小于等于)与手续费、舍入残留的实际情况一致
  • 它有没有把「当前代码恰好如此」当成「必须永远如此」——这是最常见的错误
  • 对照你自己的威胁模型:有没有一整类攻击者,它给出的不变量完全没有覆盖

真实案例

审计过,仍然被搬空反复发生的一类事故

一个协议完成审计并修复全部发现后上线,随后因价格源被操纵而损失大量资金。攻击没有用到任何代码漏洞——每一步调用都合法。

事后争议集中在一点:审计范围里是否包含经济假设。多数情况下,答案写在报告前几页,而多数团队没有细读。

教训不是「审计没用」,而是:审计的范围就是它的能力边界,而范围是你和审计方一起定的。 你如果没提供威胁模型,范围就默认只剩代码实现。

不变量抓到了单测抓不到的东西有状态 Fuzzing 的典型收益

一个金库类协议的全部单元测试通过,覆盖率很高。接上有状态 Fuzzing 后,很快找到一条七步调用序列,使「每股价值单调不减」这条不变量被违反。

原因是几个函数单独看都没问题,但特定顺序下的舍入累积让先进入的人吃了亏。

这类漏洞单元测试几乎发现不了——因为写单测的人和写代码的人是同一个人,他想不到的顺序,也不会写进测试。

监控告警响了,但没有人看事故复盘中的高频项

一个团队部署了链上监控,异常发生时告警确实触发了。但告警发进了一个平时噪音很大的频道,凌晨没有人值班。

发现时已经过去几个小时,而协议的暂停开关一直可用。

教训:监控的有效性由「谁在什么时间会看到它」决定,不由「有没有部署」决定。 告警要分级,高危告警必须有明确的值班人和响应时限。

赏金上限远低于攻击收益漏洞赏金的定价问题

一些协议设置了固定的赏金上限,而协议可被触及的资金远高于这个数。

这等于给发现者出了一道简单的算术题。行业里逐渐转向按「可挽回损失的百分比」设定上限,就是为了让这道题的答案变成举报。

这和 T29 的攻击成本表是同一张表:你在调整的是「举报收益」这一项,让它压过「攻击收益」。

改一个变量

如果你在审计开始之前,先把威胁模型交给审计方

审计的范围会被你主动定义,而不是默认落在「代码实现正确性」上。

审计方会针对你声明的攻击者能力去查,也会对你「明确不防」的那几条提出质疑——那几条质疑往往是整份报告里最值钱的部分,因为它们打的是你的假设,而不是你的代码。

成本几乎为零,只是一份你本来就该写的文档。

如果你把不变量写进代码,让它在每次交易结束时真的执行

从「测试时检查」变成「运行时强制」。违反时交易直接回滚,攻击在链上就被挡住了。

代价是 Gas:每笔交易都要多做一遍全局检查。所以现实做法通常是分层——最关键的一两条会计不变量放进运行时,其余留在 Fuzzing 里。

这里的取舍和 T8 的存储成本是同一个性质的问题:安全性通常要用 Gas 去买。

如果你依赖的一个外部协议升级了它的合约

你的代码一行没改,但你的安全假设可能已经变了——它的接口语义、它的手续费行为、它的重入保护,都可能不一样了。

这正是「审计是一次快照」最直接的体现:报告是针对当时那个依赖版本的。

应对方式是把依赖的关键假设也写成不变量,并在监控里加一条「依赖合约的实现地址发生变化」的告警。

如果你的协议管理员密钥由单人保管

前面所有的工作会被这一件事绕过去。不变量、Fuzzing、审计报告都防不住一个拥有全部权限的账户。

这是威胁模型第一问里最容易被跳过的角色:你自己。 多签、时间锁、权限最小化属于这一层,而且它们通常比再买一次审计便宜得多。

带走的问题

9
谁承担风险?

谁承担风险?审计报告通常会写明它不对损失负责。风险始终在协议方和用户身上,审计只是降低了概率。把「已通过审计」当成安全保证,是这个行业里代价最高的误读之一。

2
为什么需要 Blockchain?

为什么需要 Blockchain?因为代码公开、状态公开,任何人都能独立验证你的不变量是否成立。这既是压力也是机会:你的不变量可以做成公开的、任何人都能跑的检查,这比一份 PDF 可信得多。

12
如果补贴停止,还有用户吗?

如果补贴停止还有用户吗?这一问在安全语境下换个说法:如果你的团队解散了,这个协议还安全吗? 靠人工盯盘维持安全的系统,答案是否定的;靠不变量和权限设计维持安全的系统,答案可能是肯定的。

本章自测

一句话带走

审计真正的产出是威胁模型与不变量,不是一份 PDF。

做完这一章的动手环节了?勾上它查看全部进度

本页目录