- 献词
- 致谢
- 前言
- 第一部分 IDA 简介
- 第 1 章 反汇编简介
- 第 2 章 逆向与反汇编工具
- 第 3 章 IDA Pro 背景知识
- 第二部分 IDA 基本用法
- 第 4 章 IDA 入门
- 第 5 章 IDA 数据显示窗口
- 第 6 章 反汇编导航
- 第 7 章 反汇编操作
- 第 8 章 数据类型与数据结构
- 第 9 章 交叉引用与绘图功能
- 第 10 章 IDA 的多种面孔
- 第三部分 IDA 高级应用
- 第 11 章 定制 IDA
- 第 12 章 使用 FLIRT 签名来识别库
- 第 13 章 扩展 IDA 的知识
- 第 14 章 修补二进制文件及其他 IDA 限制
- 第四部分 扩展 IDA 的功能
- 第 15 章 编写 IDA 脚本
- 第 16 章 IDA 软件开发工具包
- 第 17 章 IDA 插件体系结构
- 第 18 章 二进制文件与 IDA 加载器模块
- 第 19 章 IDA 处理器模块
- 第五部分 实际应用
- 第 20 章 编译器变体
- 第 21 章 模糊代码分析
- 第 22 章 漏洞分析
- 第 23 章 实用 IDA 插件
- 第六部分 IDA 调试器
- 第 24 章 IDA 调试器
- 第 25 章 反汇编器/ 调试器集成
- 第 26 章 其他调试功能
- 附录 A 使用 IDA 免费版本 5.0
- 附录 B IDC/SDK 交叉引用
21.3 使用 IDA 对二进制文件进行“静态去模糊”
到现在为止,你可能会感到困惑,有了所有这些反逆向工程技巧,我们该如何分析程序员希望使其保持隐秘的软件呢?由于这些技巧同时针对静态分析工具和动态分析工具,要揭示一个程序的隐藏行为,什么才是最佳办法呢?遗憾的是,能够满足一切需求的解决方案并不存在。许多时候,解决方案取决于你掌握的技能以及你使用的工具。如果你选择的分析工具是调试器,那么你需要制订策略,避开调试器检测和预防保护。如果你的首选分析工具是反汇编器,那么你需要制订策略,获得一个准确的反汇编代码清单;如果遇到自修改代码,你还需要模拟这段代码的行为,以便正确更新反汇编代码清单。
在这一节中,我们将讨论两种在静态分析环境(也就是说,不运行代码)中处理自修改代码的技巧。在使用调试器控制一个程序时,如果你不愿(因为恶意代码)或无法(因为缺少硬件)分析这个程序,这时静态分析可能是你的唯一选择。
21.3.1 面向脚本的去模糊
因为 IDA 可用于反汇编为各种 CPU 开发的二进制文件,因此,你常常需要分析一个为截然不同的平台(而非你运行 IDA 的平台)开发的二进制文件。例如,你可能需要分析一个 Linux x86 二进制文件,即使你碰巧运行的是 Windows 版本的 IDA ,或者你可能需要分析一个 MIPS 或 ARM 二进制文件,即使 IDA 仅在 x86 平台上运行。这时候,你也许无法获得适合对提供给你的二进制文件执行动态分析的动态分析工具,如调试器。而且,如果通过对程序的组成部分进行编码来模糊处理这个二进制文件,那么你可能别无选择,只有创建一个 IDA 脚本,模拟程序的去模糊过程,以正确对程序进行解码,并正确反汇编解码后的指令和数据。
这似乎是一个烦琐的任务,但许多时候,在模糊程序的解码阶段,你只需要利用处理器的一小部分指令集。因此,掌握必要的操作,并不要求你理解目标 CPU 的整个指令集。
在第 15 章中,我们提供了一个算法,用于开发脚本,模拟一个程序各种部分的行为。在下面的例子中,我们将利用那些步骤开发一个简单的 IDC 脚本,解码一个已经使用 Burneye ELF 加密工具加密的程序。示例程序从代码清单 21-2 中的指令开始执行。
代码清单 21-2 Burneye 启动顺序和模糊代码
LOAD:05371035 start proc near LOAD:05371035 ➊ LOAD:05371035 push off_5371008 ➋ LOAD:0537103B pushf ➌ LOAD:0537103C pusha ➍ LOAD:0537103D mov ecx, dword_5371000 LOAD:05371043 jmp loc_5371082 ... LOAD:05371082 loc_5371082: ; CODE XREF: start+E ↑ j ➎ LOAD:05371082 call sub_5371048 LOAD:05371087 sal byte ptr [ebx-2Bh], 1 LOAD:0537108A pushf LOAD:0537108B xchg al, [edx-11h] LOAD:0537108E pop ss LOAD:0537108F xchg eax, esp LOAD:05371090 cwde LOAD:05371091 aad 8Eh LOAD:05371093 push ecx ➏ LOAD:05371094 out dx, eax LOAD:05371095 add [edx-57E411A0h], bh LOAD:0537109B push ss LOAD:0537109C rcr dword ptr [esi+0Ch], cl LOAD:0537109F push cs LOAD:053710A0 sub al, 70h LOAD:053710A2 cmp ch, [eax+6Eh] LOAD:053710A5 cmp dword ptr ds:0CBD35372h, 9C38A8BCh LOAD:053710AF and al, 0F4h ➐ LOAD:053710B1 db 67h
这个程序首先将内存位置 05371008h
的内容压入栈(➊),然后压入 CPU 标志(➋),接下来压入所有 CPU 寄存器(➌)。起初,这些指令的目的并不明显,因此,我们把这些信息记录下来以备后用。下一步,ECX 寄存器将与内存位置 5371000h
的内容一同加载(➍)。根据在第 15 章中介绍的算法,这时我们需要声明一个名为 ecx
的变量,并使用 IDC 的 Dword
函数对它进行初始化,如下所示:
auto ecx; ecx = Dword(0x5371000); //from instruction 0537103D
在一个绝对跳转之后,程序调用函数 sub_5371048
(➎),这个操作会产生一个副作用:将地址 05371087h
(返回地址)压入栈。注意, call
指令之后经过反汇编的指令变得越来越没有意义。通常,在用户空间代码中并不会看到 out
指令(➏),而 IDA 也无法反汇编地址 053710B1h
(➐)处的一条指令。这些都说明这个二进制文件存在问题(而且事实上“函数”窗口中仅列出两个函数)。
这时,分析需要从函数 sub_5371048
处继续进行,如代码清单 21-3 所示。
代码清单 21-3 主要的 Burneye 解码函数
LOAD:05371048 sub_5371048 proc near ; CODE XREF: start:loc_5371082↓p ➊ LOAD:05371048 pop esi ➋ LOAD:05371049 mov edi, esi ➌ LOAD:0537104B mov ebx, dword_5371004 LOAD:05371051 or ebx, ebx ➍ LOAD:05371053 jz loc_537107F ➎ LOAD:05371059 xor edx, edx ➏ LOAD:0537105B loc_537105B: ; CODE XREF: sub_5371048+35↓j LOAD:0537105B mov eax, 8 ➐ LOAD:05371060 loc_5371060: ; CODE XREF: sub_5371048+2B↓j LOAD:05371060 shrd edx, ebx, 1 LOAD:05371064 shr ebx, 1 LOAD:05371066 jnb loc_5371072 LOAD:0537106C xor ebx, 0C0000057h LOAD:05371072 loc_5371072: ; CODE XREF: sub_5371048+1E ↑j LOAD:05371072 dec eax LOAD:05371073 jnz short loc_5371060 LOAD:05371075 shr edx, 18h LOAD:05371078 lodsb LOAD:05371079 xor al, dl LOAD:0537107B stosb LOAD:0537107C dec ecx LOAD:0537107D jnz short loc_537105B LOAD:0537107F loc_537107F: ; CODE XREF: sub_5371048+B↑j LOAD:0537107F popa LOAD:05371080 popf LOAD:05371081 retn
经过仔细分析,我们发现,这并不是一个常见的函数,因为它一开始就将返回地址弹出栈,放入 ESI 寄存器中(➊)。如前所述,保存的返回地址为 05371087h
,考虑到 EDI(➋)、EBX (➌)和 EDX(➍)的初始化,我们得到如下脚本:
auto ecx, esi, edi, ebx, edx; ecx = Dword(0x5371000); //from instruction 0537103D esi = 0x05371087; //from instruction 05371048 edi = esi; //from instruction 05371049 ebx = Dword(0x5371004); //from instruction 0537104B edx = 0; //from instruction 05371059
在这些初始化之后,函数对包含在 EBX 寄存器中的值进行测试(➎),然后进入一个外层循环(➏)和一个内层循环(➐)。这个函数的剩余逻辑包含在下面的完整脚本中。在这段脚本内,注释用于将脚本操作与前面一个反汇编代码清单中对应的操作关联起来。
auto ecx, esi, edi, ebx, edx, eax, cf; ecx = Dword(0x5371000); //from instruction 0537103D esi = 0x05371087; //from instruction 05371048 edi = esi; //from instruction 05371049 ebx = Dword(0x5371004); //from instruction 0537104B if (ebx != 0) { //from instructions 05371051 and 05371053 edx = 0; //from instruction 05371059 do { eax = 8; //from instruction 0537105B do { //IDC does not offer an equivalent of the x86 shrd instruction so we //need to derive the behavior using several operations ➊ edx = (edx >> 1) & 0x7FFFFFFF; //perform unsigned shift right one bit cf = ebx & 1; //remember the low bit of ebx if (cf == 1) { //cf represents the x86 carry flag edx = edx | 0x80000000; //shift in the low bit of ebx if it is 1 } ebx = (ebx >> 1) & 0x7FFFFFFF; //perform unsigned shift right one bit if (cf == 1) { //from instruction 05371066 ebx = ebx ^ 0xC0000057; //from instruction 0537106C } eax--; //from instruction 05371072 } while (eax != 0); //from instruction 05371073 ➋ edx = (edx >> 24) & 0xFF; //perform unsigned shift right 24 bits ➌ eax = Byte(esi++); //from instruction 05371078 eax = eax ^ edx; //from instruction 05371079 ➍ PatchByte(edi++, eax); //from instruction 0537107B ecx--; //from instruction 0537107C } while (ecx != 0); //from instruction 0537107D }
这个例子有两个细微的变化。首先,IDC 中的右移位运算符(>>)执行有符号移位(表示符号位被复制到最高有效位中),而 x86 shr
和 shrd
指令执行无符号移位。为了模拟 IDC 中的一个无符号右移位,我们必须清除从左边移入的所有位,如➊和➋所示。其次,为了正确执行 x86 lodsb
(加载字符串字节)和 stosb
(存储字符串字节)指令,我们需要选择合适的数据大小和变量。这些指令在 EAX 寄存器的低 8 位1 中读取( lodsb
)和写入( stosb
)数据,让较高的 24 位保持不变。在 IDC 中,我们没有办法将一个变量划分成很小的部分,除非使用各种按位运算屏蔽并重新组合这个变量的各个部分。具体来说,就 odsb
指令而言,有一个更加可信的模拟,如下所示:
1. EAX 寄存器的低 8 位也叫做 AL 寄存器。
eax = (eax & 0xFFFFFF00) | (Byte(esi++) & 0xFF);
这个例子先清除 EAX 变量的低 8 位,然后使用一个 OR
运算合并低 8 位中的新值。在 Burneye 解码示例中,我们注意到一个事实:在每个外层循环开始时,整个 EAX 寄存器被设置为 8,这样做会将 EAX 的高 24 位清零。因此,我们选择忽略它对 EAX 高 24 位的赋值效果,以简化 lodsb
的实现(➌)。同时,我们不需要考虑 stosb
的实现(➍),因为 PatchByte
函数仅读取它的输入值(这里为 EAX)的低 8 位。
执行 Burneye 解码 IDC 脚本后,我们的数据库将反映出所有变化。正常情况下,除非模糊程序在 Linux 系统上运行,否则这些变化将不可见。如果去模糊过程得以正确执行,我们很可能会在 IDA 的“字符串”窗口中看到许多更具可读性的字符串。为了观察这一事实,需要关闭并重新打开“字符串”窗口,或者在窗口中右击光标,选择 Setup,然后单击 OK,刷新这个窗口的内容。这两个操作都会使 IDA 重新扫描数据库,从中搜索字符串内容。
剩下的任务包括:如果返回函数在它的第一条指令中就弹出返回地址,确定它将在什么地方返回;使 IDA 根据情况将解码后的字节值正确显示为指令或数据。Burneye 解码函数的最后 3 条指令如下所示:
LOAD:0537107F popa LOAD:05371080 popf LOAD:05371081 retn
如前所述,这个函数首先弹出它的返回地址,这意味着剩余的栈值由调用方设置。这里使用的 popa
和 popf
指令与 Burneye 的启动例程开始部分使用的 pusha
和 pushf
指令对应,如下所示:
LOAD:05371035 start proc near LOAD:05371035 ➊ LOAD:05371035 push off_5371008 LOAD:0537103B pushf LOAD:0537103C pusha
结果栈上剩下的唯一一个值是在 start
的第一行代码(➊)中压入的地址。Burneye 解码例程会返回到这个地址,深入分析 Burneye 保护的二进制文件也需要从这个地址继续。
从前面的例子来看,编写一段脚本解码或解压模糊二进制文件,似乎是一个相对容易的事情。就 Burneye 而言,情况确实如此,因为 Burneye 并没有使用特别复杂的模糊算法。但是,使用 IDC 执行更加复杂的实用工具(如 ASPack 和 tElock )的去模糊存根,你可能需要付出更大的努力。
基于脚本的去模糊的优点包括:你根本不需要执行你所分析的二进制文件;不需要完全了解用于去模糊二进制文件的具体算法,你就可以创建一个有效的脚本。后一个说法似乎有些矛盾,因为你只有完全理解去模糊算法,才能使用一个脚本模拟这个算法。但是,利用这里和第 15 章描述的脚本开发流程,你只需要完全理解去模糊过程使用的每一条 CPU 指令。通过使用 IDC 忠实地执行每一项 CPU 操作,并且根据反汇编代码清单排定每项操作的正确顺序,你将有一段能够模拟程序操作的脚本,即使你并不完全理解这些操作作为整体执行的高级算法。
使用基于脚本的方法的缺点在于,你编写的脚本往往相当死板。如果由于去模糊工具升级,或者由于去模糊工具使用了其他的命令行设置,那么,之前对这个工具有效的脚本可能需要进行相应的修改。例如,你可以开发出一个通用解压脚本,供使用 UPX 打包的二进制文件使用2 ,但是,随着 UPX 不断升级,你需要对这段脚本持续调整。
2. 相关示例参见 http://www.idabook.com/examples/chapter21 。
最后,使用基于脚本的去模糊方法无法构建“万能药”式的去模糊解决方案。没有任何一个脚本能够对所有的二进制文件去模糊。从某个角度说,基于脚本的去模糊方法与基于签名的入侵检测和反病毒系统有许多相同的缺点。你必须为每一个新型包装器开发一个新的脚本,现有包装器的任何细微变化都可能会使现有的脚本失效。
21.3.2 面向模拟的去模糊
在创建脚本执行去模糊任务时,我们总是需要模拟一个 CPU 的指令集,以与被去模糊的程序在行为上保持完全一致。如果我们有一个指令模拟器,那么我们可以将这些脚本执行的一些或全部工作转交给模拟器完成,从而大大缩短对一个 IDA 数据库去模糊所需的时间。模拟器能够填补脚本与调试器之间的空白,它不但比脚本更加高效,而且比调试器更加灵活。例如,使用模拟器,我们可以模拟一个在 x86 平台上运行的 MIPS 二进制文件,或者模拟一个在 Windows 平台上运行的 Linux ELF 二进制文件的指令。
模拟器的复杂程度各不相同。不过,模拟器至少需要一些指令字节和足够的内存,专门供栈操作和 CPU 寄存器使用。更加复杂的模拟器则可以利用模拟化的硬件设备和操作系统服务。
IDA 并不提供本地模拟工具3 ,但是,它的插件体系结构功能相当强大,能够创建模拟器类型的插件。实现这类模拟器的一种方法是将 IDA 数据库作为碰巧包含我们希望模拟的二进制文件(通过加载器模块的帮助)的虚拟内存处理。模拟器插件所需要做的是提供少量内存,跟踪所有 CPU 寄存器的状态,同时提供较大数量的内存,同时提供某种用于实施栈的方法。实施栈的一种方法是在映射到适合栈的位置的数据库中创建一个新段。模拟器通过从模拟器指令指针的当前值指定的数据库位置读取字节,并根据被模拟 CPU 的指令集规范解码读取到的值,同时更新任何受已解码的指令影响的内存值,从而执行它的操作。可能的更新包括修改模拟的注册表值,将这些值存储到模拟的栈内存空间中,或者根据已解码的指令生成的内存地址,将经过修改的值补缀到 IDA 数据库的数据或代码段。控制模拟器的方式与控制调试器类似,因为你同样可以逐步执行每条指令,检查内存,修改寄存器以及设置断点。程序内存空间中的内容将在 IDA 的反汇编代码清单和十六进制窗口中显示,而模拟器需要为 CPU 寄存器生成自己的显示。
3. IDA 自带有能够通过 IDA 的调试界面与开源 Bochs 模拟器交互的插件。有关详细信息,参见第 24 章至第 26 章。
使用这样的模拟器,我们可以在程序的入口点开始模拟,并逐步模拟去模糊阶段的所有指令,从而对一个模糊程序进行去模糊处理。因为这种模拟器将数据库作为它的备用存储器,因此,所有自修改将立即在数据库中反映出来。到去模糊过程完成时,数据库将被正确转换成程序的去模糊版本,就好像程序一直在调试器的控制下运行一样。与调试相比,模拟的一个明显优点在于模拟器绝不会执行潜在恶意的代码,而调试器辅助的去模糊必须至少执行恶意程序的某个部分,才能获得该程序的去模糊版本。
ida-x86emu (x86emu)插件(见表 21-1 )就是一个这样的模拟器插件,可用于模拟大部分的 x86 指令集。这个插件为开源插件,并使用自 4.9 版以来的所有 IDA SDK 版本构建。这个插件适用于 IDA 所有版本的二进制版本包含在 x86emu 发行版中。这个插件供 Windows GUI 版本的 IDA 或 Qt 版本的 IDA 使用,同时提供构建脚本,允许用户使用 MinGw(g++/make)或微软(Visual Studio 2008 )工具构建相应的插件。该插件的 Qt 版本与 Linux 版本的 IDA 和 OS X 版本的 IDA 兼容。除了与你的 IDA 版本对应的 SDK 外,使用这个插件没有其他别的要求。要安装这个插件,可以将已编译的插件二进制文件(x86emu.plw/x86emu-qt.plw)复制到/plugins 目录中。
表 21-1 ida-x86emu 插件
名称 | ida-x86emu |
---|---|
作者 | Chirs Eagle |
发布 | 用于 SDK v6.1 的源代码及用于自 5.0 以来的所有 IDA 版本(包括 IDA 免费版本)的二进制文件。源代码向后兼容到 SDK 4.9 版 |
价格 | 免费 |
说明 | IDA 嵌入式 x86 指令模拟器 |
信息 | http://www.idabook.com/ida-x86emu |
你不需要进行插件配置,x86emu 插件默认使用 ALT+F8 热键组合激活。你只能对使用 x86 处理器的二进制文件激活这个插件。这个插件可用于任何类型的二进制文件,如 PE、ELF 和 Mach-O。使用第 17 章讨论的工具(Visual Studio 或 MinGW 的 gcc 和 make),我们可以由源代码构建这个插件。
1. x86emu 初始化
激活 x86emu 插件后,该插件的控制对话框将显示出来,如图 21-4 所示。对话框的基本显示包括寄存器内容,还有控制按钮,用于执行简单的模拟任务,如控制模拟器或修改数据值。
图 21-4 x86emu 模拟器控制对话框
一旦激活后,插件将执行许多其他操作。模拟器将为所有文件类型创建名为 .stack
和 .heap
的新数据库段,为模拟程序的操作提供运行时内存支持。在某个二进制文件中第一次激活插件时,当前的光标位置用于初始化指令指针( EIP
)。对于 Windows PE 二进制文件,该插件执行以下任务。
创建另外一个名为
.headers
的程序段,重新读取输入的二进制文件,然后将 MS-DOS 和 PE 头部字节加载到数据库中。分配内存,模拟一个线程环境块(TEB )和一个进程环境块(PEB )。用合理的值填充这些结构,让被模拟的程序确信,它在真正的 Windows 环境中运行。
为 x86 段寄存器分配合理的值,配置一个虚假的中断描述符表,提供最小的异常处理功能。
尝试在 PE 文件的导入目录中定位所有被引用的 DLL 。对于每一个被发现的 DLL ,模拟器将在数据库中为它们创建额外的段,并加载该 DLL 的头部和导出目录。然后,用从已加载 DLL 的信息中获得的函数地址填充二进制文件的导入表。注意,已导入的 DLL 中没有任何代码被加载到数据库中。
每次保存或关闭数据库时,插件的当前状态(寄存器值)被保存在一个网络节点中。其他内存状态(如栈值和堆值)也被保存下来,因为这些值存储在数据库的专用段内。随后激活插件时,将从现有的网络节点数据恢复模拟器状态。
2. 基本的 x86emu 操作
模拟器控制对话框专门提供与基本调试器非常类似的功能。在你想要修改的寄存器的编辑框中输入一个新值,即可修改 CPU 寄存器的内容。
Step 按钮用于模拟单独一条指令。从 EIP 寄存器指定的数据库位置读取一个或数个字节,并执行这些指令字节指定的操作,即可模拟一条指令。在必要时,寄存器显示的值会更新,以反映因为模拟当前指令而导致的变化。每次单击 Step 按钮,模拟器一定会以代码(而非数据)显示 EIP 指定的地址处的字节。这一特性有助于阻止指令流中的任何去同步操作。此外,模拟器会使反汇编显示窗口跳转到 EIP 指定的位置,以跟踪每一条被模拟的指令。
Run to cursor 按钮用于模拟连续的几条指令。模拟从当前 EIP 位置开始,直到到达一个断点或 EIP 等于当前光标位置时结束。模拟器用于识别通过 IDA 的调试器界面(右击指定的地址并选择 Add breakpoint)或模拟器自己的断点界面(Emulate ▶Set Breakpoint)设置的断点集。
x86emu 断点
模拟器并不使用
int 3
指令之类的硬件调试寄存器或软件中断。模拟器维护一个内部断点列表,在模拟每一条指令之前,模拟器会将被模拟的指令的指针与列表中的断点比较。虽然这种方法似乎较为低效,但它比一般的模拟高效,而且它具有一个优点,即被模拟的程序无法检测到也无法修改模拟器断点。
选择 Run to cursor 按钮后,模拟器并不会暂停以为每一条获取的指令重新格式化反汇编代码清单。它只格式化第一条和最后一条被执行的指令。对于较长的指令序列,重新格式化每条指令的反汇编代码清单所导致的开销将使模拟器的性能低得令人难以忍受。因此,在使用 Run To Cursor 命令时,你应当十分小心,因为在 EIP 到达光标位置之前,你将无法重新控制模拟器(和 IDA )。如果由于某种原因,执行从未触发断点或到达光标位置,你可能需要强制终止 IDA ,这可能会导致你之前所做的重要工作被白白浪费。
Skip 按钮用于使模拟器略过一条指令,而不模拟这条指令。例如,你可以使用 Skip 命令忽略一个条件跳转而到达一个特定的代码块,不顾任何条件标志的状态。Skip 还可用于略过函数调用,如导入的库函数,因为你无法模拟它的代码。如果你选择略过某个函数调用,请确保对数据库进行更新,以反映该函数可能做出的任何更改。此类更改的示例包括:修改 EAX 的值以反映所需的函数返回值,或者填充其地址已传递给函数的缓冲区。此外,如果被略过的函数使用 stdcall
调用约定,你还应当根据被略过的函数在返回时从栈中清除的字节数,小心对 ESP 进行手动调整。
Jump to cursor 按钮将使 EIP 更新为当前光标所在位置的地址。这个特性可用于忽略整个代码块。如果 CPU 标志的状态不会对跳转造成影响,该特性还可用于跟踪一个条件跳转。记住,在一个函数内跳转可能会影响栈布局(例如,如果你忽略压入操作或栈指针调整),导致无法预料的后果。注意,模拟器并没有必要从一个程序的入口点开始模拟。你完全可以使用模拟器模拟二进制文件中的一个函数,以研究该函数的行为。这也是我们提供 Jump to cursor 按钮的目的之一。使用这个按钮,你可以轻松确定二进制文件中的模拟目标。
Run 按钮的功能与 Run to cursor 按钮的功能类似。但是,它更加危险,因为在到达一个断点之前,执行不会停止。因此,如果你选择使用这个命令,你应当完全确定执行会到达某个断点。
Segments 按钮用于访问 x86 段寄存器和段基址的配置。段配置对话框如图 21-5 所示,你可以通过它修改与段有关的值。
图 21-5 x86emu 段寄存器配置
虽然模拟器的地址计算取决于你提供的基址值,但是,模拟器当前并不能完全模拟 x86 全局描述符表(GDT)。
单击 Set Memory 按钮,将显示如图 21-6 所示的基本内存修改对话框。
图 21-6 x86emu 内存修改对话框
基本上,这个对话框是一些 SDK PatchXXX
函数的包装器。插入到数据库中的数据的类型通过对话框提供的单选按钮进行选择,而具体的数据则输入到对话框提供的编辑框中。如果选择 Load from file 按钮,用户将看到一个标准的打开文件对话框,选择一个文件后,这个文件将从指定地址开始把内容传送到数据库中。
Push data 按钮用于将数据值放入被模拟的程序栈的顶部。生成的对话框如图 21-7 所示,你可以通过它指定将要压入栈的一个或几个数据项。
图 21-7 x86emu 栈数据对话框
模拟器当前仅接受数值数据。提供的值以一次 4 字节的方式,按从右至左的顺序压入栈中,就好像它们是一个函数调用的参数。栈指针的值将根据被压入栈中的值的数量进行调整。这个对话框的主要用途是在直接跳转到将要模拟的函数之前,对函数的参数进行配置。这样,用户不需要找到函数的具体执行路径,即可模拟这个函数。
3. 模拟器辅助的去模糊
接下来,我们将讨论将 x86emu 插件作为一个去模糊工具。首先回到开发了一个完整的 IDC 脚本的 Burneye 示例。假设我们之前并不知道 Burneye 解码算法,去模糊过程如下所示。
打开 Burneye 保护的二进制文件。光标应自动位于
start
入口点处。激活模拟器(ALT+F8),图 21-4 所示的对话框将显示模拟器的结果状态。开始单步执行模拟器,请特别注意将要模拟的指令。6 步以后,模拟器将到达函数
sub_5371048
处(参见代码清单 21-3 )。这个函数的结构似乎相当完整。我们可以选择继续单步执行模拟器,以便更完整地了解该函数的执行流。我们也可以选择对这个函数进行一段时间的研究,确定将光标放置在该函数的
return
语句上并单击 Run to cursor 按钮是否安全。我们选择后一种情况,将光标放在地址0537108h
处,并单击 Run to cursor 按钮。至此去模糊已经完成。单步执行模拟器,再执行
return
语句两次,返回新的去模糊后的代码,使 IDA 将去模糊后的字节重新格式化为指令。
得到的去模糊代码如下所示:
LOAD:05371082 loc_5371082: ; CODE XREF: start+E↑ j LOAD:05371082 call sub_5371048 LOAD:05371082 ; -------------------------------------------------------------- LOAD:05371087 db 0 LOAD:05371088 db 0 LOAD:05371089 db 0 LOAD:0537108A db 0 LOAD:0537108B db 0 LOAD:0537108C db 0 LOAD:0537108D db 0 LOAD:0537108E db 0 LOAD:0537108F db 0 LOAD:05371090 ; -------------------------------------------------------------- LOAD:05371090 LOAD:05371090 loc_5371090: ; DATA XREF: LOAD:off_5371008↑ o ➊ LOAD:05371090 pushf LOAD:05371091 pop ebx LOAD:05371092 mov esi, esp LOAD:05371094 call sub_5371117 LOAD:05371099 mov ebp, edx LOAD:0537109B cmp ecx, 20h LOAD:0537109E jl loc_53710AB LOAD:053710A4 xor eax, eax LOAD:053710A6 jmp loc_53710B5
将这个代码段与代码清单 21-2 比较,很明显可以看到,指令在去模糊过程中发生了变化。完成初步的去模糊后,程序从 loc_5371090
地址处的 pushf
指令(➊)继续执行。
很明显,模拟器辅助的去模糊要比前面讨论的面向脚本的去模糊过程更加简单。花时间开发模拟器,你得到一个高度灵活的去模糊方法,而花时间编写一个特定于 Burneye 的脚本,你得到一个非常专一化的脚本,在其他去模糊情形中,这个脚本没有多大用处。
注意,虽然在前一个例子中,Burneye 保护的二进制文件是一个 Linux ELF 二进制文件,但是,x86emu 仍然能够模拟这个文件中的指令,因为它们全都是 x86 指令,而不论它们来自什么操作系统,属于何种文件类型。x86emu 可直接用于 Windows PE 二进制文件,如本章前面讨论的 UPX 示例。目前绝大多数的模糊恶意软件都以 Windows 平台为攻击对象,因此,x86emu 提供了许多特定于 Windows PE 二进制文件的功能(如前所述)。
使用模拟器解压 UPX 二进制文件非常简单。首先,在启动模拟器时,光标应放置在程序的入口点( start
)。然后,再将光标移到 UPX 导入表的第一条指令上,并重建循环(代码清单 21-1 的地址 0040886Ch
处),使模拟器能够运行 Run to Cursor 命令。这时,二进制文件已经被解压,“字符串”窗口可用于查看所有解压后的库和函数的名称,UPX 将用它们构建程序的导入表。如果模拟器逐步模拟代码清单 21-1 中的代码,最终它将遇到下面的函数调用:
UPX1:00408882 call dword ptr [esi+808Ch]
模拟这类指令可能非常危险,因为一开始,你并不清楚这个指令指向什么地方(表示 call
指令的目标地址并不明显)。一般而言,函数调用可能指向两个地方:程序代码( .text
)段内的一个函数,或者程序所使用的共享库中的一个函数。任何时候遇到 call
指令,模拟器将确定目标地址是否位于被分析的文件的虚拟地址空间之内,或者目标地址是否与所分析二进制文件已加载的一个库导出的函数有关。如前所述,模拟器会加载它所分析的二进制文件加载的所有库的导出目录。如果模拟器确定调用指令的目标地址在该二进制文件的边界以外,模拟器将扫描加载到数据库中的导出表,以确定被调用的库函数。对于 Windows PE 文件,模拟器为表 21-2 中列出的函数提供了模拟实现。
如果模拟器确定其中的一个函数被调用,它将从程序栈中读取任何参数,要么执行和该函数相同的操作(如果程序正在运行),或者执行某个最低限度的操作,生成一个在被模拟的程序看来是正确的返回地址。对于使用 stdcall
调用约定的函数,在完成被模拟的函数之前,模拟器还会删除任何栈参数。
表 21-2 被 x86emu 模拟的函数
CheckRemoteDebuggerPresent | GetTickCount | LocalFree | VirtualAlloc |
CreateThread | GetVersion | NtQuerySystemInformation | VirtualFree |
GetCurrentThreadId | HeapAlloc | NtQueryInformationProcess | calloc |
GetCurrentProcess | HeapCreate | NtSetInformationThread | free |
GetCurrentProcessId | HeapDestroy | RtlAllocateHeap | lstrcat |
GetModuleHandleA | HeapFree | TlsAlloc | lstrcpy |
GetProcAddress | IsDebuggerPresent | TlsFree | lstrlen |
GetProcessHeap | LoadLibraryA | TlsGetValue | malloc |
GetThreadContext | LocalAlloc | TlsSetValue | realloc |
模拟与堆有关的函数的行为,将会使模拟器操纵它的内部堆实现(由 .heap
节实现),并返回一个适用于被模拟函数的值。例如, HeapAlloc
的模拟版本返回的值是一个适合被模拟的程序写入数据的地址。调用 VirtualAlloc
的模拟版本时,将在数据库中创建一个新节,用于表示新映射的虚拟地址空间。 IsDebuggerPresent
的模拟版本总是返回假。在模拟 LoadLibraryA
时,模拟器会通过检查向 LoadLibraryA
提供的栈参数,提取出它所加载的库的名称。然后,模拟器尝试在本地系统上打开这个库,使这个库的导出表能够加载到数据库中。最后,模拟器会向调用方返回一个合适的库句柄4 值。当拦截到对 GetProcAddress
的调用时,模拟器会检查栈上的参数,确定被引用的共享库。然后,模拟器解析这个库的导出表,计算出 GetProcAddress
的正确内存地址。最后,模拟版本的 GetProcAddress
函数将函数地址返回给调用方。对 LoadLibraryA
和 GetProcAddress
的调用将在 IDA 的“输出”窗口中显示。
4. 一个 Windows 库句柄仅识别 Windows 进程中的一个库。库句柄实际上是一个基址,库在这个位置被加载到内存中。
调用 x86emu 不为其提供内部模拟的函数时,将显示与图 21-8 类似的对话框。
图 21-8 x86emu 库函数对话框
知道了被调用函数的名称,模拟器将查询 IDA 的类型库信息,获取该函数所需的参数的数量和类型。然后,模拟器深入挖掘程序栈,显示已经传递给该函数的所有参数及其类型和正式名称。参数类型和名称只有在 IDA 提供相关的类型信息时才能正确显示。用户可利用这个对话框指定一个返回值,以及该函数使用的调用约定(这些信息可能由 IDA 提供)。如果选择 tdcall 用约定,用户应指出,在调用完成时,应从栈上删除多少个参数(而非字节)。在模拟函数调用时,模拟器需要这些信息来维持执行栈的完整性。
回到前面的 UPX 去模糊示例,让模拟器完成导入表重建循环,我们发现,模拟器在 IDA 的“输出”窗口中生成以下输出:
x86emu: LoadLibrary called: KERNEL32.DLL (7C800000) x86emu: GetProcAddress called: ExitProcess (0x7C81CDDA) x86emu: GetProcAddress called: ExitThread (0x7C80C058) x86emu: GetProcAddress called: GetCurrentProcess (0x7C80DDF5) x86emu: GetProcAddress called: GetCurrentThread (0x7C8098EB) x86emu: GetProcAddress called: GetFileSize (0x7C810A77) x86emu: GetProcAddress called: GetModuleHandleA (0x7C80B6A1) x86emu: GetProcAddress called: CloseHandle (0x7C809B47)
这个输出记录了模糊二进制文件加载的库,以及这些库中被模糊程序查找的函数5 。如果以这种方式查找函数地址,这些地址通常保存在一个数组中(这个数组是程序的导入表),以方便随后使用。
5. 只要程序已经使用 GetProcAddress
找到某个函数的地址,随后,该程序可以使用返回的地址随时调用这个函数。以这种方式查找函数地址,既免去了在构建时显式链接函数的需要,也减少了 dumpbin 等静态分析工具能够提取到的信息量。
去模糊后的程序存在一个基本问题,即它们缺乏符号表信息,而没有经过模糊处理的二进制文件往往包含这些信息。如果一个二进制文件的导入表完好无损,IDA 的 PE 加载器将根据它在运行时将包含其地址的函数的名称,为导入表中的每个条目命名。如果遇到一个模糊二进制文件,对每一个存储函数地址的位置应用函数名称将会有好处。就 UPX 而言,下面摘自代码清单 21-1 的几行代码说明了函数地址在每次经历函数查找循环时如何保存到内存中:
UPX1:00408897 call dword ptr [esi+8090h] ; GetProcAddress UPX1:0040889D or eax, eax UPX1:0040889F jz short loc_4088A8 ➊ UPX1:004088A1 mov [ebx], eax ; Save to import table UPX1:004088A3 add ebx, 4
地址 004088A1h
处的指令(➊)负责将函数地址存储到重建后的导入表中。x86emu 提供一种自动工具,只要 x86emu 识别一个这样的指令,该工具将命名导入表中的每个条目。模拟器称这样的指令为“导入地址保存点”(import address save point),你可以使用 Emulate▶Windows▶Set Import Address Save Point (模拟▶窗口▶设置导入地址保存点)菜单将一个地址指定为“导入地址保存点”。为了使这一功能生效,你必须在模拟指令之前进行指定。完成指定后,每次模拟这条指令,模拟器将进行一次查找,确定被写入的数据代表哪一个函数,然后使用这个导入函数的名称命名被写入的地址。在 UPX 示例中,若没有指定一个“导入地址保存点”,将得到下面的导入表(部分显示):
UPX0:00406270 dd 7C81CDDAh UPX0:00406274 dd 7C80C058h UPX0:00406278 dd 7C80DDF5h UPX0:0040627C dd 7C8098EBh
但是,在指定“导入地址保存点”时,x86emu 工具执行的自动命名将产生以下自动生成的导入表(部分显示):
UPX0:00406270 ; void __stdcall ExitProcess(UINT uExitCode) UPX0:00406270 ExitProcess dd 7C81CDDAh ; DATA XREF: j_ExitProcess↑ r UPX0:00406274 ; void __stdcall ExitThread(DWORD dwExitCode) UPX0:00406274 ExitThread dd 7C80C058h ; DATA XREF: j_ExitThread ↑ r UPX0:00406278 ; HANDLE __stdcall GetCurrentProcess() UPX0:00406278 GetCurrentProcess dd 7C80DDF5h ; DATA XREF: j_GetCurrentProcess↑ r UPX0:0040627C ; HANDLE __stdcall GetCurrentThread() UPX0:0040627C GetCurrentThread dd 7C8098EBh ; DATA XREF: j_GetCurrentThread ↑ r
以这种方式重建导入表,IDA 将能够使用从它的类型库中提取出的参数类型信息,为库函数调用添加适当的注释,反汇编代码清单的总体质量也因此得到显著提高。
4. x86emu 的其他功能
这种模拟器还提供其他一些有用的功能。下面详细介绍其中一些功能。
File▶Dump(文件▶转储)。用户可利用这个菜单选项指定一个数据库地址范围,这些地址将转储到一个文件中。默认情况下,这个范围由光标当前所在位置延伸到数据库中的最大虚拟地址。
File▶Dump Embedded PE(文件▶转储嵌入式 PE)。许多恶意程序包含嵌入式可执行文件,以将它们安装到目标系统中。这个菜单选项在光标当前所在位置寻找一个有效的 PE 文件,解析这个文件的头部,以确定该文件的大小,然后从数据库中提取出相应的字节,转储到一个文件中。
View▶Enumerate Heap (查看▶枚举堆)。这个菜单项使模拟器将一组已分配的堆块转储到“输出”窗口中,如下所示:
x86emu: Heap Status --- 0x5378000-0x53781ff (0x200 bytes) 0x5378204-0x5378217 (0x14 bytes) 0x537821c-0x5378347 (0x12c bytes)
Emulate▶Switch Thread (模拟▶切换线程)。在 Windows PE 文件中进行模拟时,x86emu 会捕捉对
CreateThread
函数的调用,并分配额外的资源来管理一个新的线程。由于模拟器没有自己的调度器,如果你希望在多个线程之间切换,必须使用这个菜单项。Functions ▶Allocate Heap Block(函数▶分配堆块)。用户可利用这个菜单项在模拟堆中保留一个内存块。用户需要提供这个块的大小。这个新保留块的地址将报告给用户。如果在模拟过程中需要暂存空间,就会用到这项功能。
Functions ▶Allocate Stack Block (函数▶分配栈块)。用户可利用这个菜单项在模拟栈中保留一个内存块。它的作用与 Functions Allocate Heap Block 命令类似。
5. x86emu 与反调试
虽然模拟器并不是作为调试器使用的,但它必须为被模拟的程序模拟一个运行时环境。为了成功模拟许多模糊二进制文件,模拟器不能成为各种主动的反调试技巧的牺牲品。在设计模拟器的一些功能时,我们一直考虑到这些反调试技巧。
其中一种反调试技巧是使用 x86 rdtsc
指令测量时间间隔,确保一个程序不会被调试器暂停。 rdtsc
指令用于读取内部 时间戳计数器 (Time Stamp Counter,TSC ),并返回一个 64 位值,表示处理器自上一次重启以来所经过的时间。TSC 递增的速度因 CPU 类型而异,但基本上是每个内部 CPU 时钟周期递增一次。调试器无法终止 TSC 递增,因此,通过测量两个连续的 rdtsc
调用之间的 TSC 差异,处理器能够确定它曾经被终止很长一段时间。x86emu 维护有一个内部 TSC ,它随每条被模拟的指令而递增。因为模拟 TSC 仅仅受被模拟的指令影响,因此,使用 rdtsc
的间隙不论有多久,都不会造成问题。这样,观察到的 TSC 值之间的差距将始终与在两次调用 rdtsc
之间模拟的指令数量大致成一定比例,而且这个差距会始终保持足够小,能够让被模拟的程序确信它没有附加调试器。
有意使用异常是模拟器必须处理的另一种反调试技巧。模拟器包含非常基本的功能,能够模拟 Windows 结构化异常处理(SEH )进程的行为。如果被模拟的程序是一个 Windows PE 二进制文件,模拟器必须通过构建一个 SHE CONTEXT
结构体,通过 fs:[0]
遍历异常处理程序列表来定位当前的异常处理程序,并将控制权转交给这个已安装的异常处理程序,以此来响应一个异常或软件中断。当该异常处理程序返回时,模拟器将从 CONTEXT
结构体(可能已经被异常处理程序修改)恢复 CPU 的状态。
最后,虽然 x86emu 模拟 x86 硬件调试寄存器的行为,但它并不利用这些寄存器在一个被模拟的程序中设置断点。如前所述,模拟器在内部维护用户指定的断点列表,并在执行每条指令前扫描这个列表。在 Windows 异常处理程序中对调试寄存器的任何修改都不会影响模拟器的操作。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论