花指令 (junk code) 是一种专门用来迷惑反编译器的指令片段,这些指令片段不会影响程序的原有功能,但会使得反汇编器的结果出现偏差,从而使破解者分析失败。(对于只会 F5 的人来说,就是地狱)

这些对抗手段主要的目的都是消磨耐心拖延时间,抬高人肉分析的成本。但混淆加花这种手段是无法做到只让机器读懂代码而人读不懂这种效果的。

Note

“众所周知,逆向这个活七分靠猜,因为在 compilation 的过程中大量信息丢失了,如分支跳转,类型,oop 里的跳转表,尾递归优化,死码消除等,所以逆向不可能完全靠精确无误的 algorithm,还需要可能出错但是大部分时候正确的 heuristic(经验原则)。
“我现在很悲观,二进制分析看起来最靠谱的方法是对着编译器的优化 case-by-case 写分析,或者寻求人工智能,处理这些需要靠猜来解决的问题。”
——All You Ever Wanted to Know About x86 x64 Binary Disassembly but Were Afraid to Ask

花指令的原理和识别

反汇编算法简介

常用的反汇编算法分为两类:线性扫描 (linear sweep) 和递归下降 (recursive decend)。

  • 线性扫描
    从入口开始,依次将每一条字节序列解析为指令,遇到分支指令不会递归进入分支。这种反汇编算法非常老实,但问题是:不是所有字节都是指令,因此中间会解析出一些花指令。
    在指令区域中找到数据并不罕见,尤其是在一些现代编译器的处理产物中。在指令之间插入只读数据的一个原因是,编译器必须遵循某些架构约束,以便使代码运行得更快。
    区分代码和数据非常困难。目前我们没有任何确定的算法能够正确识别出哪些被用作代码,哪些被用作数据。大多数分析工具实现了某些启发式算法来尝试区分指令和数据。

    Note

    反汇编任何数据,都可能产生看似有效的 x86 代码。
    ——Recognizing and Avoiding Disassembled Junk
    在二进制文件中区分代码和数据是一个完全不可解问题
    ——Disassembly Challenges

    伪代码:

    1
    2
    3
    4
    5
    6
    target = getFunctionAddress(main)
    targetEnd = getFunctionEnd(main)
    currentAddr = target
    while currentAddr < targetEnd:
    currentLen = disAsm(addr)
    currentAddr += currentLen
  • 递归下降
    从入口开始,依次将每一条字节序列解析为指令,遇到分支指令递归进入分支以优先反汇编可达的代码。

    关联资料

    What is the algorithm used in Recursive Traversal disassembly?
    IDA 使用的算法是线性扫描和递归下降的综合。

    递归下降是基于代码控制流分析的算法,可以在一定程度上模拟程序的实际执行流程。对于反汇编的指令,我们立即评估其语义能力。如果正在分析的指令改变了流程控制,我们将针对该指令目标和下一条指令启动反汇编。

欺骗算法

接下来介绍一些花指令的具体原理。

可变长度指令

x86 指令集的长度不是固定的。有些指令很短,只有 1 字节;有一些指令比较长,可以达到 5 字节。如果通过巧妙构造,引导反汇编引擎解析一条错误的指令,扰乱解析指令的长度,就能使反汇编引擎无法按照正确的指令长度依次解析邻接未解析的指令,最终使得反汇编引擎返回错误结果。

接下来看一个例子:

jmp 指令的机器码(称为 opcode)是 E9,语法为:0xE9 + 4 字节的偏移值(目标地址 - 当前地址 - 5),如 0xE9 0x00 0x00 0x00 0x00 即跳转到下一条指令。

这是一个简单的基于 jmp 的花指令:

1
2
3
4
5
6
0000 E9 01 00 00 00 jmp 0006
0005 0xE9 ...
0006 0x50 push eax
0007 0x53 push ebx
0008 0x5B pop ebx
0009 B8 56 34 12 00 mov eax,0x123456

正确的过程是,CPU 执行 E9 01 00 00 00 后直接跳到 0006 地址处继续执行,0005 的指令是不执行的。

但线性扫描反汇编在分析完了 0000 地址后会接着分析 0005 地址,这就是问题所在:反汇编引擎识别到 0005 处的 0xE9 时会想当然地分析为 jmp 指令。这种引擎分析出来的结果可能是这样的:

1
2
3
0000 E9 01 00 00 00 jmp 0006
0005 E9 50 53 5B B8 jmp ...
0010 56 34 12 00 ...

逻辑完全打乱了!

分支指令

前面说过,递归下降是基于控制流的算法,因此简单的 jmp 就无法搪塞过去了。但如果我们对控制流做了手脚呢?

je 是一个条件跳转指令,当反汇编引擎遇到该指令时,就会分别解析可能跳转的两个分支。

Note

IDA 没有判断分支条件是否满足的能力,因此必须同时分析两个分支。

例如这种花指令:

1
2
3
4
5
6
xor eax,eax
cmp eax,0
je ...
... ; junk code

... ; goal

xor 是异或运算,这里用于清零 eax,这样在正常执行流程中,cmp eax,0 是永真的,je 必定跳过花指令而执行目标指令。如果是编译器,在开启优化的情况下甚至可以把这里的花指令优化掉。

但是对于反汇编器,递归下降必须分析两条分支,即使 je 可能已经指定了目标。反汇编引擎可能会分析到错误的分支,导致无法继续分析。这对于所有有条件跳转均有效。

间接跳转

