恶意样本分析手册——反调试篇(上)

DeBugger(调试器)是自从计算机诞生伊始就始终伴随着程序员的一个挚友,起初的调试器都是基于硬件直接实现的。

调试器原理

调试事件与调试循环

启动被调试程序

当我们要对一个程序进行调试时,首先要做的当然是启动这个程序,这要使用CreateProcess这个Windows API来完成。

CreateProcess(
TEXT("Path"),
NULL,
NULL,
NULL,
FALSE,
DEBUG_ONLY_THIS_PROCESS | CREATE_NEW_CONSOLE,
NULL,
NULL,
&si,
&pi)

CreateProcess的第六个参数使用了DEBUG_ONLY_THIS_PROCESS,这意味着调用CreateProcess的进程成为了调试器,而它启动的子进程成了被调试的进程。除了DEBUG_ONLY_THIS_PROCESS之外,还可以使用DEBUG_PROCESS,两者的不同在于:DEBUG_PROCESS会调试被调试进程以及它的所有子进程,而DEBUG_ONLY_THIS_PROCESS只调试被调试进程,不调试它的子进程。一般情况下我们只想调试一个进程,所以应使用后者。

建议在第六个参数中加上CREATE_NEW_CONSOLE标记。因为如果被调试程序是一个控制台程序的话,调试器和被调试程序的输出都在同一个控制台窗口内,显得很混乱,加上这个标记之后,被调试程序就会在一个新的控制台窗口中输出信息。如果被调试程序是一个窗口程序,这个标记没有影响。

调试循环

   一个进程成为被调试进程之后,在完成了某些操作或者发生异常时,它会发送通知给调试器,然后将自身挂起,直到调试器命令它继续执行。这有点像Windows窗口的消息机制。

被调试进程发送的通知称为调试事件,DEBUG_EVENT结构体描述了调试事件的内容:

typedef struct _DEBUG_EVENT {
  DWORD dwDebugEventCode;
  DWORD dwProcessId;
  DWORD dwThreadId;
  union {
    EXCEPTION_DEBUG_INFO Exception;
    CREATE_THREAD_DEBUG_INFO CreateThread;
    CREATE_PROCESS_DEBUG_INFO CreateProcessInfo;
    EXIT_THREAD_DEBUG_INFO ExitThread;
    EXIT_PROCESS_DEBUG_INFO ExitProcess;
    LOAD_DLL_DEBUG_INFO LoadDll;
    UNLOAD_DLL_DEBUG_INFO UnloadDll;
    OUTPUT_DEBUG_STRING_INFO DebugString;
    RIP_INFO RipInfo;
  } u;
} DEBUG_EVENT, 
 *LPDEBUG_EVENT;

dwDebugEventCode描述了调试事件的类型,总共有9类调试事件:

CREATE_PROCESS_DEBUG_EVENT 创建进程之后发送此类调试事件,这是调试器收到的第一个调试事件。
CREATE_THREAD_DEBUG_EVENT 创建一个线程之后发送此类调试事件。
EXCEPTION_DEBUG_EVENT 发生异常时发送此类调试事件。
EXIT_PROCESS_DEBUG_EVENT 进程结束后发送此类调试事件。
EXIT_THREAD_DEBUG_EVENT 一个线程结束后发送此类调试事件。
LOAD_DLL_DEBUG_EVENT 装载一个DLL模块之后发送此类调试事件。
OUTPUT_DEBUG_STRING_EVENT 被调试进程调用OutputDebugString之类的函数时发送此类调试事件。
RIP_EVENT 发生系统调试错误时发送此类调试事件。
UNLOAD_DLL_DEBUG_EVENT 卸载一个DLL模块之后发送此类调试事件。

每种调试事件的详细信息通过联合体u来记录,通过u的字段的名称可以很快地判断哪个字段与哪种事件关联。例如CREATE_PROCESS_DEBUG_EVENT调试事件的详细信息由CreateProcessInfo字段来记录。

dwProcessId和dwThreadId分别是触发调试事件的进程ID和线程ID。一个调试器可能同时调试多个进程,而每个进程内又可能有多个线程,通过这两个字段就可以知道调试事件是从哪个进程的哪个线程触发的了。

调试器通过WaitForDebugEvent函数获取调试事件,通过ContinueDebugEvent继续被调试进程的执行。ContinueDebugEvent有三个参数,第一和第二个参数分别是进程ID和线程ID,表示让指定进程内的指定线程继续执行。通常这是在一个循环中完成的。

异常

对于EXCEPTION_DEBUG_EVENT这类调试事件,这类调试事件是调试器与被调试进程进行交互的最主要手段,调试器可以使用这类事件完成断点、单步执行等操作。综上所述,异常是调试器的重点,所以需要首先了解关于异常的知识。

异常的分类

根据异常发生时是否可以恢复执行,可以将异常分为三种类型,分别是错误异常,陷阱异常以及中止异常。

错误异常和陷阱异常一般都可以修复,并且在修复后程序可以恢复执行。两者的不同之处在于,错误异常恢复执行时,是从引发异常的那条指令开始执行;而陷阱异常是从引发异常那条指令的下一条指令开始执行。中止异常属于严重的错误,程序不可以再继续执行。

根据异常产生的原因,可以将异常分为硬件异常和软件异常。硬件异常即由CPU引发的异常。

软件异常即程序调用RaiseException函数引发的异常,C++的throw语句最终也是调用该函数来抛出异常的。软件异常的异常代码可以在调用RaiseException时由程序员任意指定。

异常的分发

一个异常一旦发生了,就要经历一个复杂的分发过程。一般来说,一个异常有以下几种可能的结果:

1.异常未被处理,程序因“应用程序错误”退出。

2.异常被调试器处理了,程序在发生异常的地方继续执行(具体取决于是错误异常还是陷阱异常)。

