一次性进群,长期免费索取教程,没有付费教程。
教程列表见微信公众号底部菜单
进微信群回复公众号:微信群;QQ群:460500587
微信公众号:计算机与网络安全
ID:Computer-network
在Windows中有这么一些API函数是专门用来进行调试的,这些函数被称作为Debug API,或者是调试API。利用这些函数可以进行调试器的开发,调试器通过创建有调试关系的父子进程来进行调试,被调试进程的底层信息、即时的寄存器、指令等信息都可以被获取,进而用来分析。
OllyDbg调试器的功能非常得强大,虽然有众多的功能,但是其基础的实现就是依赖于调试API。调试API函数的个数虽然不多,但是合理的使用会产生非常大的作用。调试器依赖于调试事件,调试事件有着非常复杂的结构体,调试器有着固定的流程,由于实时需要等待调试事件的发生,其过程是一个调试循环体,非常类似SDK开发程序中的消息循环。无论是调试事件,还是调试循环,对于调试或者说调试器来说,其最根本、最核心的部分是中断,或者说其最核心的部分是可以捕获中断。
一、3种断点方法
产生中断的方法是设置断点,常见的产生中断的断点方法有3种,一种是中断断点,一种是内存断点,还有一种是硬件断点。下面分别来介绍这3种断点的不同。
中断断点,这里通常指的是汇编语言中的int3指令,CPU执行该指令时会产生一个断点,因此也常称之为INT3断点。现在演示一下如何使用int 3来产生一个断点。代码如下:
在代码中使用了__asm,在__asm后面可以使用汇编指令,如果想添加一段汇编指令,方法是__asm{}这样的。通过__asm可以在C语言中进行内嵌汇编语言。在__asm后面直接使用的是int3指令,这样会产生一个异常,称为断点中断异常。对这段简单的代码进行编译连接,并且运行。运行后出现了错误对话框,如图1所示。
图1 异常对话框
这个对话框可能常常见到,而且见到以后多半很郁闷,通常情况是直接单击“不发送”按钮,然后关闭这个对话框。在这里,这个异常是通过int3导致的,不要忙着关掉它。通常在写自己的软件时如果出现这样的错误,应该去寻找一些更多的帮助信息来修正错误。单击“请单击此处”链接,出现如图2所示的对话框。
图2“异常基本信息”对话框
弹出“异常基本信息”对话框,因为这个对话框给出的信息实在太少了,继续单击“要查看关于错误报告的技术信息”后面的“单击此处”链接,打开如图3所示的对话框。
图3“错误报告内容”对话框
通常情况下,在这个报告中只关心两个内容,一个内容是Code,另一个内容是Address。在图3中,Code后面的值为0x80000003,在Address后面的值为0x0000000000401028。Code的值为产生异常的异常代码,Address是产生异常的地址。在Winnt.h中定义了关于Code的值,在这里0x80000003的定义为STATUS_BREAKPOINT,也就是断点中断。在Winnt.h中的定义为:
这里给的Address可以看出是一个VA(虚拟地址),用OD打开这个程序,直接按F9键运行,如图4、图5所示。
图4 在OD中运行后被断下
图5 OD状态栏提示
从图4所示的地方看到,程序执行停在了00401029的位置处。从图5所示的地方可以看到,INT3命令位于00401028的位置处。再看一下图3中Address后面的值,值为00401028。这也就证明了在系统的错误报告中可以给出正确的出错地址的。这样在以后写程序的过程中可以很容易地定位到自己程序中有错误的位置。
回到中断断点的话题上,中断断点是由int3产生的,那么要如何通过调试器(调试进程)在被调试进程中设置中断断点呢?看图4中00401028地址处,在地址值的后面,在反汇编代码的前面,中间那一列的内容是汇编指令对应的机器码。可以看出,INT3对应的机器码是0xCC。如果想通过调试器在被调试进程中设置INT3断点的话,那么只需要把要中断的位置的机器码改为0xCC即可,当调试器捕获到该断点异常时,修改为原来的值即可。
内存断点的方法同样是通过异常来产生的。在Win32平台下,内存是按页进行划分的,每页的大小为4KB。每一页内存都有其各自的内存属性,常见的内存属性有只读、可读写、可执行等。内存断点的原理就是通过对内存属性的修改,而导致本该进行的操作无法进行,这样便会引发异常。
在OD中关于内存断点有两种,一种是内存访问,另外一种是内存写入。用OD随便打开一个应用程序,在其“转存窗口”(或者叫“数据窗口”)中随便选中一些数据点后单击右键,在弹出的菜单中选择“断点”命令,在“断点”子命令下会看到“内存访问”和“内存写入”两种断点,如图6所示。
图6 内存断点类型
下面通过简单例子来看一下如何产生一个内存访问异常,代码如下:
在这个程序中,使用了VirtualProtect()函数,该函数与VirtualProtectEx()函数类似,不过VirtualProtect()是用来修改当前进程的内存属性的。
对这个程序编译连接,并运行起来。熟悉的出错界面又出现在眼前,如图7所示。
图7“异常基本信息”对话框
按照前面介绍的步骤打开“错误报告内容”对话框,如图8所示。
图8“错误报告内容”对话框
按照上面的分析方法来看一下Code和Address这两个值。Code后面的值为0xC0000005,这个值在Winnt.h中的定义如下:
这个值的意义表示访问违例。在Address后面的值为0x0000000000402fa3,这个值是地址,但是这里的地址根据程序来考虑,值是用malloc()函数申请的,用于保存数据的堆地址,而不是用来保存代码的地址。这个地址就不进行测试了,因为是动态申请,很可能每次不同,因此大家了解就可以了。
硬件断点是有硬件进行支持的。在OD中使用硬件断点的方法类似于内存断点,同样是在右键菜单中进行设置。由于是由硬件支持的,因此只能设置4个。
二、调试API函数及相关结构体
调试器的根本是依靠中断,其核心也是中断。前面也演示了两个产生中断异常的例子。下面的内容是介绍调试API函数,及其相关的调试结构体。调试API函数的数量非常少,但是其结构体是非常少有的较为复杂的,虽然说是复杂,其实只是嵌套的层级比较多,只要了解了较为常见的,剩下的可以自己对照MSDN进行学习。在介绍完调试API函数及其结构体后,再来简单演示一下,如何通过调试API捕获INT3断点和内存断点。
创建调试关系
既然是调试,那么必然存在调试和被调试。调试和被调试的这种调试关系是如何建立起来的,是我们首先要了解的内容。要使调试和被调试创建调试关系,那么就会用到两个函数中的一个,这两个函数分别是CreateProcess()和DebugActiveProcess()。如何使用CreateProcess()函数来建立一个需要被调试的进程呢?来回顾一下CreateProcess()函数。CreateProcess()函数的定义如下:
现在要做的是创建一个被调试进程。在CreateProcess()函数中,有一个dwCreationFlags的参数,该参数的取值中有两个重要的常量,分别为DEBUG_PROCESS和DEBUG_ONLY_THIS_PROCESS。DEBUG_PROCESS的作用就是被创建的进程处于调试状态,如果一同指定了DEBUG_ONLY_THIS_PROCESS的话,那么就只能调试被创建的进程,而不能调试被调试进程创建出来的进程。只要在使用CreateProcess()函数时指定这两个常量即可。
除了CreateProcess()函数以外,还有一种创建调试关系的方法,该方法用的函数如下:
这个函数的功能是附加到一个进程上。该函数的参数就一个,该参数指定了被调试进程的进程ID号。从函数名与函数参数可以看出来,这个函数是和一个已经被创建的进程来建立调试关系的,跟CreateProcess()的方法是不一样的。在OD中也同样有这个功能,打开OD,选择菜单中的“文件”->“挂接”命令,就出现“选择要挂接的进程”窗口,如图9所示。
图9“选择要挂接到的进程”的窗口
OD的这个功能就是通过DebugActiveProcess()函数来完成的。
调试器与被调试的目标进程可以通过前两个函数建立调试关系,但是如何使调试器与被调试的目标进程断开调试关系呢?有一个很简单的方法,关闭调试器进程,这样调试器进程与被调试的目标进程会同时结束。也可以关闭被调试的目标进程,这样也可以达到断开调试关系。那如何让调试器与被调试的目标进程断开调试关系,又保持被调试目标进程的运行呢?这里介绍一个函数,函数名为DebugActiveProcessStop(),其定义如下:
该函数只有一个参数,就是被调试进程的进程ID号。使用该函数可以在不影响调试器进程和被调试进程的正常运行而将两者的关系进行解除。
三、判断是否处于被调试状态
很多程序都要检测自己是否处于被调试状态,比如游戏、病毒,或者加壳后的程序。游戏为了防止被做出外挂而进行反调试,病毒为了给反病毒工程师增加分析难度而反调试,加壳程序是专门用来保护软件的,当然也会有反调试的功能(该功能仅限于加密壳,压缩壳是没有反调试功能的)。
下面不是要介绍反调试,而是要介绍一个简单的函数,这个函数是判断自身是否处于被调试状态,函数名为IsDebuggerPresent(),函数的定义如下:
该函数没有参数,根据返回值来判断是否处于被调试状态。这个函数也可以用来进行反调试。不过由于这个函数的实现过于简单,很容易就能够被分析者突破,因此现在也没有软件再使用该函数来用作反调试了。
下面通过一个简单的例子来演示一下IsDebuggerPresent()函数的使用。代码如下:
这个例子用来检测是否被调试。在进入主函数后,直接调用IsDebuggerPresent()函数,用来判断是否被调试器创建。在自定义线程函数中,一直循环检测是否被附加。只要发现自身处于被调试状态,那么就在控制台中进行输出提示。
现在用OD对这个程序进行测试。首先用OD直接打开这个程序并按F9键运行,如图10所示。
图10 主函数检测到调试器
按下F9键启动以后,控制台中输出“mian func checked the debuggee”,也就是发现了调试器。
再测试一下检测OD附加的效果。先运行这个程序,然后用OD去挂接它,看其提示,如图11所示。
图11 线程函数检测到调试器
控制台中输出“thread func checked the debuggee”。可以看出用OD进行附加也能够检测到自身处于被调试状态。
进行该测试时请选用原版OD。由于该检测是否处于被调试方法过于简单,因此任何其他修改版的OD都可以将其突破,从而使测试失败。
四、断点异常函数
有时为了调试方便可能会在自己的代码中插入__asm int 3,这样当程序运行到这里时会产生一个断点,就可以用调试器进行调试了。其实微软提供了一个函数,使用该函数可以直接让程序运行到某处的时候产生INT3断点,该函数的定义如下:
修改一下前面的程序,把__asm int 3替换为DebugBreak(),编译连接并运行看一下。同样会因产生异常而出现“异常基本信息”对话框,查看它的“错误报告内容”,如图12所示。
图12“错误报告内容”对话框
看一下Code的后面的值,看到值为0x80000003就应该知道是EXCEPTION_BREAKPOINT。再看Address后面的值,值为0x000000007c92120e,从这个地址可以看出,该值在系统的DLL文件中,因为调用的是系统提供的函数。
五、调试事件
调试器在调试程序的过程中,是通过不断地下断点来完成的,而断点的产生在前面的内容中提到过一部分。通过前面介绍的INT3断点和内存断点可以得知,调试器是在捕获目标进程产生的异常从而作出响应。当然,对于介绍的断点来说是这样的,不过对于调试器来说,除了对异常作出响应以外,还会对其他的一些事件作出响应,异常只是所有调试能进行响应事件的一部分。
调试器的工作主要是依赖调试事件,调试事件在系统中被定义为一个结构体,也是到目前为止要接触的最为复杂的一个结构体,因为这个结构体的嵌套关系很多。这个结构体的定义如下:
这个结构体非常重要,我们有必要详细地介绍一下。
(1)dwDebugEventCode:该字段指定了调试事件的类型编码。在调试的过程中可能产生的调试事件非常多,因此要根据不同的类型码进行不同的响应处理。常见的调试事件如图13所示。
图13 dwDebugEventCode取值
(2)dwProcessId:该字段指明了引发调试事件的进程ID号。
(3)dwThreadId:该字段指明了引发调试事件的线程ID号。
(4)u:该字段是一个联合体,该联合体的取值由dwDebugEventCode指定。在该联合体中包含了很多个结构体,包括EXCEPTION_DEBUG_INFO、CREATE_THREAD_DEBUG_INFO、CREATE_PROCESS_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的定义如下:
在EXCEPTION_DEBUG_INFO中包含的EXCEPTION_RECORD结构体中保存着真正的异常信息,对于dwFirstChance里面保存着ExceptionRecord的个数。看一下关于EXCEPTION_RECORD结构体的定义:
(1)ExceptionCode:异常码。该值在MSDN中的定义非常多,不过我们需要使用的值只有3个,分别是EXCEPTION_ACCESS_VIOLATION (访问违例)、EXCEPTION_BREAKPOINT(断点异常)和EXCEPTION_SINGLE_STEP(单步异常)。这3个值中的前两个值对于我们来说是非常熟悉的,因为在前面已经介绍过了,关于最后一个单步异常想必也是非常熟悉的了。我们在使用OD快捷键的F7键、F8键时就是在使用单步功能,而单步异常就是有EXCEPTION_SINGLE_STEP来表示的。
(2)ExceptionRecord:指向一个EXCEPTION_RECORD的指针,异常记录是一个链表,其中可能保存着很多的异常信息。
(3)ExceptionAddress:异常产生的地址。
调试事件这个结构体DEBUG_EVENT看似非常复杂,其实也只是嵌套得比较深而已。只要大家去体会每个结构体,体会每层嵌套的含义,自然而然就觉得它没有多么复杂了。
六、调试循环
调试器在不断地对被调试目标进程进行捕获调试信息,有点类似于Win32应用程序的消息循环,但是又有所不同。调试器在捕获到调试信息后,进行相应地处理,然后恢复线程使之继续运行。
用来等待捕获被调试进程调试事件的函数是WaitForDebugEvent(),该函数的定义如下:
(1)lpDebugEvent:该参数用于接收保存调试事件;
(2)dwMillisenconds:该参数用于指定超时的时间,无限制等待使用INFINITE。
在调试器捕获到调试事件后会对被调试的目标进程中产生调试事件的线程进行挂起,在调试器对被调试目标进程进行相应的处理后,需要使用ContinueDebugEvent()对先前被挂起的线程进行恢复。ContinueDebugEvent()函数的定义如下:
(1)dwProcessId:该参数表示被调试进程的进程标识符。
(2)dwThreadId:该参数表示准备恢复挂起线程的线程标识符。
(3) dwContinueStatus:该参数指定了该线程以何种方式继续执行,该参数的取值为DBG_EXCEPTION_NOT_HANDLED和DBG_CONTINUE。对于这两个值来说,在通常的情况下并没有什么差别。但是当遇到调试事件中的调试码为EXCEPTION_DEBUG_EVENT时这两个常量就会有不同的动作,如果使用DBG_EXCEPTION_NOT_HANDLED,调试器进程将会忽略该异常,Windows会使用被调试进程的异常处理函数对异常进行处理;如果使用DBG_CONTINUE的话,那么需要调试器进程对异常进行处理,然后继续运行。
由上面两个函数配合调试事件结构体,就可以构成一个完整的调试循环,以下这段调试循环的代码摘自MSDN,代码如下:
以上就是一个完整的调试循环,不过有些调试事件对于我们来说可能是用不到的,那么就把不需要的调试事件所对应的case语句删除掉就可以了。
七、内存的操作
调试器进程通常要对被调试的目标进程进行内存的读取或写入。对于跨进程的内存读取和写入的函数是ReadProcessMemory() 和WriteProcessMemory()。
要对被调试的目标进程设置INT3断点时,就需要使用WriteProcessMemory()函数对指定的位置写入0xCC。当INT3被执行后,要在原来的位置上把原来的机器码写回去,原来的机器码需要使用ReadProcessMemory()函数来进行读取。
关于内存操作除了以上两个函数以外,还有一个就是修改内存的页面属性的函数,即VirtualProtectEx(),同样这个函数前面也介绍过了。
八、线程环境相关API及结构体
进程是用来向系统申请各种资源的,而真正被分配到CPU并执行代码的是线程。进程中的每个线程都共享着进程的资源,但是每个线程都有不同的线程上下文,或线程环境。Windows是一个多任务的操作系统,在Windows中为每一个线程分配一个时间片,当某个线程执行完其所属的时间片后,Windows会切换到另外的线程去执行。在进行线程切换以前有一步保存线程环境的工作,那就是保存在切换时线程的所有寄存器值、栈信息及描述符等相关的所有信息。只有把线程的上下文保存起来,在下次该线程被CPU再次调度时才能正确地接着上次的工作继续进行。
在Windows系统下,将线程环境定义为CONTEXT结构体,该结构体需要在Winnt.h头文件中找到,在MSDN中并没有给出定义。CONTEXT结构体的定义如下:
这个结构体看似很大,但是也并不大,只要了解汇编语言的话,结构体中的各个字段应该是非常熟悉的。这里介绍一下ContextFlags字段的功能,该字段用于控制GetThreadContext()和SetThreadContext()能够获取或写入的环境信息。ContextFlags的取值也只能在Winnt.h头文件中找到,其取值如下:
从这些宏定义的注释来看,能很清楚地知道这些宏可以控制GetThreadContext()和SetThreadContext()进行何种操作。大家在真正使用时进行相应的赋值就可以了。
该结构体可能会在Winnt.h中找到多个定义,因为该结构体是与平台相关的。因此,在各种不同的平台上此结构体有所不同。
线程环境在Windows中定义了一个CONTEXT的结构体,我们要进行获取或设置线程环境的话,需要使用GetThreadContext()和SetThreadContext()。这两个函数的定义分别如下:
这两个函数的参数都基本一样,hThread表示线程句柄,而lpContext表示指向CONTEXT的指针。所不同的是,GetThreadContext()是用来获取线程环境的,SetThreadContext()是用来进行设置线程环境的。
微信公众号:计算机与网络安全
ID:Computer-network
【推荐书籍】