常见于 ARM 等定长指令集,通过多次跳转使得反汇编引擎无法识别真正代码的入口点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
04009A9 call $+5 ; use '$' to get current address. 'call' here is used as 'push'
04009AE pop rax ; pop current address from stack to rax
04009AF add rax,10
04009B1 jmp rax ; jump to 04009B8
04009B3 ;---
04009B5 db 0EBh,0EBh,0EBh
04009B8 ;---
04009B8 call $+5 ; use '$' to get current address. 'call' here is used as 'push'
04009BD pop rbx ; pop current address from stack to rax
04009BE add rbx,10
04009C2 jmp rbx ; jump to 04009C7
04009C2 ;---
04009C4 db 0EBh,0EBh,0EBh
04009C7 db 48h

这两段代码仅执行跳转操作。由于地址是在执行过程中动态计算的($),我们称这种代码为位置无关代码 (Position independent code, PIC),意味着可以独立地插入正常代码中的任意位置。

正常流程下,计算机可以计算出动态地址,因此间接跳转没有问题;但反汇编引擎不一样,静态分析是没法算动态地址的!因此分析出来的 jmp 跳转错乱,没有把有用的指令反汇编出来(如 04009C7 处应该是指令,却被识别为数据)。

指令复用

换句话说,指令跳转到自己的内部。

1
2
3
4
      loc_4009E0:
EB FF jmp short near ptr loc_4009E0+1 ; jump from EB to FF
;---
C0 FF C8 sar bh,0C8h

在正常过程中,计算机从 EB 跳到 FF,然后从 FF 开始解析指令:

1
2
3
4
5
; after jump
EB
;---
FF C0 inc eax
FF C8 dec eax

但静态反汇编难以 “重走回头路”,结果就是和实际结果又错位了!

Note

…… 我们碰到一个棘手的问题,所有给定字节都是多字节指令的一部分,而且它们都能够被执行。目前业内没有一个反汇编器能够将单个字节表示为两条指令的组成部分,然而处理器没有这种限制。
——《恶意代码分析实战》

破坏栈

栈平衡,简单来讲,就是函数调用结束时,要保证和在函数调用开始的堆栈是一致的(栈顶寄存器 esp 的值一致)。正常可运行的程序,栈必然平衡,否则会报错。

我们可以误导反汇编的静态分析,使之误认为栈未平衡。IDA 会提示 positive sp value has been detected, the output may be wrong!(如果错误点在当前区域,会在顶端注释;如果错误点不在当前区域,会弹窗并给出错误点的地址)。

例如:

1
2
3
4
5
6
7
xor eax,eax
jz s
add 0x11,esp ; computer does not exec this code!
ret

s:
; normal code

这本质上是一个 GOTO s,正常执行到 jz s 就会跳转到 s 处,但还记得反汇编引擎必须分析条件跳转的两个分支吗?于是就反汇编到 add 0x11,esp,一看下一行就返回了,但 esp 没平衡啊!于是报错。

Note

正常来讲,两个分支的栈都应该是平衡的。只要有一个分支出现栈不平衡,IDA 就会分析失败。

这里的花指令没有实际执行,下面看一个有执行的:

1
2
3
4
5
6
7
call next
next:
movl continue,esp
ret

continue:
; normal code

callret 等价于 push+jmppop+jmp,这里反汇编器会将 next 当成一个函数(其实它不是,我们称之为 “假函数”)。然后 continue 的地址也被压入 esp,返回后 continue 的地址出栈,控制流跳转到 continue 处。

next 呢?它可没有 “出栈”。于是汇编器懵圈了:理论上 ret 完 esp 就要归位,但现在却多了 next 的地址值!

花指令的一般处理

先确定实际函数的范围(入口和出口)并恢复函数。大部分情况下,剩下的花指令直接 nop 掉(花指令 Hex 字节全部修改为等长的 0x90 字节序列)即可。如果在 IDA 内修改,需要先将误识别的代码片段 Undefine 一下,把指令码露出来再重新分析。

主要靠动态调试观察执行流确定花指令。IDA 也会醒目标注出一些异常指令(JUMPOUTWARNING 等)。有时花指令太多不能一次性去除,就多试几遍。

Tip

花指令内部如果涉及到寄存器的使用,一般会将其保存在栈中。利用这个弱点,我们可以观察 sp 寄存器来判断花指令的入口和出口(pushpopcall

Note

花指令有很多模式,但一个显著特征是跳转,必须通过跳转指令来实现越过不可执行的花指令,或通过跳转来实现重新解释已经被解释过的指令的一部分,以及通过连续跳转来隐藏真实跳转地址。
—— 加壳原理笔记 07:花指令入门

一种简单的 nop 方法是:先分析花指令序列,得到花指令的 Hex 串(称为模式,pattern),然后通过十六进制编辑器搜索这个模式并替换为等长的 90 串。对于少量花指令,也可以在 IDA 内直接修改。

Warning

由于花指令不影响程序正常运行,因此 nop 完后的程序必然能运行起来。
如果花指令序列过短,一方面可能无法完全消除花指令,另一方面,也可能 nop 掉正常指令。

花指令的调试

为了加快分析速度,动态调试只需要调试(疑似)花指令片段即可,正常代码尽量不执行。

可以先在程序开头断点,然后调试时用 IDA 的 “Set IP” 修改 EIP/RIP 寄存器直接执行(疑似)花指令入口指令。


©2025-Present Watermelonabc | 萌 ICP 备 20251229 号

Powered by Hexo & Stellar 1.33.1 & Vercel & HUAWEI Cloud
您的访问数据将由 Vercel 和自托管的 Umami 进行隐私优先分析,以优化未来的访问体验

本博客总访问量:capoo-2

| 开往-友链接力 | 异次元之旅

中文独立博客列表 | 博客录 随机博客

AI 参与指数(IIIA)2 级

猫猫🐱 发表了 53 篇文章 · 总计 223.8k 字