3.异常被程序内的异常处理器处理了,程序在发生异常的地方继续执行,或者转到异常处理块内继续执行。

下面来看一下异常的分发过程。为了突出重点,这里省略了很多细节:

1.程序发生了一个异常,Windows捕捉到这个异常,并转入内核态执行。

2.Windows检查发生异常的程序是否正在被调试,如果是,则发送一个EXCEPTION_DEBUG_EVENT调试事件给调试器,这是调试器第一次收到该事件;如果否,则跳到第4步。

3.调试器收到异常调试事件之后,如果在调用ContinueDebugEvent时第三个参数为DBG_CONTINUE,即表示调试器已处理了该异常,程序在发生异常的地方继续执行,异常分发结束;如果第三个参数为DBG_EXCEPTION_NOT_HANDLED,即表示调试器没有处理该异常,跳到第4步。

4.Windows转回到用户态中执行,寻找可以处理该异常的异常处理器。如果找到,则进入异常处理器中执行,然后根据执行的结果继续程序的执行,异常分发结束;如果没找到,则跳到第5步。

5.Windows又转回内核态中执行,再次检查发生异常的程序是否正在被调试,如果是,则再次发送一个EXCEPTION_DEBUG_EVENT调试事件给调试器,这是调试器第二次收到该事件;如果否,跳到第7步。

6.调试器第二次处理该异常,如果调用ContinueDebugEvent时第三个参数为DBG_CONTINUE,程序在发生异常的地方继续执行,异常分发结束;如果第三个参数为DBG_EXCEPTION_NOT_HANDLED,跳到第7步。

7.异常没有被处理,程序以“应用程序错误”结束。

下图展示了这个流程:

读取寄存器和内存

获取寄存器的值

每个线程都有一个上下文环境,它包含了有关线程的大部分信息,例如线程栈的地址,线程当前正在执行的指令地址等。上下文环境保存在寄存器中,系统进行线程调度的时候会发生上下文切换,实际上就是将一个线程的上下文环境保存到内存中,然后将另一个线程的上下文环境装入寄存器。

获取某个线程的上下文环境需要使用GetThreadContext函数,该函数声明如下:

BOOL WINAPI GetThreadContext(
HANDLE hThread,
LPCONTEXT lpContext);

第一个参数是线程的句柄,第二个参数是指向CONTEXT结构的指针。要注意,调用该函数之前需要设置CONTEXT结构的ContextFlags字段,指明你想要获取哪部分寄存器的值。该字段的取值如下:

CONTEXT_CONTROL 获取EBP,EIP,CS,EFLAGS,ESP和SS寄存器的值。
CONTEXT_INTEGER 获取EAX,EBX,ECX,EDX,ESI和EDI寄存器的值。
CONTEXT_SEGMENTS 获取DS,ES,FS和GS寄存器的值。
CONTEXT_FLOATING_POINT 获取有关浮点数寄存器的值。
CONTEXT_DEBUG_REGISTERS 获取DR0,DR1,DR2,DR3,DR6,DR7寄存器的值。
CONTEXT_FULL 等于CONTEXT_CONTROL | CONTEXT_INTEGER | CONTEXT_SEGMENTS

调用GetThreadContext函数之后,CONTEXT结构相应的字段就会被赋值,此时就可以输出各个寄存器的值了。

对于其它寄存器来说,直接输出它的值就可以了,但是EFLAGS寄存器的输出比较麻烦,因为它的每一位代表不同的含义,我们需要将这些含义也输出来。一般情况下我们只需要了解以下标志:

标志 含义
CF 0 进位标志。无符号数发生溢出时,该标志为1,否则为0。
PF 2 奇偶标志。运算结果的最低字节中包含偶数个1时,该标志为1,否则为0。
AF 4 辅助进位标志。运算结果的最低字节的第三位向高位进位时,该标志为1,否则为0。
ZF 6 0标志。运算结果未0时,该标志为1,否则为0。
SF 7 符号标志。运算结果未负数时,该标志为1,否则为0。
DF 10 方向标志。该标志为1时,字符串指令每次操作后递减ESI和EDI,为0时递增。
OF 11 溢出标志。有符号数发生溢出时,该标志为1,否则为0。

用按位与操作就可以得知某个标志是否为1。

读取内存内容

读取进程的内存使用ReadProcessMemory函数,该函数声明如下:

BOOL WINAPI ReadProcessMemory(
    HANDLE hProcess,                  //进程句柄
    LPCVOID lpBaseAddress,            //要读取的地址
    LPVOID lpBuffer,                  //一个缓冲区的指针,保存读取到的内容
    SIZE_T nSize,                     //要读取的字节数
    SIZE_T* lpNumberOfBytesRead       //一个变量的指针,保存实际读取到的字节数
);

要想成功读取到进程的内存,需要两个条件:一是hProcess句柄具有PROCESS_VM_READ的权限;二是由lpBaseAddress和nSize指定的内存范围必须位于用户模式地址空间内,而且是已分配的。

对于调试器来说,第一个条件很容易满足,因为调试器对被调试进程具有完整的权限,可以对其进行任意操作。

第二个条件意味着我们不能读取进程任意地址的内存,而是有一个限制。Windows将进程的虚拟地址空间分成了四个分区,如下表所示:

分区 地址范围
空指针赋值分区 0x00000000~0x0000FFFF
用户模式分区 0x00010000~0x7FFEFFFF
64KB禁入分区 0x7FFF0000~0x7FFFFFFF
内核模式分区 0x80000000~0xFFFFFFFF

