本节书摘来自异步社区出版社《c++ 黑客编程揭秘与防范(第2版)》一书中的第6章,第6.6节,作者:冀云,更多章节内容可以访问云栖社区“异步社区”公众号查看。
c++ 黑客编程揭秘与防范(第2版)
windows中有些api函数是专门用来进行调试的,被称作debug api,或者是调试api。利用这些函数可以进行调试器的开发,调试器通过创建有调试关系的父子进程来进行调试,被调试进程的底层信息、即时的寄存器、指令等信息都可以被获取,进而用来分析。
上面介绍的ollydbg调试器的功能非常强大,虽然有众多的功能,但是其基础的实现就是依赖于调试api。调试api函数的个数虽然不多,但是合理使用会产生非常大的作用。调试器依赖于调试事件,调试事件有着非常复杂的结构体。调试器有着固定的流程,由于实时需要等待调试事件的发生,其过程是一个调试循环体,非常类似于sdk开发程序中的消息循环。无论是调试事件还是调试循环,对于调试或者说调试器来说,其最根本、最核心的部分是中断,或者说其最核心的部分是可以捕获中断。
6.6.1 常见的3种断点方法
在前面介绍od的时候提到过,产生中断的方法是设置断点。常见的产生中断的断点方法有3种,分别是中断断点、内存断点和硬件断点。下面介绍这3种断点的不同。
中断断点,这里通常指的是汇编语言中的int 3指令,cpu执行该指令时会产生一个断点,因此也常称之为int3断点。现在演示如何使用int 3来产生一个断点,代码如下:
可以看出,这里给的address是一个va(虚拟地址),用od打开这个程序,直接按f9键运行,如图6-65和图6-66所示。
从图6-65中可以看到,程序执行停在了00401029位置处。从图6-66看到,int3命令位于00401028位置处。再看一下图6-64中address后面的值,为00401028。这也就证明了在系统的错误报告中可以给出正确的出错地址(或产生异常的地址)。这样在以后写程序的过程中可以很容易地定位到自己程序中有错误的位置。
注:在od中运行自己的int 3程序时,可能od不会停在00401029地址处,也不会给出类似图6-65的提示。在实验这个例子的时候需要对od进行设置,在菜单中选择“选项”->“调试设置”,打开“调试选项”对话框,选择“异常”选项卡,取消“int3 中断”复选框的选中状态,这样就可以按照该例子进行测试了。
回到中断断点的话题上,中断断点是由int 3产生的,那么要如何通过调试器(调试进程)在被调试进程中设置中断断点呢?看图6-65中00401028地址处,在地址值的后面、反汇编代码的前面,中间那一列的内容是汇编指令对应的机器码。可以看出,int3对应的机器码是0xcc。如果想通过调试器在被调试进程中设置int3断点的话,那么只需要把要中断的位置的机器码改为0xcc即可。当调试器捕获到该断点异常时,修改为原来的值即可。
内存断点的方法同样是通过异常产生的。在win32平台下,内存是按页进行划分的,每页的大小为4kb。每一页内存都有其各自的内存属性,常见的内存属性有只读、可读写、可执行、可共享等。内存断点的原理就是通过对内存属性的修改,本该允许进行的操作无法进行,这样便会引发异常。
在od中关于内存断点有两种,一种是内存访问,另一种是内存写入。用od随便打开一个应用程序,在其“转存窗口”(或者叫“数据窗口”)中随便选中一些数据点后单击右键,在弹出的菜单中选择“断点”命令,在“断点”子命令下会看到“内存访问”和“内存写入”两种断点,如图6-67所示。
图6-67 内存断点类型
下面通过简单例子来看如何产生一个内存访问异常,代码如下:
bool createprocess(
lpctstr lpapplicationname, // name of executable module
lptstr lpcommandline, // command line string
lpsecurity_attributes lpprocessattributes, // sd
lpsecurity_attributes lpthreadattributes, // sd
bool binherithandles, // handle inheritance option
dword dwcreationflags, // creation flags
lpvoid lpenvironment, // new environment block
lpctstr lpcurrentdirectory, // current directory name
lpstartupinfo lpstartupinfo, // startup information
lpprocess_information lpprocessinformation // process information
);<code>`</code>
现在要做的是创建一个被调试进程。createprocess()函数有一个dwcreationflags参数,其取值中有两个重要的常量,分别为debug_process和debug_only_this_process。debug_
process的作用是被创建的进程处于调试状态。如果一同指定了debug_only_ this_process的话,那么就只能调试被创建的进程,而不能调试被调试进程创建出来的进程。只要在使用createprocess()函数时指定这两个常量即可。
除了createprocess()函数以外,还有一种创建调试关系的方法,该方法用的函数如下:
winbaseapi
bool
winapi
debugactiveprocessstop(
__in dword dwprocessid
);<code>`</code>
该函数只有一个参数,就是被调试进程的进程id号。使用该函数可以在不影响调试器进程和被调试进程的正常运行的情况下,将两者的关系解除。但是有一个前提,被调试进程需要处于运行状态,而不是中断状态。如果被调试进程处于中断状态时和调试进程解除调试关系,由于被调试进程无法运行而导致退出。
2.判断进程是否处于被调试状态
很多程序都要检测自己是否处于被调试状态,比如游戏、病毒,或者加壳后的程序。游戏为了防止被做出外挂而进行反调试,病毒为了给反病毒工程师增加分析难度而反调试。加壳程序是专门用来保护软件的,当然也会有反调试的功能(该功能仅限于加密壳,压缩壳一般没有反调试功能)。
本小节不是要介绍反调试,而是介绍一个简单的函数,这个函数是判断自身是否处于被调试状态,函数名为isdebuggerpresent(),其定义如下:
<code>bool isdebuggerpresent(void);</code>
该函数没有参数,根据返回值来判断是否处于被调试状态。这个函数也可以用来进行反调试。不过由于这个函数的实现过于简单,很容易就能够被分析者突破,因此现在也没有软件再使用该函数来进行反调试了。
下面通过一个简单的例子来演示isdebuggerpresent()函数的使用,代码如下:
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;<code>`</code>
这个结构体非常重要,这里有必要详细地介绍。
dwdebugeventcode:该字段指定了调试事件的类型编码。在调试过程中可能产生的调试事件非常多,因此要根据不同的类型码进行不同的响应处理。常见的调试事件如图6-74所示。
图6-74 dwdebugeventcode的取值
dwprocessid:该字段指明了引发调试事件的进程id号。
dwthreadid:该字段指明了引发调试事件的线程id号。
u:该字段是一个联合体,其取值由dwdebugeventcode指定。该联合体包含很多个结构体,包括exception_debug_info、create_thread_ debug_info、create_pro cess_debug_
info、exit_thread_debug_info、exit_process_debug_info、load_dll_debug_
info、unload_dll_debug_info和output_debug_string_info。
在以上众多的结构体中,特别要介绍一下exception_debug_info,因为这个结构体包含关于异常相关的信息;而其他几个结构体的使用比较简单,读者可以参考msdn。
exception_debug_info的定义如下:
typedef struct _exception_record {
dword exceptioncode;
dword exceptionflags;
struct _exception_record *exceptionrecord;
pvoid exceptionaddress;
dword numberparameters;
ulong_ptr exceptioninformation[exception_maximum_parameters];
} exception_record, *pexception_record;<code>`</code>
exceptioncode:异常码。该值在msdn中的定义非常多,不过这里需要使用的值只有3个,分别是exception_access_violation(访问违例)、exception_ breakpoint(断点异常)和exception_single_step(单步异常)。这3个值中的前两个值对于读者来说应该是非常熟悉的,因为在前面已经介绍过了;最后一个单步异常想必读者也非常熟悉。使用od快捷键的f7键、f8键时就是在使用单步功能,而单步异常就是由exception_single _step来表示的。
exceptionrecord:指向一个exception_record的指针,异常记录是一个链表,其中可能保存着很多异常信息。
exceptionaddress:异常产生的地址。
调试事件这个结构体debug_event看似非常复杂,其实也只是嵌套得比较深而已。只要读者仔细体会每个结构体、每层嵌套的含义,自然就觉得它没有多么复杂。
5.调试循环
调试器不断地对被调试目标进程进行捕获调试信息,有点类似于win32应用程序的消息循环,但是又有所不同。调试器在捕获到调试信息后进行相应的处理,然后恢复线程,使之继续运行。
用来等待捕获被调试进程调试事件的函数是waitfordebugevent(),其定义如下:
bool continuedebugevent(
dword dwprocessid, // process to continue
dword dwthreadid, // thread to continue
dword dwcontinuestatus // continuation status
dwprocessid:该参数表示被调试进程的进程标识符。
dwthreadid:该参数表示准备恢复挂起线程的线程标识符。
dwcontinuestatus:该参数指定了该线程以何种方式继续执行,其取值为dbg_excepti on_not_handled和dbg_continue。对于这两个值来说,通常情况下并没有什么差别。但是当遇到调试事件中的调试码为exception_debug_event时,这两个常量就会有不同的动作。如果使用dbg_exception_not_handled,调试器进程将会忽略该异常,windows会使用被调试进程的异常处理函数对异常进行处理;如果使用dbg_continue的话,那么需要调试器进程对异常进行处理,然后继续运行。
由上面两个函数配合调试事件结构体,就可以构成一个完整的调试循环。以下这段调试循环的代码摘自msdn:
//
// context frame
// this frame has a several purposes: 1) it is used as an argument to
// ntcontinue, 2) is is used to constuct a call frame for apc delivery,
// and 3) it is used in the user level thread creation routines.
// the layout of the record conforms to a standard call frame.
typedef struct _context {
//
// the flags values within this flag control the contents of
// a context record.
// if the context record is used as an input parameter, then
// for each portion of the context record controlled by a flag
// whose value is set, it is assumed that that portion of the
// context record contains valid context. if the context record
// is being used to modify a threads context, then only that
// portion of the threads context will be modified.
// if the context record is used as an in out parameter to capture
// the context of a thread, then only those portions of the thread's
// context corresponding to set flags will be returned.
// the context record is never used as an out only parameter.
dword contextflags;
// this section is specified/returned if context_debug_registers is
// set in contextflags. note that context_debug_registers is not
// included in context_full.
dword dr0;
dword dr1;
dword dr2;
dword dr3;
dword dr6;
dword dr7;
// this section is specified/returned if the
// contextflags word contians the flag context_floating_point.
floating_save_area floatsave;
// contextflags word contians the flag context_segments.
dword seggs;
dword segfs;
dword seges;
dword segds;
// contextflags word contians the flag context_integer.
dword edi;
dword esi;
dword ebx;
dword edx;
dword ecx;
dword eax;
// contextflags word contians the flag context_control.
dword ebp;
dword eip;
dword segcs; // must be sanitized
dword eflags; // must be sanitized
dword esp;
dword segss;
// this section is specified/returned if the contextflags word
// contains the flag context_extended_registers.
// the format and contexts are processor specific
byte extendedregisters[maximum_supported_extension];
} context;<code>`</code>
这个结构体看似很大,只要了解汇编语言其实也并不大。前面章节中介绍了关于汇编语言的知识,对于结构体中的各个字段,读者应该非常熟悉。关于各个寄存器的介绍,这里就不重复了,这需要读者翻看前面的内容。这里只介绍contextflags字段的功能,该字段用于控制getthreadcontext()和setthreadcontext()能够获取或写入的环境信息。contextflags的取值也只能在winnt.h头文件中找到,其取值如下:
bool getthreadcontext(
handle hthread, // handle to thread with context
lpcontext lpcontext // context structure
);
bool setthreadcontext(
handle hthread, // handle to thread
const context *lpcontext // context structure
这两个函数的参数基本一样,hthread表示线程句柄,而lpcontext表示指向context的指针。所不同的是,getthreadcontext()是用来获取线程环境的,setthreadcontext()是用来设置线程环境的。需要注意的是,在获取或设置线程的上下文时,请将线程暂停后进行,以免发生“不明现象”。