3、模型检测入门:状态空间、迁移系统、时态逻辑(LTL/CTL)、模型检测算法概述
模型检测,说白了就是「穷举验证」。你写了一个智能合约,想知道它有没有 bug?模型检测会遍历所有可能的状态,告诉你「有」或「没有」。我最早接触这玩意儿是在做跨链桥安全审计的时候,那会儿合约状态多到爆炸,手动测试根本测不完。后来发现,模型检测才是治本的办法。
3.1 状态空间:你的合约到底有多少种可能?
状态空间,就是系统所有可能状态的集合。拿一个简单的计数器合约举例:
contract Counter {
uint public count;
function inc() public { count += 1; }
function dec() public { count -= 1; }
}
如果 count 是 256 位的整数,那状态空间就是 2^256 种可能。嗯,这数字大到离谱。实际做模型检测时,我们不会真的枚举所有 2^256 个状态,而是做抽象——比如把 count 限制在 0 到 3 之间,或者用符号化表示。
关键点:状态空间的大小决定了模型检测能不能跑完。我见过一个项目,状态空间 10^20 量级,用 BDD(二叉决策图)硬是压到了可处理的范围。抽象和符号化是两大法宝。
状态空间里每个状态,都是一个快照。比如:
(count=0, owner=0x123...)(count=1, owner=0x123...)(count=0, owner=0x456...)
每个变量取值的组合,就是一个状态。状态空间就是这些组合的全体。
3.2 迁移系统:状态之间怎么跳?
有了状态,还得知道状态之间怎么变。迁移系统就是干这个的。它由三部分组成:
- 状态集合 S:所有可能的状态
- 初始状态集合 I:合约部署时的状态
- 迁移关系 R:从状态 s 到状态 t 的跳转规则
举个例子,上面的计数器合约,迁移关系可以写成:
inc: (count == n) → (count == n+1)
dec: (count == n) → (count == n-1)
每个函数调用都是一条迁移边。你想想看,如果合约里有 require 语句,那迁移关系就带条件了:
withdraw(amount):
require(balance >= amount);
balance -= amount;
只有满足 balance >= amount 的状态,才能触发这条迁移。
个人经验:我在审计一个 DeFi 协议时,发现它的迁移关系里有个「幽灵状态」——某个条件永远无法满足,导致一段代码永远执行不到。模型检测一跑,直接暴露了这条死代码。手动 review 根本看不出来。
3.3 时态逻辑:怎么描述「永远不出事」?
状态空间和迁移系统描述的是「系统长什么样」。但我们要验证的是「系统行为对不对」。时态逻辑就是用来描述行为性质的。
3.3.1 LTL(线性时态逻辑)
LTL 假设时间是一条线,每个时刻只有一个后继。常用的操作符:
- G p:全局成立(Globally),p 在所有未来状态都成立
- F p:最终成立(Finally),p 在某个未来状态成立
- X p:下一个状态成立(neXt)
- p U q:p 一直成立直到 q 成立(Until)
举个例子,验证「合约永远不会被重入攻击」:
G (call_external → (lock == true))
意思是:任何时候,只要发起外部调用,锁必须是 true。这就是一个典型的 LTL 性质。
避坑指南:我曾经写过一个 LTL 公式 G (F paid),意思是「最终一定会付款」。结果模型检测说这个性质不成立。我查了半天,发现合约里有个分支路径可以无限循环,永远不执行付款逻辑。LTL 能捕捉这种「无限拖延」的问题,手动测试很难发现。
3.3.2 CTL(计算树逻辑)
CTL 和 LTL 最大的区别:CTL 考虑的是「分支时间」,每个时刻可能有多个后继。操作符前面必须加路径量词:
- A:所有路径(All)
- E:存在某条路径(Exists)
比如:
- AG p:所有路径上,p 全局成立
- EF p:存在某条路径,最终 p 成立
- AF p:所有路径上,最终 p 成立
- EG p:存在某条路径,p 全局成立
举个例子,验证「存在一条路径可以让合约自毁」:
EF (selfdestruct_called == true)
如果模型检测返回 true,说明确实有办法触发自毁。如果返回 false,说明自毁函数永远无法被调用——可能是权限控制太严了。
| 特性 | LTL | CTL |
|---|---|---|
| 时间模型 | 线性(单路径) | 分支(多路径) |
| 路径量词 | 隐含(所有路径) | 显式(A/E) |
| 表达能力 | 擅长描述「沿路径」的性质 | 擅长描述「分支选择」的性质 |
| 典型应用 | 公平性、活性 | 可达性、安全性 |
我的建议:做智能合约安全验证时,我一般先用 CTL 查「可达性」——比如某个危险函数能不能被调用。再用 LTL 查「活性」——比如某个承诺最终会不会兑现。两者互补,缺一不可。
3.4 模型检测算法:机器是怎么遍历的?
算法层面,模型检测的核心就是「状态空间搜索」。但直接暴力搜索肯定不行,得用点技巧。
3.4.1 显式状态模型检测
最朴素的方法:把状态一个一个列出来,然后 BFS 或 DFS 遍历。每个状态存到哈希表里,避免重复访问。
visited = set()
queue = [initial_state]
while queue:
s = queue.pop()
if s in visited: continue
visited.add(s)
for t in successors(s):
if property_holds(t):
queue.append(t)
else:
report_counterexample(t)
这个方法的问题很明显:状态一多就炸了。我试过跑一个 10 个变量的合约,状态空间 2^10 = 1024,还行。但 20 个变量就是 100 万,直接内存溢出。
3.4.2 符号化模型检测
符号化方法用 BDD(二叉决策图)或 SAT/SMT 求解器来表示状态集合和迁移关系。不存具体状态,存布尔公式。
比如状态 (count=0) ∨ (count=1) 可以用一个 BDD 节点表示。迁移关系 inc 可以写成 count' = count + 1 的布尔公式。然后通过不动点计算来验证性质。
核心思想:不动点计算。比如验证 AG p(所有路径上 p 都成立),算法会从「所有满足 p 的状态」出发,反复去掉那些能一步走到非 p 状态的状态,直到收敛。收敛时的状态集合,就是 AG p 成立的最大区域。
3.4.3 有界模型检测(BMC)
BMC 是另一种思路:我不遍历所有状态,我只展开 k 步,然后检查这 k 步内有没有违反性质。用 SAT 求解器来解。
for k = 1 to MAX_DEPTH:
formula = (初始状态) ∧ (迁移^1) ∧ ... ∧ (迁移^k) ∧ (¬性质在第k步)
if SAT(formula) == true:
return counterexample of length k
BMC 的好处是快,坏处是只能找到「有限步」的反例。如果性质在 100 步之后才违反,而你的 k 只设到 50,那就漏了。
注意:BMC 不能证明性质成立,只能证明「在 k 步内没发现反例」。我见过有人拿 BMC 当完整验证用,结果上线后被攻击了——攻击路径需要 200 步,而他的 BMC 只跑了 50 步。完整验证还得靠符号化模型检测或归纳法。
3.5 实际应用中的选择
做智能合约形式化验证时,我一般这样选:
- 小规模合约(状态 < 10^6):显式状态模型检测,简单直接
- 中等规模(状态 10^6 ~ 10^12):符号化模型检测,用 BDD 或 SMT
- 大规模(状态 > 10^12):BMC 加抽象,或者用归纳法
工具方面,我常用 NuSMV 做符号化模型检测,用 CBMC 做有界模型检测。对于 Solidity 合约,可以先用 Slither 转成中间表示,再喂给这些工具。
最后说一句:模型检测不是银弹。状态空间爆炸是永远的敌人。但如果你能做好抽象、选对逻辑、用对算法,它绝对是你安全工具箱里最锋利的一把刀。我每次审计关键合约,都会先跑一遍模型检测——哪怕只跑出几个反例,也比上线后被人跑出反例强得多。