空指针赋值分区主要为了帮助程序员检测对空指针的访问,任何对这一分区的读取或写入操作都会引发异常。64KB禁入分区正如其名字所言,是禁止访问的,由Windows保留。内核模式分区由Windows的内核部分使用,运行于用户态的进程不能访问这一区域。进程只能访问用户模式分区的内存,对于其它分区的访问将会引发ACCESS_VIOLATION异常。

另外,并不是用户模式分区的任意部分都可以访问。我们知道,在32位保护模式下,进程的4GB地址空间是虚拟的,在物理内存中不存在。如果要使用某一部分地址空间的话,必须先向操作系统提交申请,让操作系统为这部分地址空间分配物理内存。只有经过分配之后的地址空间才是可访问的,试图访问未分配的地址空间仍然会引发ACCESS_VIOLATION异常。

调试符号

什么是调试符号

一个调试器应该可以跟踪被调试程序执行到了什么地方,显示下一条将要执行的语句,显示各个变量的值,设置断点,进行单步执行等等,这些功能都需要一个基础设施的支持,那就是调试符号。

所谓符号,简单来说就是源代码中每个对象的名称。例如变量、函数、类型等,它们都有一个名称,以及其它的相关信息:变量有类型、地址等信息;函数有返回值类型、参数类型、地址等信息;类型有长度等信息。编译器在编译每个源文件的时候都会收集该源文件中的符号的信息,在生成目标文件的时候将这些信息保存到符号表中。链接器使用符号表中的信息将各个目标文件链接成可执行文件,同时将多个符号表整合成一个文件,这个文件就是用于调试的符号文件,它既可以嵌入可执行文件中,也可以独立存在。

加载调试符号

一个进程会有多个模块,每个模块都有它自己的符号文件,有关符号文件的信息保存在模块的可执行文件中。DbgHelp通过符号处理器(Symbol Handler)来处理模块的符号文件。符号处理器位于调试器进程中,每个被调试的进程对应一个符号处理器。通常,调试器在被调试进程启动的时候创建符号处理器,在被调试进程结束的时候清理相应符号处理器占用的资源。

创建一个符号处理器使用SymInitialize函数,该函数声明如下:

BOOL WINAPI SymInitialize(
HANDLE hProcess,
PCTSTR UserSearchPath,
fInvadeProcess);

第一个参数是被调试进程的句柄,它是符号管理器的标识符,其它的DbgHelp函数都需要这样一个参数值指明使用哪个符号管理器。实际上这个参数不一定是句柄:当fInvadeProcess参数为TRUE时,它必须是一个有效的进程句柄;当fInvadeProcess为FALSE时,它可以是任意一个唯一的数值。

fInvadeProcess的作用是指示是否加载进程所有模块的调试符号,如果该参数为FALSE,那么SymInitialize只是创建一个符号处理器,不加载任何模块的调试符号,此时需要我们自己调用SymLoadModule64函数来加载模块;如果为TRUE,SymInitialize会遍历进程的所有模块,并加载其调试符号,所以在这种情况下hProcess必须是一个有效的进程句柄。

当fInvadeProcess为TRUE时,第二个参数UserSearchPath指示SymInitialize函数去哪里寻找符号文件。使用PDB符号文件的可执行文件中已包含有符号文件的绝对路径,如果符号文件不存在,SymInitialize就会使用UserSearchPath指定的路径去寻找符号文件。该参数可指定多个路径,以分号(;)分割。如果该参数为NULL,那么SymInitialize会按照以下的顺序寻找符号文件:

调试器进程的工作目录;

_NT_SYMBOL_PATH环境变量指定的路径;

_NT_ALTERNATE_SYMBOL_PATH环境变量指定的路径。

如果在以上路径中仍然找不到符号文件,SymInitialize并不会返回FALSE,而是返回TRUE。也就是说,它成功创建了符号处理器,并且加载了模块的信息,但是没有加载调试符号(关于如何判断某个模块是否加载了调试符号,下文会有讲解)。实际上,SymInitialize几乎不会返回FALSE,然而在某种情况下它会这么做,下面会有关于这方面的说明。

根据对SymInitialize的描述,有两种方法可以加载调试符号。第一种方法是在调用SymInitialize的时候第三个参数传入TRUE,由它负责加载每个模块的调试符号。这种方法的好处是方便,但是有一个前提:被调试进程必须初始化完毕。由于每个进程在初始化完毕之后都会引发一个断点异常,所以加载调试符号的最好的时机就是在处理这个初始断点的时候。

第二种方法是在调用SymInitialize的时候第三个参数传入FALSE,然后对每个模块调用SymLoadModule64函数加载调试符号。我们可以在处理CREATE_PROCESS_DEBUG_EVENT和LOAD_DLL_DEBUG_EVENT事件时分别加载exe文件和dll文件的调试符号。SymLoadModule64函数的声明如下:

DWORD64 WINAPI SymLoadModule64(
    HANDLE hProcess,
    HANDLE hFile,
    PCSTR ImageName,
    PCSTR ModuleName,
    DWORD64 BaseOfDll,
    DWORD SizeOfDll
);

第一个参数是符号处理器的标识符,也就是在调用SymInitialize时第一个参数的值。

第二个参数是模块文件的句柄,该函数通过这个文件句柄来获取有关符号文件的信息。你可能记得在CREATE_PROCESS_DEBUG_INFO和LOAD_DLL_DEBUG_INFO结构体中都有一个hFile的字段,这个字段刚好可以用在SymLoadModule64函数上。

第三个参数ImageName用于指定模块文件的路径和名称,当第二个参数为NULL时,SymLoadModule64会通过这里指定的路径和名称去寻找模块文件。一般情况下都不会使用这个参数,因为我们可以使用更可靠的hFile参数。

第四个参数ModuleName为该模块赋予一个名称,在使用其它DbgHelp函数的时候可以通过这个名称来引用模块。如果该参数为NULL,SymLoadModule64会使用符号文件的文件名作为模块名称。

