做逆向分析时间长了,你可能会注意到一个规律:单看某一条表达式,几乎所有代码都长得差不多;但把几条表达式通过逻辑运算符串成一组复合判断,每段代码就有了一股"认得出"的味道。这就像看笔迹,单个笔画谁都会写,可笔画与笔画的连接方式、用力习惯,才是真正能用来鉴别的特征。多表达式逻辑关系,恰恰就是二进制程序里那层最容易保留作者痕迹、又最容易被忽视的结构。
这篇文章要聊的就是这个方向:如何从一段代码或一个二进制函数中,提取出"多表达式逻辑关系"层面的逆向特征,并用它来完成恶意样本同源性分析、代码归属判定、漏洞模式匹配这类偏实战的任务。适合做病毒分析、固件逆向、代码审计,以及做二进制相似性检测的工程师参考。我会把核心概念、特征维度、实战推演和自动化提取路径都过一遍,最后聊聊几个容易翻车的地方。
1. 多表达式逻辑关系:为什么值得当成独立的特征层
很多人在做逆向时习惯盯着单条指令或单个基本块看:这里有个 cmp,那里有个 jz,然后顺着控制流一路跟下去。这种做法的优点是直接,缺点是碎片化。因为你看到的是被编译器切碎之后的"指令流",而不是程序作者真正写出来的"逻辑组织方式"。
1.1 从"一句话"到"一段话":特征粒度的跃迁
如果你只看一行代码,比如 if (a > 0),反汇编出来基本就是一次比较加一次跳转,几乎没有任何辨识度。但当你把视野拉宽,看到一个这样的复合判断:
c复制if ((a > 0 && b < 100) || (c == 0x5A && d != 0)) {
// do something
}
这个由两个子条件通过 OR 连接的复合表达式,带给分析者的信息就完全不一样了:
- 子条件的个数和连接符分布构成了拓扑特征;
0x5A这个常量携带了明确的数值指纹;- 短路求值的顺序直接映射到底层汇编的基本块排列方式。
所以,"多表达式逻辑关系"不是几行代码的堆叠,而是一个有结构的对象。把它单独作为一层特征来研究,能拿到的信息量远超单条语句。
1.2 为什么这层特征更稳定、更能抗混淆
从对抗的角度看,混淆工具能做的最顺手的事情,是替换变量名、增加垃圾指令、改写单个运算表达式。比如把 a + 5 改成 a + 2 + 3,或者把 x * 2 改成 x << 1。这类变换对单条表达式的形态影响极大,但它很难改变一个复合条件内部的骨架——比如"必须先满足 A 再满足 B"这种嵌套关系、操作数的常量取值、子条件之间的 AND/OR 连接方式。因为一旦改了这些,程序行为就变了。
这让我想起一个很实际的类比:你想在人群里辨认一个人,身高、发色这种单一外观特征很不可靠,但如果看的是"走路时摆手的幅度、停顿时微微侧身的姿势、上下台阶时先迈哪只脚"这种组合关系,识别率就会大幅提升。多表达式逻辑关系,就相当于代码的"步态"。
1.3 逆向中的三类典型需求都依赖这层信息
我总结了一下,凡是涉及"关系级"逆向分析的场景,最终都会落到这层特征上:
- 恶意代码同源性分析:某个家族的木马在不同版本里复用同一个校验逻辑,函数名变了、加密壳换了,但
if (hash == 0x0D0C && version < 5 && flag & 0x80)这种复合条件骨架还在; - 代码作者识别:同一个工程师写的网络协议解析代码,总是习惯先判长度再判魔数,再判校验,这个顺序本身就是一种行为指纹;
- 漏洞模式匹配:典型漏洞往往由一个"缺失边界检查 + 特定比较逻辑 + 危险操作"的组合构成,提取复合条件能直接命中模式。
换句话说,多表达式逻辑关系这个分析维度,是连接"底层指令"和"高层语义"的一座桥。越过这座桥,你看到的就不再是一堆跳转指令,而是作者脑子里的决策树。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 逆向视角下的三类复合逻辑形态:先认识目标再动手提特征
在正式进入特征提取之前,得先把"多表达式逻辑关系"在二进制里最常见的三种形态理清楚。形态不同,能提取的特征类型也完全不同。如果上来就眉毛胡子一把抓,很容易把逻辑拓扑特征和数据流特征搅在一起,反而降低后续匹配的准确率。
2.1 逻辑运算符串联的复合条件
这是最直观的一类。多个子表达式通过 &&、||、! 连接,最终落成一个布尔结果。在源码层面,它可能出现在 if、while、for 的条件位置,也可能直接出现在 return 语句里。反汇编层面,这类逻辑会被编译成一组"比较 + 条件跳转"指令,而跳转的目标和顺序,恰好暴露了原始逻辑的连接方式。
典型特征提取点有两个。第一是短路求值留下的顺序痕迹。以 if (a > 0 && b < 100) 为例,编译器通常不会把两个子条件都算完再合并,而是先比较 a,失败就直接跳过整个分支。这个"先算谁、失败跳去哪"的顺序,在反汇编里非常清晰。第二是子条件之间的关联,比如两个子条件是否引用同一个变量、比较对象是常量还是内存加载。
2.2 赋值表达式与副作用构成的隐式逻辑
这一类比第一类隐蔽得多,因为它的"逻辑关系"不是显式写在布尔表达式里,而是藏在赋值表达式的副作用中。举个例子:
c复制int i = 0;
while ((c = getchar()) != EOF && i++ < 10) {
// process c
}
这里的 c = getchar() 是赋值表达式,i++ 带副作用,两个表达式通过 && 组合起来共同决定循环是否继续。如果逆向时只盯着赋值语句本身,很容易把 c = getchar() 当成一条普通指令,而忽略它其实参与了整个循环条件的逻辑判定。
这种形态的特征表现在:多个寄存器或栈变量的定义-使用链条交织在同一个条件表达式里。它的数据流特征往往比逻辑拓扑特征更突出,所以提取时要把重点放在"哪些变量在哪个表达式里被定值、又在哪个表达式里被使用"上。
2.3 多分支映射与表达式归一化
第三种形态来自多重分支结构:switch-case、if-else if 链、三元运算符嵌套。这类代码在 AST 层面通常会被编译成跳转表,或者一棵比较树。多个表达式会先经过一个"归一化"过程(比如把范围判断转换成无符号下溢技巧),然后分派到不同的执行路径。
这种形态最具模式识别价值,因为它直接反映了人类作者或混淆工具组织决策的方式。有人喜欢把区间判断写成 x > 0 && x <= 100,有人会写成 (unsigned)(x - 1) < 100,还有人会写成 switch-case 配合 0 和 1 的掩码。这些不同的组织方式,落到反汇编层面后会有截然不同的控制流形态,但它们的语义约束是等价的。这种"同义异形"关系,正是做语义特征提取时需要重点关注的。
3. 六类可落地的逆向特征:把复合判断变成可量化的指纹
这一部分是全文的核心操作层。我会逐一说清楚每类特征的提取方法、稳定性如何、适合用在哪里。你可以在自己的工具链里按需选用,不需要每类都全部实现,但要清楚每类特征能回答什么问题。
3.1 表达式拓扑与运算符分布
第一类特征是从 AST 或反汇编控制流图中算出来的,具体包括:
- 复合条件的嵌套深度;
- 逻辑运算符的类型、数量、比例;
- 括号表达式形成的子树形状;
- 比较操作的左右操作数类型(常量、变量、内存加载、函数调用)。
为什么这层特征稳定?因为混淆工具可以改掉变量名、打乱基本块,但很难消灭表达式之间的嵌套深度和运算符配比。这就像改写一个句子可以换同义词,但句子的主谓宾骨架不会轻易变化。
我在实践中会把这些数值组合成一个定长向量,比如 [嵌套深度=2, AND=4, OR=1, NOT=0, 常量比较=3, 变量比较=4]。做样本库粗筛时,这个向量的计算成本很低,能快速排除大量明显不相关的函数。
3.2 常量与边界值指纹
每个复合条件里总会出现一些"定海神针"式的数值:魔数、数组边界、枚举值、位掩码。比如 if ((x & 0xFF) == 0x5A) 里的 0xFF 和 0x5A。这些数值在特征提取时的权重应该最高,因为它们的语义非常明确,而且跨编译器、跨混淆强度都高度稳定。
实际提取时,我会单独做一张常量表,记录每个逻辑分支中出现的所有立即数及其位宽、比较方向(大于、小于、等于、不等于)。这些常量串在一起,可以当成"语义哈希"来用。比如一个函数的常量指纹串是 [0x5A, 0xFF, 0x80, 0xF0, 0x1A, 0x20, 0x64],在样本库里做索引召回时,效率比全文检索字符串高得多。
更妙的是,常量的组合往往能直接暴露程序的用途。一个逻辑块里同时出现 0x1A 和 0x20,你大概能猜到它在做某种范围校验;出现 0x5A 这种魔数,则很可能是在匹配某个协议头标识。这种"常量组合"级别的语义推断,是单个常量无法提供的。
3.3 短路求值与执行顺序的时序特征
这是很多新手最容易忽略的特征维度。前面说过,短路求值顺序会反映在汇编基本块的排列和跳转方向上。更进一步,如果你在动态调试环境里,甚至可以通过测量同一个复合条件在不同输入下消耗的时钟周期,反推出表达式的内部顺序——这本质上是一种侧信道特征。
静态提取时,我主要是记录比较操作的先后顺序序列。比如 (cmp a, jg fail; cmp b, jl fail; cmp c, je pass; ...),把每个比较目标和跳转方向按顺序记录下来。这个序列非常像程序的"节奏纹路",不同作者写出同样功能的复合条件,顺序经常不一样。
有一个点需要注意:编译器的优化等级会改变这个顺序。比如 -O0 和 -O2 下,同样的源码生成的比较顺序可能完全不同。因此时序特征适合在"同编译器、同优化等级"的条件下做相似性对比,跨编译器对比时只能作为辅助参考。
3.4 数据依赖和定值-使用链
多表达式之间通常会共享变量:前一个表达式给变量赋值,后一个表达式拿它参与判断。这种数据依赖关系可以用 def-use 链来表示。提取时,把参与条件表达式的所有变量和寄存器列出来,画它们之间的引用关系,就能得到一个有向图。
举个例子:
c复制int v1 = a * 17 + 3;
int v2 = b - 0x1A;
if (v1 > 0x64 && v2 < 0x20) { ... }
这里的依赖关系是 a -> v1、b -> v2、v1 -> 比较1、v2 -> 比较2。这个图虽然简单,但它说明了 v1 的定值和使用之间隔着一次乘法加法和一次比较,这种"距离"在语义上是稳定的。
这个图的拓扑形状鲁棒性很强,因为即便是激进的编译器优化,也不太可能在不改变逻辑的前提下彻底重排变量之间的数据依赖。特别是当多个表达式共享同一个中间变量时,图的拓扑特征会非常明显,适合做高区分度匹配。
3.5 冗余逻辑与防御性检查痕迹
有些复合条件里藏着"多余"的检查,比如:
c复制if (ptr != NULL && ptr->size > 0) { ... }
第一项 ptr != NULL 理论上可以由调用点保证,但作者出于防御习惯还是写上了。逆向分析时,这些冗余项恰恰是作者思维方式的直接体现。一个喜欢写防御性检查的工程师,在多个函数里都会留下类似痕迹;一个追求极致性能的工程师,则会把这类检查全部删掉。
混淆器也会留下类似痕迹,比如插入不透明谓词(opaque predicate)。这类谓词让表达式拓扑发生漂移,但它们的常量特征通常很特殊,比如 x * (x + 1) % 2 == 0 这种恒真式。识别出这类模式后,反而可以作为"这是某款混淆器处理过的代码"的特征,用于工具链识别。
3.6 语义等价的模式特征
同一段逻辑可以用完全不同的写法表达。判断 x 是否在 1 到 100 之间,常见写法至少有三种:
c复制if (x > 0 && x <= 100) // 直接区间判断
if ((unsigned)(x - 1) < 100) // 无符号下溢技巧
if (x >= 1 && x < 101) // 边界微调
这三种写法的 AST 完全不同,但语义等价。单纯比较 AST 会完全失效,必须借助符号执行或 SMT 求解器,把表达式转换成某种范式(如多项式、区间约束),再比较范式。这类特征提取成本最高,但抗混淆能力也最强,最适合用在误报容忍度低的场景,比如漏洞模式匹配、恶意代码同源判定。
4. 实战推演:一段混淆后的多表达式逻辑如何被逐层还原
理论说太多容易飘,下面用一个可以复现的例子,带你走一遍完整链路。假设目标是某个嵌入式固件中的函数,反编译出来是这样一段伪代码:
c复制int check(int a, int b, int c, int d) {
int v1 = a * 17 + 3;
int v2 = b - 0x1A;
if (v1 > 0x64 && v2 < 0x20) {
if ((c ^ 0x5A) == 0 && (d & 0xF0) == 0x80) {
return 1;
}
}
return 0;
}
这个例子我做了大量简化,但足以展示"多表达式逻辑关系"的提取思路。真实二进制里的指令会更多、更碎,但分析链路是一样的。
4.1 先抓"关系图",不看单条语句
拿到这段伪代码,我建议先别急着算 a * 17 + 3 是干什么的,而是先把参数和最终返回值之间的逻辑关系画出来。这个过程完成后,你会得到一张清晰的结构图:
- 外层:
v1和v2用 AND 连接; - 内层:
c和d也用 AND 连接; - 内外层之间是顺序进入关系,先过外层,再过内层。
这个关系图本身就是第一个特征:逻辑深度为 2、AND 运算符出现 4 次、没有 OR、没有 NOT。记下这个结构,它可以直接作为样本库检索时的结构化条件。如果你在库里见过另一个函数也是"两层嵌套、四个 AND、数值比较为主",那它们很可能是同一段逻辑的变体。
4.2 提取常量指纹串
接下来挑常量。从左到右提取:17、3、0x1A、0x64、0x20、0x5A、0xF0、0x80。这里有个小技巧:不仅要记录数值,还要记录每个常量参与的比较方向。
- 17 和 3:参与乘法和加法,属于算术运算常量;
- 0x1A:作为减数出现;
- 0x64 和 0x20:作为区间上界,参与大于/小于比较;
- 0x5A:参与异或比较,很可能是魔数;
- 0xF0 和 0x80:参与位掩码检测。
把这些常量按出现顺序组成序列,就是"常量指纹串"。在实际项目里,我用这个串做索引召回的效果非常好——同源样本即使代码被重新编译、变量名被抹掉,常量串也几乎不会变。
4.3 用符号执行消除算术噪声
这段代码里 v1 = a * 17 + 3 和 v2 = b - 0x1A 是典型的算术变换。原作者写的可能是 a > 3 && b < 0x3A,但为了绕过高亮特征匹配,硬是把比较的两边都做了线性变换。
这时候就要上符号执行了。用 z3 把外层条件直接翻译成 SMT 约束:
python复制from z3 import *
a, b = Ints('a b')
v1 = a * 17 + 3
v2 = b - 0x1A
s = Solver()
s.add(v1 > 0x64, v2 < 0x20)
print(s)
z3 求解并整理后,可以得到等价约束 a >= 6 && b <= 0x39。这时候外层复合条件的真实语义就出来了。后续做语义等同比较时,直接用这个化简后的约束集去匹配库里的已知函数即可,而不需要关心它当初是用乘法还是移位来写的。
4.4 验证完整路径与反例
符号执行化简只是第一步,不要直接信结果。我会把化简出的约束带回到原始二进制,构造测试输入跑一遍,确认能到达 return 1 分支。比如取 a=6, b=0x39, c=0x5A, d=0x80,原函数确实返回 1,那说明化简没有破坏行为。
如果发现化简后的约束与原函数行为不一致,就要回头检查是不是 v1/v2 的算术只在特定区间内成立。比如 a * 17 + 3 在 a 取负数时,可能因为补码运算出现意想不到的整数溢出路径,导致原函数在 a 为负数时也返回 1,而符号执行给出的约束没有覆盖这个边界。这种边界条件,往往是逆向分析时最容易翻车的地方。
4.5 把还原结果沉淀成结构化特征记录
最后,把整个分析结果存成一份结构化特征记录,包含:
- 逻辑拓扑(嵌套深度、运算符分布);
- 常量指纹串(数值 + 参与的比较方向);
- 化简后的语义约束(供后续 SMT 验证);
- 数据依赖图(变量之间的定值-使用关系);
- 反汇编比较顺序序列。
这样既完成了当前分析任务,也为后面搭建自动化特征库积累了素材。我一般会直接输出成 JSON 或 YAML,方便后续脚本读取。
5. 自动化提取:AST、符号执行与特征匹配的协作流程
手动分析能解决单点问题,但如果你要面对几百个固件样本、几十万个函数,就必须把上面这套方法流程化。下面是我实际项目里常用的三层流水线。
5.1 用解析器或反编译器快速生成 AST 候选
在源码可用或能得到 IR 的场景下,我优先用 tree-sitter、ANTLR 或 Ghidra 的 P-Code 来生成 AST/IR。目标只有一个:自动拆分出所有复合条件表达式,切出每个子表达式及其连接符。
关键代码片段如下:
python复制import tree_sitter
code = b"if ((a > 0 && b < 100) || (c == 0x5A)) { }"
tree = parser.parse(code)
root = tree.root_node
def extract_binary_expr(node):
if node.type in ('binary_expression', 'parenthesized_expression'):
# 提取操作符和操作数
pass
for child in node.children:
extract_binary_expr(child)
extract_binary_expr(root)
这一步不追求分析语义,只做形态切割,速度快,适合批量跑。我会在这个阶段输出每个复合表达式的括号嵌套深度、运算符直方图、常量表,作为特征向量的基础分量。
5.2 用符号执行做语义化简与归并
形态特征提取完之后,对候选逻辑块做语义化简。Ghidra 的 P-Code 可以直接喂给 z3,也可以用 angr 的 VexIR 做符号执行。但这里有个关键点:只化简"逻辑条件部分",不要对整个函数做全路径符号执行,否则状态爆炸会拖垮整个流程。
化简得到的约束集先做归一化,比如把所有比较都转成 <= 形式,提取成区间约束;再对约束做哈希或编码,变成一个固定长度的语义向量。这里的语义向量不是用来做完全等同判定的,而是作为粗筛:先通过它把明显不同的样本过滤掉,再用精确求解处理少量候选。
5.3 分层特征匹配:从快速召回到底层语义验证
特征匹配阶段我建议分成三层来做,每层解决不同精度的问题:
- 第一层:常量指纹串匹配,速度快,能直接召回到同类样本;
- 第二层:AST/控制流图的结构相似度计算,用编辑距离或图同构算法,处理常量串有变化但骨架相似的样本;
- 第三层:对少数高相似候选做 SMT 等价验证,用 z3 判断两个逻辑块是否语义等价。
分层匹配的核心原因,是这三类特征的表征空间不同,不能简单拼成一个向量。硬拼的话,物理意义会混掉,查出来的结果也不知道是"像"在结构上,还是"像"在语义上。我在恶意固件同源分析项目里用这套流程,能把候选集从几十万函数收敛到几十个,后续人工确认的工作量大大降低。
6. 容易翻车的地方:优化、混淆变形与特征漂移
工具和流程能跑起来只是第一步。真正让这套方法论在实际项目里站住脚,靠的是对各种"特征失真"情况的清醒认识。下面这几个坑我都踩过,列出来希望你能避开。
6.1 编译器优化等级会偷偷改掉表达式顺序
同一个 if ((a && b) || c),GCC -O0 编译出来可能是一个接一个的比较和跳转,顺序非常直白;到了 -O2,编译器可能把条件翻转、合并,甚至改成用 setcc 指令一次性算好结果。这意味着,基于"汇编指令顺序"提取的时序特征,在跨优化等级对比时会失真。
我的应对策略是:优先对比语义约束和数据依赖图,其次才对比指令顺序序列。在做特征工程时,把"顺序敏感型特征"和"顺序不敏感型特征"分开存储,标注适用的编译条件,避免误用。
6.2 短路求值可能被编译器直接消除
如果编译器能确定某个子表达式没有副作用,它可能不再严格按源码里的短路顺序执行,而是把所有子表达式都算完再合并。结果就是,你基于"先执行哪个比较"提取的时序特征直接失效。这种情况在 -O2 和 -O3 下尤其常见。
所以我在实际项目里,从不会单独依赖时序特征做判定。时序特征只作为辅助维度,最终结论必须由语义约束和常量指纹来兜底。
6.3 不透明谓词会污染拓扑特征
混淆器插入的不透明谓词会人为增加逻辑深度和运算符数量,导致 AST 拓扑特征发生漂移。比如原本只有 if (a && b) 的地方,被改成 if (a && b && (x * (x + 1) % 2 == 0)),最后一项恒真但明显增加了特征维度。
识别这类谓词需要额外的模式库。更稳妥的做法,是在提取特征前先用符号执行把恒真/恒假的子条件剥离掉,让后续特征专注于真正影响行为的逻辑部分。这个过程可以用 z3 快速完成:对每个子条件做求解,验证它是否在定义域内恒真或恒假。
6.4 语义等价的"代偿写法"会造成误报和漏报
两个功能完全相同的逻辑,一个作者写 if (x > 0 && x < 10),另一个作者写 if ((x >= 1) && (x <= 9)),或者更激进地写成位运算形式。它们的语义向量可能完全一样,但形态向量差异巨大。反过来,两个形态很相似的条件,可能因为一个 > 和一个 >= 的区别,导致行为完全不同。
这个坑在漏洞模式匹配时尤其致命。我的原则是:形态特征只用于快速召回,语义特征用于最终确认,永远不要只凭一个层面的特征下结论。把特征分层使用,误报率能控制在一个可以接受的范围。
7. 这套"关系特征"思路还能用在哪些地方
聊完具体实现,最后说几个我实际接触过的延伸场景。这些方向未必都适合直接套用文章里的完整流程,但核心思路——从"多表达式之间的关系"里挖特征——是共通的。
7.1 恶意代码同源性与家族判定
恶意软件作者通常会在多个样本里复用同一个逻辑核心,只是换掉外壳、换掉字符串、换掉加密方式。文件哈希在这种场景下完全失效,字符串相似度也不可靠,但程序内部"先做什么校验、再做什么变换、最后比较什么常量"的复合条件骨架,很难在每次变种里都重写一遍。多表达式逻辑关系特征,能稳定地捕捉到这种"逻辑骨架"层面的相似性。
7.2 代码作者与编译器指纹识别
不同开发者写复合条件时的习惯差异非常明显:有人喜欢把常量放在比较左边(if (0x5A == c)),有人习惯先判空再判值;有人偏好提前 return,有人偏好用嵌套 if 降低缩进深度。这些习惯在多表达式逻辑关系里表现得尤其清晰,可以做代码作者识别,也可以在做代码审计时快速定位"非团队风格"的可疑代码段。
7.3 固件漏洞模式的批量扫描
某个漏洞的基础模式,往往是"缺失边界检查 + 特定比较逻辑 + 危险函数调用"的组合。比如一个溢出漏洞,前提是 if (length > max_size) 这个检查被写反或者漏掉。把已知漏洞的多表达式逻辑关系建成特征库,就可以在大量固件里批量扫描相似模式,比单纯搜字符串或人工翻反汇编高效得多。
我个人的体会是:多表达式逻辑关系这个分析维度,不是某个特定工具能替代的,它更像是一种思维方式——时刻提醒你不要只盯着单条指令,而是去看序列、看关系、看骨架。做逆向分析越久,越会发现这些"看不见的结构"才是真正决定程序行为与身份的东西。
最后分享一个实操中的小技巧:拿到一个新样本时,我通常先花几分钟把所有函数的常量指纹串列出来,按出现频次排序。那些高频出现且具有明确语义的常量组合,往往就是整个程序最核心的逻辑骨架,从那里切入分析,比漫无目的地翻反汇编高效得多。这套方法我用在很多不同架构的二进制上,目前还没有失效过。
