- 献词
- 致谢
- 前言
- 第一部分 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 交叉引用
25.3 调试模糊代码
我们已经多次提到:在调试器中加载一个模糊程序,使它继续运行,直到去模糊过程完成,然后拍摄该程序经过去模糊处理后的内存快照,这似乎是一个不错的策略,可以帮助我们获得程序的去模糊版本。但是,与调试相比,以受控执行(controlled execution)的方式实施这个策略可能要更好一些,因为这时我们只需要观察代码的运行状态,然后在适当的时候拍摄一张内存快照。调试器这种工具正好可以帮助我们完成这个任务,至少它是我们正在寻找的工具。在第 21 章中,我们了解到一系列反“反汇编”和反调试技巧,模糊器正是利用这些技巧阻止我们清楚了解程序的行为。现在,我们来看 IDA 调试器如何帮助我们避开其中一些技巧。
在本章中,假定我们所处理的模糊程序全都对二进制文件的相关部分进行了某种形式的加密或压缩。了解模糊代码的用途的困难程度,完全取决于模糊过程所使用的反分析技巧,以及为避开这些技巧而采取的措施的复杂程度。但是,在开始讨论之前,我们首先说明在调试环境中分析恶意软件需要遵循的一些规则。
保护网络和主机环境。始终在一个沙盒环境中进行分析。
在初始分析时,尽可能使用单步分析。这样做可能有些烦琐,但这是防止程序脱离你的控制的最佳方法。
在执行允许执行多条指令的调试器命令之前,请三思而行。如果控制不当,你调试的程序可能会执行代码的恶意部分。
如有可能,使用硬件断点。你很难在模糊代码中设置软件断点,因为去模糊算法可能会修改你插入的断点指令并计算代段区域的校验和1 。
1. 请记住,调试器插入的软件断点指令将导致校验和计算,以生成一个非预期的结果。
在初次分析程序时,最好让调试器处理该程序生成的所有异常,以便于你明智地决定将哪些异常交由程序处理,哪些异常应由调试器继续捕获。
做好经常重新开始调试的准备,因为一步出错,可能会导致整个调试过程失败(例如,如果你允许进程检测调试器)。请详细记录你确定安全的地址,以便迅速找到这些地址,重新开始调试过程。
一般来说,当你第一次开始分析一个特殊的模糊程序时,你应该始终保持谨慎。多数情况下,你的主要目标是获得该程序的去模糊版本。然后,你的下一个目标是在设置断点之前,了解你到底能够“走多远”,从而加快去模糊过程;在你第一次成功去模糊一个程序后,最好将这整个过程保存下来,便于以后演练。
25.3.1 启动进程
无论你是否已花费数分钟或数小时使用 IDA 来研究某个恶意可执行程序,你都希望在调试器中初次启动该程序时就能控制它。控制进程的最简单方法之一是,在进程的入口点——创建进程的内存镜像后执行的第一条指令——设置一个断点。许多时候,入口点带有符号标记 start
,但有时则没有。例如,PE 文件格式允许为每个线程的数据指派用于执行初始化和解构任务的 TLS6 回调函数2 ,且这些 TLS6 回调函数会在控制权转交到 start
之前被调用。
2. 有关 TLS 回调函数的更多信息,参见 PE 文件格式规范: http://msdn.microsoft.com/en-us/windows/hardware/gg-463119.aspx 。
恶意软件开发者深知 TLS 回调函数的特点,并利用这些函数在程序的主入口点代码开始运行之前执行一些代码。他们希望任何分析恶意软件的人将不会注意到 TLS 回调函数,因此也就无法了解所分析程序的真实意图。IDA 能够正确解析 PE 文件头并识别 PE 文件中的任何 TLS 回调函数,同时将任何此类函数添加到 Exports 窗口内的二进制文件入口点列表中。包含一个 TLS 回调函数的可执行程序的 Exports 窗口如图 25-3 所示。
图 25-3 Exports 窗口显示一个 TLS 回调函数
对于 TLS 回调函数而言,关键在于确定它们确实存在,然后在每个 TLS 回调函数的起始位置设置断点,以确保你能够及时获得进程的控制权。
许多调试器都提供了选项,用于指定调试器应在创建进程后于何时暂停,IDA 也不例外。IDA 的 Debugger Setup 对话框(Debugger ▶Debugger Options)的一部分如图 25-4 所示。
图 25-4 调试器暂停事件
每个可用的选项都为你提供了机会,以便于在发生特定事件时自动暂停所调试的进程。这些事件汇总如下。
Stop on debugging start (在调试开始时停止) 。此选项允许你在创建进程后尽早暂停调试器。例如,在 Windows 7 中,此选项将暂停 ntdll.dll 中
RtlUserThreadStart
函数起始位置的进程。调试器将在任何程序代码(包括 TLS 回调函数)执行之前暂停。Stop on process entry point (在进程入口点停止) 。一旦到达程序入口点,即暂停调试器。通常,此选项的作用与 IDA 数据库中的
start
符号(或类似符号)的作用相同。所有 TLS 回调函数已在此事件发生之前执行。Stop on thread start/exit(线程启动/退出时停止) 。每次新线程启动或现有线程终止时暂停调试器。在 Windows 系统中,如果发生此类事件,调试器将在 kernel32.dll 的某个位置暂停。
Stop on library load/unload (库加载/卸载时停止) 。每次加载新库或卸载现有库时暂停调试器。在 Windows 系统中,如果发生此类事件,调试器将在 kernel32.dll 的某个位置暂停。
Stop on debugging message (在输出调试消息时停止) 。每次进程使用调试打印设备输出消息时暂停调试器。在 Windows 系统中,此选项对应于调用
OutputDebugString
,调试器将在 kernel32.dll 中暂停。
为了防止你所调试的进程继续执行,超出你预想的位置,了解进程在发生这些调试器事件时可能的暂停位置非常重要。确定能够以可预见的方式控制进程后,你就可以使用调试器执行其他任务。
25.3.2 简单的解密和解压循环
这里说的 简单解密和解压循环 是指没有采用嵌入式模糊技巧的循环,你可以轻易确定其中所有可能的退出点。如果你遇到这样的循环,分析它们的最简单方法是在所有可能的退出点设置断点,然后让循环开始执行。你可以考虑单步执行这些循环一到两次,以初步了解它们,然后再相应地设置断点。如果在一个循环结束后立即设置一个断点,你必须确保你设置的断点所在地址处的字节在整个循环过程中不会发生变化,否则,你可能无法触发软件断点。如果不能肯定,可以使用一个硬件断点。
如果你的目标是建立一个完全自动化的去模糊过程,那么你需要开发一个算法,用于确定去模糊过程何时完成。如果这个条件得到满足,你的自动化解决方案将能够使进程暂停,这时你就可以拍摄一张内存快照。对于简单的去模糊例程,要确定去模糊阶段是否已经结束,你只需要观察指令指针的一个明显变化,或者某个指令的执行。例如,模糊 Windows 可执行文件 UPX
解压例程的开始和结束部分如下所示:
UPX1:00410370 start proc near ➊ UPX1:00410370 pusha UPX1:00410371 mov esi, offset off_40A000 UPX1:00410376 lea edi, [esi-9000h] UPX1:0041037C push edi ... UPX1:004104EC pop eax ➋ UPX1:004104ED popa ; opcode 0x53 UPX1:004104EE lea eax, [esp-80h] UPX1:004104F2 UPX1:004104F2 loc_4104F2: ; CODE XREF: start+186↓ j UPX1:004104F2 push 0 UPX1:004104F4 cmp esp, eax UPX1:004104F6 jnz short loc_4104F2 UPX1:004104F8 sub esp, 0FFFFFF80h ➌ UPX1:004104FB jmp loc_40134C
这个例程的几个特点可用于自动识别它是否完成。首先,这个例程一开始就在程序入口点将所有寄存器压入栈中(➊ )。例程快结束时(➋ )弹出所有寄存器,这时程序已经解压。最后,控制权已转移到新解压的程序(➌)。因此,要自动完成解压,一种策略是单步跟踪程序,直到当前指令为 popa
。由于单步跟踪相当缓慢,代码清单 25-1 中的 IDC 示例采用一种稍微不同的方法来扫描 popa
指令,然后运行程序,直到 popa
所在的地址:
代码清单 25-1 简单的 UPX 解包器脚本
#include #define POPA 0x53 static main() { auto addr, seg; addr = BeginEA(); //Obtain the entry point address seg = SegName(addr); ➋ while (addr != BADADDR && SegName(addr) == seg) { ➌ if (Byte(addr) == POPA) { ➍ RunTo(addr); GetDebuggerEvent(WFNE_SUSP, -1); Warning("Program is unpacked!"); ➎ TakeMemorySnapshot(1); return; } ➊ addr = FindCode(addr, SEARCH_NEXT | SEARCH_DOWN); } Warning("Failed to locate popa!"); }
代码清单 25-1 中的脚本需要在启动调试器之前在 IDA 数据库中启动,并且假设你以前已经使用 Debugger ▶Select debugger 选择了一个调试器。这段脚本详细说明了如何启动调试器并控制新建的进程。这个脚本利用了 UPX 的一些非常特殊的特性,因此并不特别适合作为通用的去模糊脚本。但是,它说明了一些我们稍后将要用到的概念。这个脚本有两个依据:首先,解压例程位于一个程序段(通常叫做 UPX1
)的尾部;其次,UPX 并未利用任何取消同步技巧来阻止正确的反汇编。
模糊器之模糊
UPX 是当前最流行的模糊实用工具之一(可能因为它是免费的)。但是,它的流行并未使它成为一种特别有效的工具。在效率方面,它的一个主要缺点在于 UPX 本身提供一个命令行选项,能够将 UPX 打包的二进制文件恢复到它的原始状态。因此,一个“作坊式”行业逐渐形成,他们专门开发用于阻止 UPX 解包的工具。由于在解包一个压缩二进制文件之前,UPX 会对这个文件全面检查,因此,我们可以对该文件进行一些简单的修改,使完整性检查失效,并令 UPX 的解包功能无法操作,但又不影响这个压缩二进制文件的运行。其中一个这样的技巧是将默认的 UPX 区块名称更改为
UPX0
、UPX1
和UPX2
以外的名称。因此,在为解包 UPX 开发脚本时,最好不要硬编码这些区块名称。
这个脚本因这两个依据从程序的入口点开始向前扫描,一次一条指令(➊)——只要下一条指令位于同一个程序段内(➋)——直到当前指令为 popa
(➌)。一旦到达 popa
指令,调试器将被调用(➍),以执行 popa
指令的地址所在处的进程,这时程序已经被解压。最后一步是拍摄一张内存快照(➎),将经过去模糊处理的程序加载到我们的数据库中,以进行深入分析。
一个更加通用的自动化解包解决方案利用了一个事实:许多去模糊例程通常被附加到一个二进制文件的结尾部分,一旦去模糊完成,它将跳转到初始的入口点,这个入口点通常在二进制文件的开始部分。有时候,初始的入口点可能位于一个截然不同的程序段;而在其他情况下,原始的入口点可能就在去模糊代码所使用的地址的前面。代码清单 25-2 中的 Python 脚本提供了一个更加直接的方法,可以运行一个简单的去模糊算法,直到它跳转到程序的初始入口点。
代码清单 25-2 继续运行,直到到达 OEP
start = BeginEA() ➊ RunTo(start) GetDebuggerEvent(WFNE_SUSP, -1) ➋ EnableTracing(TRACE_STEP, 1) code = GetDebuggerEvent(WFNE_ANY | WFNE_CONT, -1) while code > 0: ➌ if GetEventEa() < start: break code = GetDebuggerEvent(WFNE_ANY | WFNE_CONT, -1) ➍ PauseProcess() GetDebuggerEvent(WFNE_SUSP, -1) ➎ EnableTracing(TRACE_STEP, 0) ➏ MakeCode(GetEventEa()) TakeMemorySnapshot(1)
与代码清单 25-1 中的脚本类似,这个脚本应该从反汇编器而不是调试器中启动。这个脚本详细说明如何启动调试器并获得必要的控制权,以运行新建的进程。这个特殊的脚本有两个假设:入口点之前的所有代码都被模糊处理;在将控制权转交给入口点前面的地址之前,没有任何恶意行为发生。这个脚本首先启动调试器并在程序的入口点处暂停(➊)。然后,程序执行单步跟踪(➋)和循环,以测试每一个生成的事件的地址(➌)。只要事件地址到达程序入口点地址的前面,则认为去模糊已经完成,进程将被暂停(➍),单步跟踪也被禁用(➎)。最后,这个脚本还确保当前指令指针位置处的字节被格式化成代码(➏)。
在执行这个脚本的过程中,你通常会看到如图 25-5 所示的警告。
图 25-5 调试器指令指针警告
这条警告指出,指令指针指向一个 IDA 认为是数据的项目,或者指向一个之前已经反汇编的指令的中间。在单步执行利用反汇编“去同步”技巧的代码时,常常会遇到这样的警告。当一个程序跳转到一个之前为数据现在为代码的区域(对一个程序进行去模糊处理后往往会出现这种情况)时,这个警告也经常出现。对警告中的问题回答“是”,IDA 会将相关字节重新格式化成代码,这样做是正确的,因为指令指针指出这是下一个将要提取出来并执行的项目。
需要注意的是,因为使用了单步跟踪,代码清单 25-2 中的脚本的执行速度要比代码清单 25-1 中的脚本慢得多。但是,执行缓慢也带来了一些好处。首先,我们可以指定一个与任何地址都无关的终止条件,而仅仅使用断点却无法做到这一点。其次,在这个脚本中,任何对反汇编器去同步的尝试都将失败,因为指令边界完全由指令指针的运行时值决定,而不能通过静态反汇编分析决定。在有关脚本化调试功能3 的声明中,Hex-Rays 提供了一个更加健壮的脚本,该脚本能够执行一个通用解包器的任务。
3. 参见 http://www.hex-rays.com/idapro/scriptable.htm 。
25.3.3 导入表重建
对二进制文件去模糊后,接下来可以开始分析这个文件。虽然我们从未打算执行经过去模糊处理的导入表(实际上,如果一个快照被直接加载到 IDA 数据库中,我们根本无法执行这个程序),但是要了解程序的行为,该程序的导入表几乎总是一个非常重要的资源。
正常情况下,在最初创建数据库之后,IDA 能够在随后的文件加载过程中解析程序的导入表。但在模糊程序中,IDA 在加载阶段看到的唯一导入表属于该程序的去模糊组件。此导入表通常仅包含完成去模糊过程所需的最小数量的函数。最复杂的模糊器可能会生成空导入表,这时去模糊组件必须包含自行加载库和解析必要的函数所需的全部代码。
对于已经过模糊处理的二进制文件,多数情况下,它的导入表也已经过模糊处理,并在去模糊过程中以某种形式进行了重建。一般情况下,重建过程需要利用新近经过去模糊处理的数据进行它自己的库加载和函数地址解析。对于 Windows 程序而言,这个过程几乎总是需要调用 LoadLibrary
函数和重复调用 GetProcAddress
,以解析所需的函数地址。
更加复杂的导入表重建例程可能会利用自定义查找函数来代替 GetProcAddress
,以避免触发 GetProcAddress
自己设置的断点。这些例程还可能会用散列值代替字符串,以识别被请求的函数的地址。少数情况下,导入表重建可能还需要避开 LoadLibrary
函数,这时重建例程必须自己执行该函数的自定义版本。
最终,导入表重建过程将生成一个函数地址表,在静态分析上下文中,这些地址没有任何意义。如果拍摄一个进程的内存快照,我们最多可以得到下面的内容(部分显示):
UPX1:0040A000 dword_40A000 dd 7C812F1Dh ; DATA XREF: start+1↓o UPX1:0040A004 dword_40A004 dd 7C91043Dh ; DATA XREF: sub_403BF3+68↑r UPX1:0040A004 ; sub_405F0B+2B4↑ r ... UPX1:0040A008 dd 7C812ADEh UPX1:0040A00C dword_40A00C dd 7C9105D4h ; DATA XREF: sub_40621F+5D↑r UPX1:0040A00C ; sub_4070E8+F↑ r ... UPX1:0040A010 dd 7C80ABC1h UPX1:0040A014 dword_40A014 dd 7C901005h ; DATA XREF: sub_401564+34↑r UPX1:0040A014 ; sub_4015A0+27↑ r ...
这段代码描述大量 4 字节值,它们的地址紧密相连,并且被程序中许多不同的位置引用。问题在于,如果映射到我们调试的进程中,这些值(如 7C812F1Dh
)表示库函数的地址。在程序的代码块中,我们可以看到类似于下面的函数调用:
UPX0:00403C5B ➊ call ds:dword_40A004 UPX0:00403C61 test eax, eax UPX0:00403C63 jnz short loc_403C7B UPX0:00403C65 ➌ call sub_40230F UPX0:00403C6A mov esi, eax UPX0:00403C6C ➋ call ds:dword_40A058
需要注意的是,有两个函数调用(➊和➋)引用了重建后的导入表的内容,而第三个函数调用(➌)引用的是一个正文位于数据库中的函数。理想情况下,重建后的导入表中的每个条目将以它包含其地址的函数命名。
我们最好在为经过去模糊处理的进程拍摄内存快照前解决上述问题。如下所示,如果从调试器中查看与上面相同的内存范围,我们将看到一个截然不同的列表。因为调试器已经访问了每一个被引用的函数所在的内存区域,现在调试器能够将地址(如 7C812F1Dh
)显示成与之对应的符号名称(这里为 kernel32_GetCommandLineA
)。
UPX1:0040A000 off_40A000 dd offset kernel32_GetCommandLineA;DATAXREF:UPX0:loc_40128F↑r UPX1:0040A000 ; start+1 ↓ o UPX1:0040A004 off_40A004 dd offset ntdll_RtlFreeHeap ; DATA XREF: UPX0:004011E4↑ r UPX1:0040A004 ; UPX0:0040120A↑r ... UPX1:0040A008 off_40A008 dd offset kernel32_GetVersionExA ;DATA XREF: UPX0:004011D4↑r UPX1:0040A00C dd offset ntdll_RtlAllocateHeap ; DATA XREF:UPX0:004011B3↑r UPX1:0040A00C ; sub_405E98+D↑ r ... UPX1:0040A010 off_40A010 dd offset kernel32_GetProcessHeap ;DATA XREF:UPX0:004011AA↑r UPX1:0040A014 dd offset ntdll_RtlEnterCriticalSection ; DATA XREF: sub_401564+34↑ r UPX1:0040A014 ; sub_4015A0+27 ↑ r ...
注意,这时调试器采用的命名方案与我们常见的命名方案略有不同。调试器会在所有由共享库导出的函数前面加上相关库的名称和一个下划线。例如,kernel32.dll 导出的 GetCommandLineA
函数的名称为 kernel32_GetCommandLineA
。这样做可确保在两个库导出同一个名称时生成唯一的名称。
对于上面的导入表,我们需要解决两个问题。其一,为了使函数调用更具可读性,我们需要根据导入表中的每个条目引用的函数,为这些条目命名。如果这些条目拥有正确的名称,IDA 将能够自动显示它的类型库中的函数签名。只要拥有可供分配的名称,那么命名每一个导入表条目就是一个相对容易的任务。这诱生了第二个问题:要获得适当的名称。获得名称的一种方法是解析调试器生成的名称,去除前面的库名称,并将剩下的文本作为导入表条目的名称。这种获取名称的方法存在一种问题,即有些时候,库名称和函数名称可能都包含有下划线字符,这使得我们很难确定一个较长的名称字符串中函数名称的准确长度。尽管如此,IDA 自带的 renimp.idc 导入表重命名脚本(位于<IDADIR>/idc 目录)仍然采用了这种方法。
这个脚本必须在调试器处于活动状态(以便访问加载的库名称)时才能正常运行,并且我们必须能够确定去模糊二进制文件中重构导入表的位置。确定已重建的导入表的位置的一种策略是,跟踪对 GetProcAddress
的调用并记下结果在内存中的存储位置。UPX 用于调用 GetProcAddress
并存储结果的代码如代码清单 25-3 所示。
代码清单 25-3 解析并存储导入的函数地址的 UPX 代码
UPX1:00408897 ➊ call dword ptr [esi+8090h] UPX1:0040889D or eax, eax UPX1:0040889F jz short loc_4088A8 UPX1:004088A1 ➋ mov [ebx], eax UPX1:004088A3 ➌ add ebx, 4
对 GetProcAddress
的调用发生在➊处,其结果存储在内存中的➋处。记住,➋处的 ebx
寄存器中保存的值将可获知导入表的位置。 ebx
寄存器之前有 4 个字节(➌),用于为它下次遍历函数解析循环作好准备。
确定已重建的导入表的位置后,renimp.idc 要求我们使用单击并拖动操作突出显示从表开头到结尾的内容。renimp.idc 脚本将遍历这些内容,获得所引用的函数的名称,去除库名称前缀并相应地为导入表条目命名。执行这个脚本后,前面的导入表将转换成如下所示的导入表:
UPX1:0040A000 ; LPSTR __stdcall GetCommandLineA() UPX1:0040A000 GetCommandLineA dd offset kernel32_GetCommandLineA UPX1:0040A000 ; DATA XREF: UPX0:loc_40128F↑r UPX1:0040A000 ; start+1 ↓ o UPX1:0040A004 RtlFreeHeap dd offset ntdll_RtlFreeHeap ; DATA XREF: UPX0:004011E4↑r UPX1:0040A004 ; UPX0:0040120A ↑ r ... UPX1:0040A008 ; BOOL __stdcall GetVersionExA(LPOSVERSIONINFOA lpVersionInformation) UPX1:0040A008 GetVersionExA dd offset kernel32_GetVersionExA;DATA XREF:UPX0:004011D4↑r UPX1:0040A00C RtlAllocateHeap dd offset ntdll_RtlAllocateHeap;DATA XREF:UPX0:004011B3↑r UPX1:0040A00C ; sub_405E98+D↑ r ... UPX1:0040A010 ; HANDLE __stdcall GetProcessHeap() UPX1:0040A010 GetProcessHeap dd offset kernel32_GetProcessHeap;DATA XREF:UPX0:004011AA↑r UPX1:0040A014 RtlEnterCriticalSection dd offset ntdll_RtlEnterCriticalSection UPX1:0040A014 ; DATA XREF: sub_401564+34↑ r UPX1:0040A014 ; sub_4015A0+27 ↑ r ...
如上所示,renimp.idc 脚本已经重命名了每一个导入表条目,但 IDA 为每一个它拥有类型信息的函数添加了函数原型。需要注意的是,如果库名称没有从函数名称中去除,则 IDA 将无法提供函数类型信息。另外,如果函数所在的模块的名称中包含一个下划线,renimp.idc 脚本可能无法正确地提取出导入函数的名称。ws2_32 网络库就是一个典型的例子,它的名称中就包含一个下划线。renimp.idc 脚本会对 ws2_32 进行特殊处理,但对于任何其他名称中包含下划线的模块,renimp.idc 脚本无法从它们的名称中提取出正确的函数名称。
如果仅有一条指令负责存储所有已解析的函数地址(如代码清单 25-3 中的 UPX 代码),这时可以采用另一种方法来重命名导入表条目。如果可以确定此类指令(如代码清单 25-3 中➋处的指令),则我们可以利用 IDA 使用 IDC 语句来指定断点条件这样一个事实。在这种情况下,我们可以在地址 004088A1
处设置一个条件断点,并使条件表达式调用我们定义的函数。下面我们命名了 createImportLabel
函数并将其定义如下:
static createImportLabel() { auto n = Name(EAX); auto i = strstr(n, "_"); while (i != -1) { n = n[i+1:]; i = strstr(n, "_"); } MakeUnkn(EBX,DOUNK_EXPAND); MakeDword(EBX); if (MakeNameEx(EBX,n,SN_NOWARN) == 0) { MakeNameEx(EBX,n + "_",SN_NOWARN); } return 0; }
这个函数首先查询 EAX 引用的名称。前面我们讲到,EAX 包含调用 GetProcAddress
的结果,因此它应该会引用某个 DLL 中的函数。然后,该函数执行循环,截断查询到的名称,仅保留原始名称中最后一个下划线之后的部分。最后再进行一系列函数调用,将目标位置(被 EBX 引用)正确格式化为一个 4 字节数据项,并将一个名称应用于该位置。通过返回零,该函数告诉 IDA 不要实践断点,因此执行会继续进行,而不会暂停。
在第 24 章中,我们讨论了如何在 IDA 的调试器中指定断点条件。然而,将用户定义的函数设置为断点处理器并不像设置和编辑断点然后输入 createImportLabel()
作为断点条件那样简单。尽管此时我们确实希望输入这个条件,但问题在于,在 IDA 看来, createImportLabel
是一个未定义的函数。要解决这个问题,我们可以创建一个脚本文件(根据定义取名为 IDC ),其中包含 createImportLabel
函数以及如下所示的简单的 main
函数:
static main() { ➊ AddBpt(ScreenEA()); ➋ SetBptCnd(ScreenEA(), "createImportLabel()"); }
将光标放在要在其上设置断点的指令上,然后运行此脚本(File▶Script File),将生成一个条件断点,每次触发该断点时都会调用 createImportLabel
。 AddBpt
函数(➊)在指定位置(本例中为光标位置)添加一个断点, SetBptCnd
函数(➋)将一个条件添加到现有断点。该条件被指定为一个字符串,其中包含每次触发断点时都会进行求值的 IDC 语句。设置这个断点后,一旦完成去模糊操作,我们将得到一个带标签的导入表,而不必在进程的内存空间中查找导入表。
另一种获取名称信息的方法是搜索内存,查找与一个函数地址有关的文件头,然后解析这些头部描述的导出表,确定被引用的函数的名称。基本上,这是在根据函数的地址逆向查询该函数的名称。本书的网站提供了一个基于这个概念的脚本(RebuildImports.idc/RebuidImports.py )。这其中的任何一个脚本都可以代替 renimp.idc,而且效果几乎完全相同。renimp.idc 在处理名称中包含下划线字符的模块时遇到的问题也得以避免,因为这时的函数名称是直接从进程内存空间中的导出表中提取出来的。
为每一个导入表条目正确命名的效果还会直接在反汇编代码清单中反映出来,如下面自动更新的反汇编代码清单所示:
UPX0:00403C5B call ds:RtlFreeHeap UPX0:00403C61 test eax, eax UPX0:00403C63 jnz short loc_403C7B UPX0:00403C65 call sub_40230F UPX0:00403C6A mov esi, eax UPX0:00403C6C call ds:RtlGetLastWin32Error
每一个重命名后的导入表条目的名称应用到了调用导入函数的所有位置,这进一步提高了反汇编代码清单的可读性。值得注意的是,你在使用调试器时所作的任何格式化更改也会自动应用到数据库视图中。换言之,你不需要拍摄内存快照来捕获你所作的格式化更改。使用内存快照的目的是将内存内容(代码和数据)从进程地址空间移回 IDA 数据库中。
25.3.4 隐藏调试器
有许多方法可以阻止你将调试器作为去模糊工具使用,其中一个常见的方法叫做 调试器检测 。模糊工具的作者也认识到,用户可以使用调试器撤销他们辛苦劳动的成果。为应对这种情况,如果他们的工具检测到调试器,他们将采取措施阻止这些工具运行。我们已经在第 21 章讨论过一些调试器检测方法。如第 21 章所述,Nicolas Falliere 的文章“Windows Anti-Debug Reference”4 全面介绍了大量特定于 Windows 的调试器检测技巧。通过使用一段简单的脚本来启动调试器会话,并自动配置一些断点,你就可以避开其中的一些检测技巧。虽然我们可以使用 Python 来避免这些技巧,但最终还是会使用条件断点,而我们只能用 IDC 来指定条件断点。因此,下面的示例均以 IDC 编写。
4. 参见 http://www.symantec.com/connect/articles/windows-anti-debug-reference/ 。
为了从脚本启动调试会话,首先运行下面的代码:
auto n; for (n= 0; n < GetEntryPointQty(); n++) { auto ord = GetEntryOrdinal(n); if (GetEntryName(ord) == "TlsCallback_0") { AddBpt(GetEntryPoint(ord)); break; } } RunTo(BeginEA()); GetDebuggerEvent(WFNE_SUSP, -1);
这些语句检查 TLS 调用函数是否存在,设置断点(如果有的话),然后启动调试器,请求在入口点地址处中止,然后等待操作完成(严格来讲,我们还应当测试 GetDebuggerEvent
的返回值)。一旦我们的脚本重新获得控制权,我们将拥有一个处于活动状态的调试器会话,我们希望调试的进程将与它依赖的所有库一起映射到内存中。
我们需要避开的第一个调试器检测是进程环境块(PEB )中的 IsDebugged
字段。这是一个 1 字节字段,如果进程正接受调试,它就被设为 1,否则设为 0。这个字段位于 PEB 中的第二个字节,因此我们所需要做的就是找到 PEB ,并将适当的字节修补为 0 即可。同时,这个字段也是 Windows API 函数 IsDebuggerPresent
测试的字段,因此我们可以设法取得“一石二鸟”的效果。如果我们知道已经停在了与 TLS 回调相对的程序入口点,找到 PEB 的位置其实相当简单,因为在进入进程后,EBX 寄存器中即包含一个指向 PEB 的指针。但是,如果进程已在 TLS 回调函数处停止,那么我们需要采用一种更为常规的方法来查找 PEB 。我们将采用与 shellcode 和模糊器中常用的类似的方法。基本思想就是定位当前 线程信息块 (TIB )5 ,然后跟踪嵌入指针来查找 PEB 。下面的代码可以定位 PEB 并对相关字节进行适当的修补:
5. 该块也称为线程环境块。
auto seg; auto peb = 0; auto tid = GetCurrentThreadId(); auto tib = sprintf("TIB[%08X]", tid); //IDA naming convention for (seg = FirstSeg(); seg != BADADDR; seg = NextSeg(seg)) { if (SegName(seg) == tib) { peb = Dword(seg + 0x30); //read PEB pointer from TIB break; } } if (peb != 0) { PatchDbgByte(peb + 2, 0); //Set PEB!IsDebugged to zero }
值得注意的是,在 IDA 5.5 之前,IDA 并未引入 PatchDbgByte
函数。在使用 IDA 5.5 之前的版本时,我们可以使用 PatchByte
函数,如果数据库中存在指定的地址,该函数也会对数据库进行修改(修补)。
Falliere 的文章中提到的另一种反调试技巧是测试 PEB 的名为 NtGlobalFlags
的另一个字段中的几个位。这些位与进程的堆的操作有关,如果一个进程正被调试,则它们被设为 1。假设变量 peb
继续以前面的示例设置,下面的代码从 PEB 中获取 NtGlobalFlags
字段,重新设置造成问题的位,并将标志存储到 PEB 中。
globalFlags = Dword(peb + 0x68) & ~0x70; //read and mask PEB.NtGlobalFlags PatchDword(peb + 0x68, globalFlags); //patch PEB.NtGlobalFlags
Falliere 的文章中提到的一些技巧利用了有进程被调试与没有进程被调试时系统函数返回的信息之间的差异。文章中提到的第一个函数为 NtQueryInformationProcess
,位于 ntdll.dll 中。使用这个函数,进程可以请求与它的 ProcessDebugPort
有关的信息。如果这个进程正在被调试,该函数的返回值为非零值;如果它没有被调试,则该函数的返回值应为零。要避免这种形式的检测,可以在 NtQueryInformationProcess
返回的地方设置一个断点,然后指定断点条件函数来过滤 ProcessDebugPort
请求。我们采取以下步骤来自动定位这里的指令。
查询
NtQueryInformationProcess
的地址。在
NtQueryInformationProcess
上设置断点。添加一个断点条件以调用我们将其命名为
bpt_NtQueryInformationProcess
的函数,每次调用NtQueryInformationProcess
时都会执行该函数。
要查找 NtQueryInformationProcess
的地址,我们需要记住这个函数将在调试器中命名为 ntdll_NtQueryInformationProcess
。配置必要断点的代码如下所示:
func = LocByName("ntdll_NtQueryInformationProcess"); AddBpt(func); SetBptCnd(func, "bpt_NtQueryInformationProcess()");
我们所需要做的是执行断点函数,使“多疑的”进程无法发现我们的调试器。函数 NtQuery- InformationProcess
的原型如下所示:
NTSTATUS WINAPI NtQueryInformationProcess( __in HANDLE ProcessHandle, ➊ __in PROCESSINFOCLASS ProcessInformationClass, ➋ __out PVOID ProcessInformation, __in ULONG ProcessInformationLength, __out_opt PULONG ReturnLength );
该函数通过在 ProcessInformationClass
参数中提供一个整数查询标识符(➊),以请求与进程有关的信息。信息通过 ProcessInformation
参数指向的、用户提供的缓冲区返回(➋)。调用方可以传递枚举常量 ProcessDebugPort
(值 7),以查询一个给定进程的调试状态。如果一个进程正被一个用户空间调试器调试,则通过所提供的指针传递的返回值将为非零值;如果这个进程没有被调试,则返回值为零。一个始终将 ProcessDebugPort
的返回值设置为零的断点函数如下所示:
#define ProcessDebugPort 7 static bpt_NtQueryInformationProcess() { auto p_ret; ➊ if (Dword(ESP + 8) == ProcessDebugPort) {//test ProcessInformationClass ➋ p_ret = Dword(ESP + 12); ➌ if (p_ret) { ➍ PatchDword(p_ret, 0); //fake no debugger present } ➎ EIP = Dword(ESP); //skip function, just return ➏ ESP = ESP + 24; //stdcall so clear args from stack ➐ EAX = 0; //signifies success } return 0; //don’t pause at the breakpoint }
如前所述,这个函数在每次调用 NtQueryInformationProcess
时执行。这时,栈指针指向该函数的返回地址,这个地址位于传递给 NtQueryInformationProcess
的 5 个参数的顶部。该断点函数首先检查 ProcessInformationClass
的返回值,以确定调用方是否正请求 ProcessDebugPort
信息(➊)。如果调用方正请求 ProcessDebugPort
,则该函数继续执行,获取返回值指针(➋),检查它是否为非零(➌),最后保存一个零返回值(➍),从而达到隐藏调试器的目的。为了跳过该函数的剩余部分,随后会通过读取保存的返回地址(➎)来修改 EIP,然后调整 ESP 以模拟 stdcall
返回(➏)。 NtQueryInformationProcess
返回一个 NTSTATUS 码,它在返回前于➐处设置为 0(成功)。
Falliere 的文章中提到的另一个函数是 NtSetInformationThread
,你也可以在 ntdll.dll 中找到这个函数。该函数的原型如下所示:
NTSTATUS NtSetInformationThread( IN HANDLE ThreadHandle, IN THREADINFOCLASS ThreadInformationClass, IN PVOID ThreadInformation, IN ULONG ThreadInformationLength );
有一种反调试技巧,它将 ThreadHideFromDebugger
值传递到 ThreadInformationClass
参数中,它会使线程脱离调试器。要避开这种技巧,我们需要使用和前一个例子一样的基本设置。最后的设置代码如下所示:
func = LocByName("ntdll_NtSetInformationThread"); AddBpt(func); //break at function entry SetBptCnd(func, "bpt_NtSetInformationThread()");
相关的断点函数如下所示:
#define ThreadHideFromDebugger 0x11 static bpt_NtSetInformationThread() { ➊ if (Dword(ESP + 8) == ThreadHideFromDebugger) {//test ThreadInformationClass ➋ EAX = 0; //STATUS_SUCCESS ➌ EIP = Dword(ESP); //just return ➍ ESP = ESP + 20; //simulate stdcall } return 0; }
我们测试 ThreadInformationClass
参数的值(➊ ),并避开函数正文(如果用户已经指定 ThreadHideFromDebugger
)。通过设置我们期望的返回值(➋ ),并从栈中读取保存的返回值来修改指令指针(➌),从而避开函数体。我们通过对 ESP 进行 20 字节的调整(➍)来模拟 stdcall
返回。
我们最后讨论的函数为 kernel32.dll 中的 OutputDebugStringA
,Falliere 在他的文章中介绍了如何在反调试技巧中应用这个函数。该函数的原型如下所示:
void WINAPI OutputDebugStringA( __in_opt LPCTSTR lpOutputString );
在这个例子中, WINAPI
是 _stdcall
的同义词,用于指定 OutputDebugStringA
使用的调用约定。严格来讲,这个函数并没有返回值,因为它的原型指定的是 void
返回类型。但是,据 Falliere 的文章讲述,如果没有调试器依附于正被调用的进程,这个函数返回 1 ;如果在调试器正依附于被调用的进程时,该函数被调用,则它“返回”作为参数传递的字符串的地址。正常情况下,如果 _stdcall
函数确实返回一个值,那它应该返回 EAX 寄存器中的值。由于在 OutputDe- bugStringA
返回时,EAX 必须保存某个值,因此,我们可以认为这个值就是该函数的返回值。但是,由于正式的返回类型为 void
,因此没有文档资料或保证书指出这时 EAX 到底保存的是什么值。这个特殊的反调试技巧只是依赖于观察到的函数行为。为阻止观察到的返回值发生变化,我们可以设法确保在 OutputDebugStringA
返回时,EAX 包含值 1 。下面的 IDC 代码用于实施这个技巧:
func = LocByName("kernel32_OutputDebugStringA"); AddBpt(func); //fix the return value as expected in non-debugged processes //also adjust EIP and ESP ➊ SetBptCnd(func, "!((EAX = 1) && (EIP = Dword(ESP)) && (ESP = ESP + 8))");
这个例子使用和前一个例子相同的技巧自动定位 OutputDebugStringA
函数的结束部分。但是,与前一个例子不同,到达断点后你需要做的工作在一个 IDC 表达式(➊ )中指定就可以了(不需要专门的函数)。在这个例子中,断点表达式修改(注意,这里是赋值而不是比较)EAX 寄存器,以确保它在函数返回时包含值 1 ,并且也能调整 EIP 和 ESP 以避开该函数。我们取消了断点条件,以在所有情况下均跳过断点,因为布尔“与”表达式的结果应始终为非零值。
本书的网站包含一个脚本(HideDebugger.idc),它将我们在这一节介绍的所有要素组合到一个有用的工具中,用于启动调试会话,并同时采取措施来阻止反调试。欲了解更多有关隐藏调试器的信息,请参阅 Ilfak 的博客,其中介绍了几种隐藏技巧6 。
6. 参见 http://www.hexblog.com/2005/11/simple_trick_to_hide_ida_debug.html 、 http://www.hexblog.com/2005/11/stealth_plugin_1.html 和 http://www.hexblog.com/2005/11/the_ultimate_stealth_method_1.html 。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论