第五个参数BaseOfDll是模块加载到进程地址空间之后的基地址。这个参数很重要,因为符号文件中每个符号的地址都是相对于模块基地址的偏移地址,而不是绝对地址,这样的话,不论模块被加载到哪个地址,它的符号文件都是可用的。当然,这一切的前提是你将正确的模块基地址传给了SymLoadModule64函数。幸运的是,CREATE_PROCESS_DEBUG_INFO和LOAD_DLL_DEBUG_INFO结构体中已包含了一个lpBaseOfImage字段,我们直接使用即可,不必为了获取模块基地址而大动干戈。

至于最后一个参数SizeOfDll,表示模块文件的大小。

判断符号文件的格式

前面说过,SymInitialize在找不到符号文件的情况下仍然会返回TRUE,此时它只加载了模块的信息,而没有加载调试符号。SymLoadModule64函数同样如此。那么,如何知道某个模块是否含有调试信息呢?或者,如何知道某个模块的符号文件使用哪种格式呢?可以通过调用SymGetModuleInfo64函数来获取这些信息。该函数的声明如下:

BOOL WINAPI SymGetModuleInfo64(
HANDLE hProcess,
DWORD64 dwAddr,
PIMAGEHLP_MODULE64 ModuleInfo);

第一个参数是符号处理器的标识符,现在你应该对它很熟悉了。第二个参数是模块的基地址,也就是在调用SymLoadModule64时传给BaseOfDll参数的值。第三个参数是指向IMAGEHLP_MODULE64结构体的指针,调用函数完成之后模块的信息将会保存到这个结构体中。

IMAGEHLP_MODULE64结构体含有非常多的字段,不过我们一般只关心其中的一个:SymType。这个字段指示模块使用的是哪种格式的符号文件,其可能的取值如下:

SymCoff COFF格式。
SymCv CodeView 格式。
SymDeferred 调试符号是延迟加载的。下文会提及。
SymDia DIA 格式。
SymExport 符号是从DLL文件的导出表中生成的。
SymNone 没有调试符号。
SymPdb PDB格式。
SymSym 使用.sym类型的符号文件。
SymVirtual 与SymLoadModuleEx函数的最后一个参数有关,还未知道什么意思。

在调用SymGetModuleInfo64之前需要将IMAGEHLP_MODULE64结构体的SizeOfStruct字段设置为sizeof(IMAGEHLP_MODULE64);

清理调试符号

在被调试进程结束的时候必须删除与之对应的符号处理器,以及清理它占用的资源。只要在处理EXIT_PROCESS_DEBUG_EVENT事件的时候调用SymCleanup函数就可以完成这个操作,该函数接受一个符号处理器的标识符。

另外,在dll文件卸载的时候也应该清理与之相关的调试符号,避免占用内存。这要在处理UNLOAD_DLL_DEBUG_EVENT事件时调用SymUnloadModule64函数。该函数接受一个符号处理器的标识符,以及模块的基地址,我们可以直接使用UNLOAD_DLL_DEBUG_INFO结构体中唯一的字段lpBaseOfDll。

显示源代码

实现显示源代码的功能大概需要一下几个步骤:

  • 获取下一条要执行的指令的地址。
  • 通过调试符号获取该地址对应哪个源文件的哪一行。

③  对于其它的行,通过调试符号获取它对应的地址。

第一步可以通过获取EIP寄存器的值来完成,相关的内容已经在上文中进行了讲解,这里不再重复。下面讲一下如何实现第二个和第三个步骤。

获取源文件以及行号

在调试符号中,记录了每一行源代码对应的地址。通过DbgHelp的SymGetLineFromAddr64函数可以由地址获取源文件路径以及行号。该函数的声明如下:

BOOL WINAPI SymGetLineFromAddr64(
HANDLE hProcess,
DWORD64 dwAddr,
PDWORD pdwDisplacement,
PIMAGEHLP_LINE64 Line);

hProcess参数是符号处理器的标识符,dwAddr是指令的地址。pdwDisplacement是一个输出参数,用于获取dwAddr相对于它所在行的起始地址的偏移量,以字节为单位。之所以需要这么一个参数,是因为一行代码可能对应多条汇编指令,有了它就可以知道下一条要执行的指令位于这一行代码的哪个位置。

第四个参数是指向IMAGEHLP_LINE64结构体的指针,该结构体用来保存有关于行的信息,其声明如下:

typedef struct _IMAGEHLP_LINE64 {
    DWORD SizeOfStruct;
    PVOID Key;
    DWORD LineNumber;
    PTSTR FileName;
    DWORD64 Address;
} IMAGEHLP_LINE64, *PIMAGEHLP_LINE64;

SizeOfStruct字段保存结构体的大小,在调用SymGetLineFromAddr64之前需要初始化这个字段,否则函数调用会失败。Key字段是由操作系统保留的,我们不需要使用它。FileName和LineNumber字段分别是源文件的绝对路径以及行号。Address是该行的起始地址。

要注意,FileName字段是一个指向字符串的指针,而这个字符串的存储空间并不需要我们自己分配,我们也不需要释放这个指针指向的内存。实际上这个指针指向了调试符号内的某个地方,我们可以读取这些数据,但是不能修改其中的数据,一旦这些数据被修改,其它的DbgHelp函数可能会出现奇怪的问题。如果一定要修改这个字符串,要先将它复制到另一个地方再进行操作。我很奇怪为什么这个字段不是PCTSTR类型的,这样的话就不必担心这个字符串被修改了。

调用SymGetLineFromAddr64成功的条件有两个:一是dwAddr的值所在的模块已经通过SymLoadModule64函数加载到符号处理器中;二是该模块含有SymGetLineFromAddr64所需的调试符号信息。如果第一个条件没有满足,GetLastError返回126;如果第二个条件没有满足,GetLastError返回487。

