去除反混淆后程序中的冗余汇编代码
一、前言
在之前去混淆的过程中,发现去混淆的程序中,伪代码与原程序伪代码相差无几,然而如果仔细观察汇编代码的话,可以发现存在较多的冗余代码,即混淆过程中产生的垃圾代码。那么如何去除这些冗余汇编指令呢?
二、基于Liveness Analysis的想法
先给出我们需要解决一个例子(这是一个虚假控制流混淆中的部分代码,其中标*的为冗余汇编指令):
1 | |
最开始的想法是从第20行的汇编指令开始,跟踪与该指令以及后续指令相关的指令。如何跟踪呢?简单想就是跟踪这些指令使用到的寄存器。
2.1 Liveness Analysis
Liveness Analysis是一种数据流分析技术,用于确定程序中的每个变量在程序的不同点上是否“活跃”。“活跃”的意思是变量在某个程序点后将被使用,且在这之前没有被重新定义。这项技术在编译器优化、寄存器分配和死代码消除中起着关键作用。

本文章中主要用到了里面的def和use集合的想法。
def集合:当前汇编指令显式和隐式改变的寄存器、内存的集合。use集合:当前汇编指令显式和隐式使用的寄存器、内存的集合。
2.2 改进Liveness Analysis以适配
然而从图中看,似乎Liveness Analysis技术没有考虑到标志寄存器的改变和使用。对于Liveness Analysis技术能否解决我所预设的问题,我也不太清楚。因此在此基础上,进行如下改进:
- 引入标志寄存器来跟踪相关指令,这对于一些条件指令的跟踪是有帮助的。
- 对于内存操作数(例如
[rbp+var_10]),还应提取其表达式中的寄存器,存入use集合中。因为我认为既要跟踪内存地址是怎么来的,也要跟踪内存地址对应的值是怎么来的。 - 对于指令中操作数既不是寄存器,也不是内存的(例如
var_4、全局变量x),应认为它们是常量,即不加入use集合中。 - 由于指令中出现的寄存器名可能是同一寄存器的不同位数区分(例如
rax, eax, ax, ah, al),统一将它们转换成最大位数的寄存器名。
对刚才那个例子进行def和use集合的分析,结果如下:
1 | |
具体跟踪的初步想法是:
- 给定某一指令 $I$ ,初始化跟踪集合 $trace = use_I$ (指令 $I$ 的 $use$ 集合),初始化相关指令列表 $relevantInsns = [I]$。
- 往上追溯每条指令,如果 $trace \cap def_{I_i} \neq \emptyset$ ,那么认为指令 $I_i$ 是相关指令,将指令 $I_i$ 加入到 $relevantInsns$中, 同时在 $trace$ 集合删除交集中的元素,并将指令 $I_i$ 的 $use$ 集合中的元素加入进来,即进行如下运算:$trace = (trace - (trace \cap def_{I_i})) \cup use_{I_i}$ 。如果 $trace \cap def_{I_i} = \emptyset$ ,那么认为指令 $I_i$ 是不相关指令,不进行任何操作。
- 在追溯的过程中,如果 $trace = \emptyset$ 或者待跟踪指令集为空,则认为跟踪结束。
感觉说的有些混乱,就那上面这个例子解释一下吧。首先我们假设跟踪的指令为jnz loc_401235,后续步骤如下:
1 | |
对于上述例子,我们的想法成功的追踪到了所有冗余指令。然而当存在cmovcc指令时,我们的想法并不是很理想。
2.3 cmovcc指令所带来的问题
以下是控制流平坦化混淆的垃圾汇编指令(其中标*的为冗余汇编指令)。
1 | |
由于cmovnz存在执行和不执行的情况,因此需要分两种情况讨论:
- 当标志寄存器满足
cmovnz的条件时,执行cmovnz指令,此时def={rax},use={rcx,rflags}。 - 当标志寄存器不满足
cmovnz的条件时,不执行cmovnz指令,此时def={},use={rflags}。
单独分析以上任何一种情况,其结果都不能覆盖所有的冗余指令,但其两者情况的并集能够覆盖所有冗余指令。因此在向上溯源时,需要注意是否时cmovcc指令,如果是,则需要保存当前状态,探索其中一条路径,当该路径探索完毕后,再探索另一种路径。此情况可通过递归实现。
三、代码实现
接下来主要展示代码中重要的部分,完整代码请参考https://github.com/gal2xy/AssembleCodeTracer。
3.1 汇编指令字典的建立
对于刚才所展示的例子中,我们可以直观的看出指令的def和use集合,但是计算机不行。这是我所面临的第一个麻烦,解决这个问题,后续分析才能进行下去。为此,我建立了一个简单的汇编指令字典,部分示例如下:
1 | |
3.2 解析指令中的操作数
由于汇编指令中的操作数有多种类型,且对于不同类型的操作数,我们需要进行不同的操作。
- 寄存器类型可以直接放入
def和use集合中。 - 内存不仅可以直接放入
def和use集合中,而且还需要解析其表达式中的寄存器,这些寄存器也要放入def和use集合中。 - 立即数则不应放入
def和use集合中,应认为它是某一寄存器跟踪结束的标志。
1 | |
3.3 从内存表达式中提取寄存器
由于指令中的内存可能是一个表达式的情形,例如[2*rax+rbx-1],我们可以直观的看出,它肯定使用了rax、rbx寄存器,因此我们需要实现一个函数来提取内存表达式中的寄存器。
1 | |
3.4 获取指令中的def和use集合
这一步就需要借助到 3.1 所定义的汇编指令字典。整个函数的功能为:
- 获取
def集合:获取当前指令的def_exp_index键值,根据索引值列表找到对应的具体操作数,并根据操作数的类型进行不同操作。之后再获取当前指令的def_imp_reg键值,将隐式定义的寄存器加入到def集合中。 - 获取
use集合:同理类似。
1 | |
3.5 跟踪相关汇编指令
最终算法如下:
- 给定待跟踪的指令
I,以及所需跟踪的指令范围,初始化 $traceSet = useSet_I$,$relevantInsPos={I}$,$startPos = pos_I$,其中$pos_I$为指令 $I$ 在$Insns$中的索引值。 - 开始向上探索(即$startPos–$),对于每一条指令 $I_i$,$i∈[0, pos_I)$:
- 如果 $traceSet \cap defSet_{I_i} \neq \emptyset$ ,则指令 $I_i$ 为相关指令,更新 $tarceSet = (traceSet - defSet_{I_i}) \cup useSet_{I_i}$ 以及 $I_i → relevantInsPos$。(对于
cmovcc指令,需先进行如下操作:定义 $newTarceSet = traceSet \cup {rflags}$ ,递归调用当前算法,将返回结果当前 $relevantInsPos$合并) - 如果 $traceSet \cap defSet_{I_i} = \emptyset$ ,则指令 $I_i$ 为非相关指令,不进行任何操作。
- 如果 $traceSet \cap defSet_{I_i} \neq \emptyset$ ,则指令 $I_i$ 为相关指令,更新 $tarceSet = (traceSet - defSet_{I_i}) \cup useSet_{I_i}$ 以及 $I_i → relevantInsPos$。(对于
- 如果 $traceSet = \emptyset$或者$startPos < 0$, 则跟踪结束,返回 $relevantInsPos$ ,否则继续进行步骤2。
1 | |
四、不足之处
当真实汇编指令与垃圾汇编指令构成了标志寄存器的前者定义后者使用的关系时,此算法会造成误判。具体例子如下所示。(其中标*的为冗余汇编指令,其余为真实代码)
1 | |
由于cmovl指令依赖标志寄存器,因此会将第5行的cmp指令纳入到相关指令集合中(然而第5行的cmp指令是真实代码),以至于后续将其余真实代码也囊括进来,最终造成了误判!这个问题似乎解决不了,所以我认为我这个想法到头来还是失败的。
2024/12补
现代反汇编器都会提供充分的指令和寄存器分析,比如当前指令属于哪一类(读写、比较、加减、乘除等等)、是否影响标志位、会读取哪些寄存器、会对哪些寄存器产生修改等等。Capstone 是反汇编器的集大成者,自然也包括这些分析。下面看一下代码实现,regs_access会返回两个列表,第一个 list 是当前指令所读取的寄存器,第二个 list 是当前指令所修改的寄存器。
1 | |