1. 从标题拆解这篇论文到底在解决什么问题
看到"WANA: Symbolic Execution of Wasm Bytecode"这个标题,第一反应应该是三个关键词的碰撞:Wasm字节码、符号执行、WebAssembly生态的安全分析。这三个词单独拎出来都不算新鲜,但组合在一起,指向的是一个相当具体的痛点——WebAssembly跑在浏览器里、跑在边缘节点上、跑在区块链合约里,可它的安全性分析工具链一直跟不上它扩张的速度。
Wasm的设计初衷是"可移植、可验证、高效",它有一套结构化的类型系统和沙箱执行模型,理论上比原生x86汇编安全得多。但"理论上安全"和"实际没有漏洞"是两码事。Wasm模块里照样可能出现整数溢出、越界访问、类型混淆、逻辑后门,尤其是当它被用作智能合约字节码或者插件沙箱的时候,一个漏洞的代价可能是真金白银。所以问题就变成了:怎么在Wasm字节码层面做系统性的漏洞挖掘?
符号执行是回答这个问题最自然的思路之一。它的核心思想不复杂:不去跑具体的输入,而是把输入抽象成符号,沿着程序的每条路径走,收集路径约束,最后交给约束求解器(比如Z3)判断哪条路径能触发异常状态。这条路子在x86、ARM、LLVM IR上都有人走通过,经典工具像KLEE、angr、S2E都是这个范式。但Wasm有它自己的脾气——栈式虚拟机、结构化控制流、显式的类型系统、线性内存模型,这些特性让直接把x86那套符号执行引擎搬过来变得不划算。
WANA这篇论文的价值,就在于它没有偷懒去复用LLVM IR层面的符号执行,而是直接在Wasm字节码上构建符号执行引擎。这个选择背后有明确的工程理由,也是我读这篇论文时最先想搞清楚的地方。下面我会按"为什么这么设计—核心机制怎么运作—实测中会遇到什么—能怎么用"这条线,把这篇论文拆开揉碎讲一遍。
2. 为什么不能直接把LLVM IR的符号执行搬过来
2.1 Wasm字节码和LLVM IR的语义鸿沟
很多人第一反应是:Wasm工具链(比如Emscripten)本来就会把C/C++编译成LLVM IR再转Wasm,那我直接在LLVM IR层面做符号执行不就行了?这个思路在理论上成立,但实际用起来会丢掉很多东西。
LLVM IR是编译器中间表示,它假设自己运行在一个相对"干净"的抽象机器上,内存模型是平坦的,控制流是任意的跳转图。而Wasm字节码是面向栈的、结构化的、带类型验证的。当你从LLVM IR降到Wasm时,编译器会做大量优化和重写:栈式指令序列、结构化块(block/loop/if)、显式的局部变量和全局变量、线性内存的边界检查。这些信息在LLVM IR层面要么不存在,要么被抽象掉了。
如果你在LLVM IR层面做符号执行,你分析的是"编译器认为的程序",而不是"实际执行的程序"。这两者之间的差异,恰恰是漏洞容易藏身的地方——比如Wasm的类型验证器在某些边界情况下的行为、线性内存增长指令memory.grow的副作用、间接调用表call_indirect的类型检查逻辑,这些在LLVM IR里根本看不到。
2.2 栈式虚拟机给符号执行带来的特殊挑战
Wasm是栈式虚拟机,指令的操作数隐式地从操作数栈上取。这对符号执行来说是个双刃剑。
好处是:每条指令的语义非常局部化,你不需要像x86那样处理复杂的寄存器别名和标志位依赖。坏处是:符号状态的表示变得复杂。在x86上,你可以把寄存器状态表示成一个映射表;在Wasm上,操作数栈是一个动态变化的结构,每个栈槽都可能持有一个符号表达式,而且栈的高度在不同路径上可能不同。
WANA要解决的核心工程问题之一,就是如何高效地表示和操作这个符号化的操作数栈。论文里提到他们用了符号表达式树加上路径约束的分离表示,栈槽只存表达式引用,实际的约束收集在路径条件里做。这个设计的好处是避免了在每个栈操作上都做昂贵的约束求解,把求解推迟到路径分叉或者状态检查的时候。
2.3 结构化控制流反而简化了路径探索
这一点比较反直觉。Wasm的控制流是结构化的——没有任意跳转,只有block、loop、if、br、br_if、br_table这些结构化的分支和循环指令。初看起来这限制了程序表达力,但对符号执行来说反而是好事。
在x86上做符号执行,最头疼的问题之一就是间接跳转和任意控制流,你很难静态确定一个jmp rax会跳到哪里,只能靠值集分析或者启发式。Wasm没有这个问题,所有分支目标在字节码里都是显式编码的,路径探索的边界非常清晰。WANA正是利用了这一点,把路径探索做成了一种结构化的遍历,而不是传统符号执行里的无头苍蝇式搜索。
这里有个实操心得:如果你自己动手实现一个Wasm符号执行器,优先把结构化控制流的遍历逻辑写对,这部分的正确性直接决定了后面所有分析的可信度。我见过有人在这块偷懒,结果路径约束收集错了,后面求解出来的"漏洞"全是误报。
3. WANA的核心机制:符号状态、路径约束与内存模型
3.1 符号状态的三层结构
WANA的符号状态设计可以拆成三层来看,这个分层是我读论文时觉得最值得借鉴的部分。
第一层是操作数栈。每个栈槽持有一个符号表达式,表达式可以是常量、符号变量、或者对其它表达式的运算。WANA用了一种紧凑的表达式表示,相同子表达式会做哈希共享,避免重复构造。
第二层是局部变量和全局变量。Wasm的局部变量是函数级的,全局变量是模块级的。WANA把它们都建模成符号存储,读写操作生成对应的约束。
第三层是线性内存。这是最麻烦的部分。Wasm的线性内存是一个可增长的字节数组,所有内存访问都要做边界检查。WANA的做法是把内存建模成一个符号化的字节数组,读操作生成"读取地址addr处的值"这样的表达式,写操作则更新内存状态并生成地址范围的约束。
这三层之间的交互通过路径条件来协调。每执行一条指令,如果它产生了分支或者可能触发陷阱(trap),就把对应的条件加到当前路径的约束集合里。当路径走到终点或者遇到需要求解的点,就把整个约束集合交给求解器。
3.2 路径探索策略:DFS还是BFS?
论文里没有明确说他们用的是哪种搜索策略,但从描述看,WANA更倾向于深度优先加启发式剪枝。原因很实际:Wasm模块的函数调用深度通常不会太夸张,DFS能更快地走到叶子路径,尽早发现可满足的约束或者不可满足的冲突。
但纯DFS有个问题:如果某个循环的符号条件一直可满足,搜索会陷进去出不来。WANA的处理方式是设置路径深度上限和循环迭代上限,超过就剪枝。这个做法在工程上很常见,代价是可能漏掉一些深层漏洞,但换来了可终止性。
我自己的经验是,路径探索策略的选择高度依赖于你要分析的目标。如果是智能合约那种逻辑相对简单但状态空间大的场景,BFS配合状态合并可能更合适;如果是插件沙箱那种调用链深的场景,DFS加剪枝更实用。WANA的选择偏向后者。
3.3 内存模型的精度与性能权衡
线性内存的符号化建模是WANA里最耗资源的部分。如果做得太精确——每个字节都单独符号化——内存状态会爆炸。如果做得太粗糙——比如把整个内存当成一个不透明的块——那又分析不出越界访问。
WANA采取了一种分区域建模的策略:对已知的、频繁访问的内存区域做精确符号化,对不常访问的区域做保守近似。具体怎么划分区域,论文里提到用了静态分析来识别热点内存地址。这个思路和很多二进制分析工具里的"内存分区"做法类似,核心是在精度和可扩展性之间找平衡点。
注意:内存模型的精度直接决定了误报率和漏报率。如果你在做类似工具,建议先把内存访问模式统计出来,再决定哪些区域值得精确建模。盲目追求全精确,最后大概率跑不动真实规模的模块。
4. 实测中会遇到哪些坑:从论文到落地的距离
4.1 约束求解器的性能瓶颈
符号执行的老大难问题在WANA上同样存在:约束求解太慢。Wasm的整数运算是精确的位向量运算,没有x86那种复杂的标志位,这本来是个优势。但一旦涉及到内存地址的符号化,约束就变成了位向量加数组理论的混合问题,求解复杂度陡增。
论文里提到他们用了Z3作为后端求解器,并且做了一些约束简化。实际用的时候,我建议关注几个点:一是约束的增量求解,不要每次从头求解;二是不相关约束的切片,把和当前查询无关的路径条件剔除掉;三是超时机制,单个查询超过阈值就放弃,避免卡死。
4.2 间接调用和函数表的处理
Wasm的call_indirect指令通过函数表做间接调用,调用目标在运行时才能确定。这对符号执行来说是个挑战:你不知道会调到哪个函数,就没法继续精确分析。
WANA的处理方式是枚举可能的调用目标,对每个可能的目标分别探索。如果函数表的大小可控,这个做法是可行的;如果函数表很大,就会导致路径爆炸。论文里没有详细展开这块的优化,但从工程角度看,这里有很大的改进空间——比如结合类型信息做目标过滤,或者用值集分析缩小候选集。
4.3 陷阱指令的语义建模
Wasm有一类指令会触发陷阱(trap),比如整数除零、越界访问、间接调用类型不匹配。这些陷阱在正常执行时是异常,但在安全分析里恰恰是最有价值的目标——很多漏洞利用最终就是通过触发陷阱或者绕过陷阱检查来实现的。
WANA把陷阱建模成一种特殊的路径终止条件:当路径条件蕴含陷阱触发条件时,这条路径就被标记为"可能触发陷阱"。这个建模方式很直接,但要注意陷阱条件的精确性。比如整数除零,Wasm的语义是除数为零时陷阱,但如果你把除数符号化成一个范围,你需要判断这个范围是否包含零,而不是简单地判断"除数是否等于零"。
5. 这篇论文对做安全分析的人有什么实际启发
5.1 字节码层面的分析值得投入
WANA最核心的启发是:不要总是想着往上层抽象走。很多人做分析喜欢往IR层面靠,觉得IR更干净、工具更多。但WANA证明了,在字节码层面直接做分析,虽然工程量大,但能捕获到IR层面丢失的信息,尤其是那些和运行时语义紧密相关的漏洞。
这个思路可以迁移到其它字节码平台上,比如JVM字节码、Python字节码、EVM字节码。每个平台都有自己的特性,直接在这些特性上做分析,往往比套用通用框架更有效。
5.2 结构化控制流是符号执行的盟友
前面提到过,Wasm的结构化控制流简化了路径探索。这个观察可以推广:任何具有结构化控制流的执行环境,都比任意控制流的环境更适合符号执行。如果你在设计一个新的字节码格式或者分析工具,保留结构化控制流是个明智的选择。
5.3 符号执行的工程化比理论更重要
读这篇论文最大的感受是,符号执行的理论框架几十年前就定型了,真正难的是工程化。怎么表示状态、怎么管理约束、怎么剪枝、怎么和求解器交互,这些工程决策直接决定了工具能不能用。WANA在这些方面做了很多务实的选择,值得借鉴。
6. 如果我要复现或扩展这个工作,会从哪下手
6.1 最小可行原型的技术选型
如果让我从零搭一个Wasm符号执行的原型,我会这么选:
- Wasm解析:用现成的解析库,比如Rust生态里的
wasmparser,它能把字节码解析成结构化的指令流,省去手写解析器的大量工作。 - 符号表达式表示:自己实现一个轻量的表达式树,支持哈希共享和简化规则。不要一上来就上SMT-LIB,先用自己的表示做快速原型。
- 约束求解:Z3的Rust绑定或者Python绑定都行,关键是做好约束的增量管理和超时控制。
- 路径探索:先实现DFS加深度限制,跑通几个小模块之后再考虑更复杂的策略。
6.2 验证策略:从已知漏洞样本开始
不要一上来就拿真实世界的复杂模块做测试,那样你连结果是误报还是真漏洞都分不清。建议先构造一批已知漏洞的Wasm样本,比如手动写几个包含整数溢出、越界访问的小模块,验证你的工具能不能稳定地报出来。然后再逐步增加复杂度。
6.3 可扩展的方向
WANA本身还有不少可以扩展的地方。比如结合模糊测试,用符号执行生成高质量的测试用例,再用模糊测试做大规模验证;比如支持多模块分析,处理Wasm的模块链接和导入导出;比如优化内存模型,引入更精细的别名分析来减少约束规模。这些都是有实际价值的方向。
读论文最有意思的地方,不是记住它做了什么,而是想清楚它为什么这么做,以及如果换我来做,我会怎么选。WANA在Wasm符号执行这个方向上给出了一个扎实的工程答案,剩下的就是动手试了。