获取行的地址

通过SymGetLineFromAddr64可以获取指令对应的源文件以及行号,那么能不能根据源文件路径以及行号获取行的地址呢?当然可以,SymGetLineFromName64函数就是用作此目的的。该函数的声明如下:

BOOL WINAPI SymGetLineFromName64(
    HANDLE hProcess,
    PCTSTR ModuleName,
    PCTSTR FileName,
    DWORD dwLineNumber,
    PLONG lpDisplacement,
    PIMAGEHLP_LINE64 Line
);

该函数与SymGetLineFromAddr64很相似,都是通过IMAGEHLP_LINE64结构体来返回行的信息,并且都有一个displacement输出参数,不过这个参数在两个函数中的意义大不相同,下面将会详述。首先来看一下其它参数的含义。

ModuleName用于指定模块的名称,上文中讲解SymLoadModule64函数时提到的ModuleName参数就可以用在这个地方(奇怪的是SymLoadModule64的ModuleName参数是PCSTR类型,而SymGetLineFromName64的ModuleName参数却是PCTSTR类型)。当FileName参数只指定了文件名,而多个模块中含有同名的源文件时,SymGetLineFromName64就使用这个参数确定使用哪个模块的源文件。如果各个模块都没有同名的源文件,或者FileName指定的是绝对路径时,这个参数就没有必要了,指定为NULL即可。

FileName和dwLineNumber 参数分别指定源文件和行号。FileName可以是文件名,也可以是绝对路径,正如上面的描述那样。dwLineNumber是任意非零值,即使行号在源文件中不存在,甚至是负数,SymGetLineFromName64也会返回TRUE!那么我们如何知道指定的行号是否有效呢?只要检查displacement的值即可。大多数情况下,displacement表示指定行与最接近该行的有效行的行号之差,而且有效行的行号要小于等于指定行的行号。所谓有效行即能够产生汇编指令的行(能产生汇编指令才会有对应的地址),可以用下面的式子表示(式中的变量均使用函数参数的名字):

*lpDisplacement = dwLine - Line->LineNumber  (dwLine >= Line->LineNumber)

断点:

断点是最基本和最重要的调试技术之一,下面讲解如何在调试器中实现断点功能。

什么是断点

在进行调试的时候,只有被调试进程暂停执行时调试器才可以对它执行操作,例如观察内存内容等。如果被调试进程不停下来的话,调试器是什么也做不了的。要使被调试进程停下来,除了几个在特定时刻才发生的调试事件外,唯一的途径就是引发异常。

断点正是用来达到上述目的的异常,在上文中的异常代码表中,有一种EXCEPTION_BREAKPOINT异常,它就是断点异常。虽然断点是一种异常,但并不意味着被调试进程发生了问题,它只是用来调试的一种手段,所以调试器应该将它和别的异常明显区分开来。实际上Windows对断点异常的处理也有一些微妙的不同,下文将会讲到。

断点有软件断点和硬件断点之分。硬件断点是通过CPU的寄存器来设置的,它的功能很强大,既可以在代码中设置断点,也可以在数据中设置断点,但是可以设置的数量有限。软件断点即通过int 3指令引发的断点,机器码是0xCC,它只能设置在代码中,但没有数量的限制。本文只关注软件断点。

陷阱标志

除了断点之外,CPU本身提供了一个单步执行的功能,也可以使程序在某处中断。在CPU的标志寄存器中,有一个TF(Trap Flag)位,当该位为1时,CPU每执行一条指令就会引发一次中断,Windows以单步执行异常来通知调试器,异常代码为EXCEPTION_SINGLE_STEP。每引发一次中断,CPU都会自动将TF位设为0,所以如果想连续单步执行多条指令,需要在每次处理单步执行异常时都重新设置TF位。

单步执行异常属于错误异常,引发异常的地址与EIP指向的地址相同。

断点功能原理:

为了可以在任何指令处设置断点,调试器要将指令的第一个字节替换成0xCC(int 3的机器码),接收到断点异常之后,再替换回原来的那个字节,从该指令开始继续执行。这样就实现了在任意指令处中断,并对原程序毫无影响。

例如,下面的赋值语句对应一条汇编指令:

int b = 2;
C7 45 F8 02 00 00 00    mov    dword ptr [b],2 

这条指令有7个字节。假如调试器想要在这条语句设置断点,它首先将指令的第一个字节0xC7保存起来,然后替换成0xCC:

CC 45 F8 02 00 00 00

此时原来的mov指令变成了一条int 3指令和6个字节的垃圾数据。被调试程序不知道这个变化,它逐条指令地执行,到了int 3指令之后引发断点异常,暂停执行。此时被调试程序不能再往下执行了,因为接下来的6个字节是垃圾数据,尝试执行的话肯定会失败。

调试器可以选择在第一次或第二次接收断点异常时进行处理。如果在第一次接收时处理,它就要主动将被调试进程的EIP减。如果在第二次接收时处理,就不需要修改被调试进程的EIP了,因为正如上文所说,第二次接收断点异常时Windows已经将EIP减1了。无论何时处理异常,调试器都要将0xCC替换回原来的0xC7,然后以DBG_CONTINUE继续被调试进程执行。

最后还有一个问题需要留意,如果断点设置在循环的内部,或者设置在一个被多次调用的函数中,那么该断点只会中断一次,因为它在第一次中断之后就被取消了。为了让它持续有效,我们需要一种机制,让断点所在的指令执行完之后重新设置该断点。这可以借助TF位的帮助:处理断点异常的时候,在取消断点之后立即设置TF位,然后继续执行;在捕捉到单步执行异常时重新设置断点。

完整的断点功能流程图如下:

实现断点功能:

了解了断点功能的原理,下面就来逐步实现这个功能。这里只描述大概的思路

首先是要确定断点的地址,注意,断点只能设置在指令的第一个字节,否则会破坏指令的结构,导致被调试进程无法执行。

确定地址之后就要替换指令第一个字节。读取这个字节可以使用ReadProcessMemory函数,写入字节可以使用WriteProcessMemory函数。

调试器必须保存一份断点列表,最好用一个结构体来表示断点,例如:

typedef struct {
DWORD address;    //断点地址
BYTE content;     //原指令第一个字节
} BREAK_POINT;

接下来是处理断点异常的方式。应该将断点分成三种类型:初始断点,被调试进程中的断点,以及调试器设置的断点。对于初始断点,不需要进行任何处理,因为它是由Windows管理的。如果对初始断点应用了以上的处理过程,被调试进程会无法运行。被调试进程中的断点即代码中显式加入的断点,对于这类断点,只要在第一次接收断点异常时报告给用户即可,不需要进行其它处理。而调试器设置的断点就要按照上文所说的方法来处理了。

如果选择在第一次接收断点异常时进行处理,那么需要使用SetThreadContext函数设置被调试进程的EIP,该函数的参数与GetThreadContext完全一致。为了避免修改EIP而影响到其它的寄存器,应该先调用GetThreadContext填充CONTEXT结构,再调用SetThreadContext。例如:

CONTEXT context;
context.ContextFlags = CONTEXT_CONTROL;
GetThreadContext(g_hThread, &context);
context.Eip -= 1;
SetThreadContext(g_hThread, &context);

设置TF位的方法与设置EIP的方法一致,同样是先调用GetThreadContext,然后修改Eflags字段的值,再调用SetThreadContext。TF位是EFLAGS寄存器中的第8位(从0开始算),通过下面的语句可以设置TF位:

ontext.EFlags |= 0x100;

在处理单步执行异常时,不能简单认为EIP减1就是原断点的地址,因为断点所在指令的长度是不确定的。为了重新设置断点,需要保存该断点的地址,或者干脆将所有断点都重新设置一次。

在main函数设置断点

如果按照上面的处理方法将初始断点忽略之后,带来了一个新的问题:被调试进程此时不会在初始断点发生时暂停,而是一直运行到结束,我们根本没机会对它进行任何操作。解决这个问题的方法就是在Main函数的入口处设置断点。这里所说的Main函数是一个统称,指代下面四个入口函数:

main
wmain
WinMain
wWinMain

一个C/C++应用程序的入口函数必定是上面四个的其中之一。

为了在Main函数处设置断点,首先要知道它的地址,这就需要调试符号的帮助了。一个函数是一个符号,可以通过SymFromName函数根据符号名称获取符号的信息。该函数的声明如下:

BOOL WINAPI SymFromName(
HANDLE hProcess,
PCTSTR Name,
PSYMBOL_INFO Symbol);

第一个参数是符号处理器的标识符;第二个参数是符号的名称;第三个参数是指向SYMBOL_INFO结构体的指针,函数调用成功后符号的信息就保存在这个结构体中。该结构体的定义如下:

typedef struct _SYMBOL_INFO {
    ULONG SizeOfStruct;
    ULONG TypeIndex;
    ULONG64 Reserved[2];
    ULONG Index;
    ULONG Size;
    ULONG64 ModBase;
    ULONG Flags;
    ULONG64 Value;
    ULONG64 Address;
    ULONG Register;
    ULONG Scope;
    ULONG Tag;
    ULONG NameLen;
    ULONG MaxNameLen;
    TCHAR Name[1];
} SYMBOL_INFO, *PSYMBOL_INFO;

单步执行

单步执行有三种类型:StepIn,StepOver和StepOut,下面进行讲解每种单步执行的原理

StepIn原理

StepIn即逐条语句执行,遇到函数调用时进入函数内部。当用户对调试器下达StepIn命令时,调试器的实现方式如下:

  • 通过调试符号获取当前指令对应的行信息,并保存该行的信息。
  • 设置TF位,开始CPU的单步执行。

③  在处理单步执行异常时,获取当前指令对应的行信息,与①中保存的行信息进行比较。如果相同,表示仍然在同一行上,转到②;如果不相同,表示已到了不同的行,结束StepIn。

行信息可以使用上文中介绍过的SymGetLineFromAddr64函数来获取。StepIn的原理比较简单,需要注意的一点是,断点也是使用TF位来达到重新设置的目的,所以在处理单步执行异常时需要判断是否重新设置断点。另外,如果进行StepIn的那行代码存在断点,调试器只要恢复该断点所在指令就可以了,不需要通知用户;如果那行代码含有__asm { int 3};语句,调试器只要忽略它即可,也不需要通知用户。

StepOver原理

StepOver即逐条语句执行,遇到函数调用时不进入函数内部。当用户对调试器下达StepOver命令时,调试器的实现方式如下:

  • 通过调试符号获取当前指令对应的行信息,并保存该行的信息。
  • 检查当前指令是否CALL指令。如果是,则在下一条指令设置一个断点,然后让被调试进程继续运行;如果不是,则设置TF位,开始CPU的单步执行,跳到④。

③  处理断点异常时,恢复断点所在指令第一个字节的内容。然后获取当前指令对应的行信息,与①中保存的行信息进行比较,如果相同,跳到②;否则停止StepOver。

④  处理单步执行异常时,获取当前指令对应的行信息,与①中保存的行信息进行比较。如果相同,跳到②;否则停止StepOver。

StepOver的原理与StepIn基本相同,唯一的不同就是遇到CALL指令时跳过,跳过的方法是在下一条指令设置断点,然后令被调试进程全速执行,触发断点之后再设置TF位,进行CPU的单步执行。在全速执行时,用户设置的断点和被调试进程中的断点不应该忽略,而应该像正常的执行那样中断并通知用户,此时应该停止StepOver。在进行CPU的单步执行时,则应该像StepIn那样忽略断点。StepOver需要断点来实现,所以应该将StepOver使用的断点和其它类型的断点区分开来。

StepOut原理

StepOut即跳出当前正在执行的函数,立即回到上一层函数。当用户对调试器下达StepOut命令时,调试器的实现方式如下:

  • 获取当前函数的RET指令地址,并在该指令设置断点,让被调试进程继续执行。
  • 处理断点异常时,恢复断点所在指令的第一个字节。从线程栈的顶部中获取返回地址,在该地址设置断点,然后让被调试进程继续执行。
  • 再次处理断点异常,恢复断点所在指令的第一个字节,结束StepOut。

StepOut有两个难点:一是如何获取RET指令的地址,二是如何获取函数的返回地址。对于第一个问题,可以使用SymFromAddr函数来获取函数的相关信息。SymFromAddr函数的作用是由地址获取相应符号的信息,符号可以是变量或者函数。其声明如下:

BOOL WINAPI SymFromAddr(
HANDLE hProcess,
DWORD64 Address,
PDWORD64 Displacement,
PSYMBOL_INFO Symbol );

第一个参数是符号处理器的标识符。第二个参数是地址。第三个参数是输出参数,函数调用成功之后返回Address相对于符号起始地址的偏移。第四个参数是指向SYMBOL_INFO结构体的指针,函数调用成功后与符号相关的信息都保存在这个结构体中。

我们可以将当前被调试进程的EIP作为地址传给SymFromAddr,以得到当前函数的信息。SYMBOL_INFO的Address字段保存了函数第一条指令的地址,Size字段保存了函数所有指令的字节长度,由于RET指令是函数的最后一条指令,所以Address加上Size再减去RET指令的长度就是RET指令的地址。那么接下来的问题就是要获得RET指令的长度。RET指令比CALL指令简单得多,只有两种形式,一种只有一个字节长,十六进制值为0xC3或0xCB;另一种有三个字节长,第一个字节的十六进制值为0xC2或0xCA,后面两个字节是ESP将要加上的值。

第二个难点是获取函数的返回地址。如果你熟悉函数的调用过程,肯定知道在即将执行RET指令时,线程栈的顶部,也就是ESP所指的内存位置,必定是函数的返回地址。这就是在RET指令设置断点的目的。

断点异常处理过程图

单步执行异常处理过程图

显示函数调用栈

原理

首先我们来看一下显示调用栈所依据的原理。每个线程都有一个栈结构,用来记录函数的调用过程,这个栈是由高地址向低地址增长的,即栈底的地址比栈顶的地址大。ESP寄存器的值是栈顶的地址,通过增加或减小ESP的值可以缩减或扩大栈的大小。如下所示为详细过程:

  •  在栈上压入参数。
  • 执行CALL指令,在栈上压入函数的返回地址。
  •  压入EBP寄存器的值。
  • 将ESP寄存器的值赋给EBP寄存器。
  •  减小ESP寄存器的值,为局部变量分配空间。
  • 执行函数代码。
  •  将EBP寄存器的值赋给ESP寄存器,等于回收了局部变量的空间。
  • 弹出栈顶的值,赋给EBP,即将第③步中压入的值重新赋给EBP。

⑨   执行RET指令,弹出栈顶的返回地址。如果被调用函数负责回收参数的空间,则需要增加ESP的值。

完成第③步的指令是push ebp,它是所有函数的第一条指令,因此每个函数在栈上都会保存有一个EBP值,标志了一个函数调用的开始,这就像分界线一样,将每个函数调用区分开来。从一个分界线开始,到下一个分界线之间的部分称作“栈帧”,一个栈帧代表一个函数调用。

在压入了EBP的值之后,第④步立即将ESP的值赋给了EBP,此时ESP和EBP的值都是刚刚压入的值的地址。从此之后,ESP的值随着指令的执行不断变化,而EBP的值在当前栈帧中永远不会改变,一直指向当前栈帧的起始地址,所以EBP也被称为“栈帧指针”。

函数返回的时候,第⑦步将EBP的值赋给ESP,此时ESP指向第③步压入的值,然后第⑧步弹出这个值,赋给EBP,恢复EBP在上一个函数调用中的值。

函数调用过程的第③步和第④步使得各个压入的EBP值形成了一个链表的结构,而EBP寄存器是链表的表头

正是这种链表结构的存在,使得获取函数调用栈成为可能。只要从EBP寄存器开始,沿着链表层层往上,就可以得到函数调用的轨迹。

由于EBP在当前函数调用中的不变性,调试版的程序都使用EBP作为变量和参数的基址,将EBP的值与一个偏移值相加就可以得到变量或参数的地址。有些发行版的程序会对函数的调用过程进行优化,省略了压入EBP的步骤,因此不能再使用EBP作为变量和参数的基址,也不能使用EBP链表来获取函数调用栈。

在DbgHelp中主要使用StackWalk64函数来获取函数调用栈,该函数的声明如下:

BOOL WINAPI StackWalk64(
    DWORD MachineType,
    HANDLE hProcess,
    HANDLE hThread,
    LPSTACKFRAME64 StackFrame,
    PVOID ContextRecord,
    PREAD_PROCESS_MEMORY_ROUTINE64 ReadMemoryRoutine,
    PFUNCTION_TABLE_ACCESS_ROUTINE64 FunctionTableAccessRoutine,
    PGET_MODULE_BASE_ROUTINE64 GetModuleBaseRoutine,
    PTRANSLATE_ADDRESS_ROUTINE64 TranslateAddress
);

该函数的参数比较多,这意味着灵活性,同时也意味着复杂性。事实上StackWalk64有多种不同的使用方式,使用何种方式由传入的参数决定。在这里我只介绍一种最简单的方式,这种方式已经足够了。如果想了解更多有关StackWalk64的信息,请参考MSDN。

MachineType参数指定CPU的类型,它的取值范围以及意义如下表所示(摘自MSDN):

Value Meaning
IMAGE_FILE_MACHINE_I386 Intel x86
IMAGE_FILE_MACHINE_IA64 Intel Itanium Processor Family (IPF)
IMAGE_FILE_MACHINE_AMD64 x64 (AMD64 or EM64T)

调试器需要根据CPU的类型来设置该参数的值。在目前,大部分情况下都是设置为IMAGE_FILE_MACHINE_I386。

hProcess和hThread分别指定被调试进程的进程句柄以及线程句柄。而且在当前所使用的方式下,hProcess必须是符号处理器的标识符。如果在调用SymInitialize创建符号处理器时使用的就是进程的句柄,那么在这里不会有任何问题;如果不是使用进程句柄,那么就必须用另一种方式调用StackWalk64了。

StackFrame参数是一个STACKFRAME64结构体的指针,在调用StackWalk64之前需要初始化这个结构体,函数调用成功后,前一个栈帧的信息会保存到该结构体中;然后用这些信息再次调用StackWalk64,以获取再前一个栈帧的信息……由此看出,StackWalk64的工作就是获取指定栈帧的前一个栈帧,所以,必须要在循环中获取所有栈帧。STACKFRAME64结构体中需要初始化的字段有三个:AddrPC,AddrStack和AddrFrame,它们分别表示程序计数器,线程栈顶以及栈帧指针,也是EIP,ESP和EBP的用途。这三个字段又分别是一个ADDRESS64结构体,这个结构体可以表示多种不同类型的地址,但Windows应用程序只会使用虚拟地址,所以Mode字段应设为AddrModeFlat,Offset字段设为上述寄存器的值。STACKFRAME64结构体的初始化代码如下所示(context为CONTEXT结构):

STACKFRAME64 stackFrame = { 0 };
stackFrame.AddrPC.Mode = AddrModeFlat;
stackFrame.AddrPC.Offset = context.Eip;
stackFrame.AddrStack.Mode = AddrModeFlat;
stackFrame.AddrStack.Offset = context.Esp;
stackFrame.AddrFrame.Mode = AddrModeFlat;
stackFrame.AddrFrame.Offset = context.Ebp;

第一次成功调用StackWalk64后,STACKFRAME64结构体的其它字段会被设置为适当的值,而上述的三个字段不会改变。从第二次调用开始才会真正获取前一个栈帧,这三个字段才会改变。

ContextRecord参数是指向CONTEXT结构体的指针,调用之前需要使用GetThreadContext初始化该结构体,StackWalk64函数会使用里面的值,并有可能会修改它。

ReadMemoryRoutine是一个回调函数的指针,当StackWalk64函数需要读取被调试进程的内存时会调用该函数。如果不想提供这样的函数,最简单的方法就是设置该参数为NULL,这样StackWalk64就会使用默认的函数,此时hProcess必须是一个有效的进程句柄。

FunctionTableAccessRoutine也是一个回调函数的指针,当StackWalk64需要访问函数表时会调用该函数。简单来说,函数表保存了每一个函数的信息,比如起始地址,长度等。这个参数不能为NULL,但是我们可以将它设置为一个已有的函数,这个函数就是SymFunctionTableAccess64。此时hProcess必须是符号处理器的标识符。

GetModuleBaseRoutine又是一个回调函数的指针,当StackWalk64需要获取模块的基地址时会调用该函数。将该参数设置为GetModuleBase64函数即可,此时hProcess也必须是符号处理器的标识符。

最后的参数TranslateAddress仍然是回调函数的指针,不过该参数只用于16位地址的转换,几乎不会用到,设置为NULL即可。

可以看到,选择StackWalk64的何种使用方式由后面的四个参数决定。最简单的使用方式就是直接使用NULL或DbgHelp提供的函数作为这几个参数的值,不过此时对hProcess和hThread的限制最大。我们也可以自己提供这几个回调参数,此时hProcess和hThread几乎没有什么限制,它们只是作为唯一标识符。

StackWalk64调用成功后,STACKFRAME64结构体被赋值,在众多的字段中,我们只需要关心AddrPC,它表示栈帧的返回地址(除了第一次调用StackWalk64之外),即CALL指令下一条指令的地址,本文开头的图片中显示的地址就是AddrPC.Offset的值。由于返回地址是指向前一个栈帧的,所以每次调用StackWalk64都会使STACKFRAME64结构体填充前一个栈帧的信息。StackWalk64只能获取用户模式下的栈帧,如果栈帧遍历完毕,它会返回FALSE。

获取模块名称:

显示函数调用栈时最好同时显示函数所在的模块,这样可以方便知道每个函数位于哪个模块。有一个SymGetModuleInfo64函数可以获取模块的信息,但是却不可以获取模块的名称,而另一个SymEnumerateModules64函数可以做到这点,虽然它的使用方式比较麻烦。SymEnumerateModules64用于枚举所有已经加载到符号处理器中的模块,它的声明如下:

BOOL WINAPI SymEnumerateModules64(
HANDLE hProcess,
PSYM_ENUMMODULES_CALLBACK64 EnumModulesCallback,
PVOID UserContext);

ModuleName是模块文件的绝对路径;BaseOfDll是模块的基地址;UserContext就是SymEnumerateModules64的第三个参数,可以通过这个参数给回调函数传递更多信息。

未完待续~

如果您需要了解更多内容,可以
加入QQ群:570982169
直接询问:010-68438880

Spread the word. Share this post!

Meet The Author

Leave Comment