错过会难过 发表于 2018-1-26 11:40:56

win32调试器的实现

本帖最后由 错过会难过 于 2018-1-26 11:14 编辑

调试器的实现

Table of Contents 调试器实现的依赖.1.CPU所支持的调试功能2.操作系统提供的功能3. 调试器实现的原理3.1 以调试创建一个进程或附加到一个进程3.2 2.创建完进程后,需要安装个电话,静静的等待调试子系统找上门来:3.2.1 关于解读DEBUG_EVENT结构体的一些方法和注释3.3 通过DEBUG_EVENT结构体来解读异常信息4. 让程序停下来实现4.1 单步断点的实现(利用TF断点) :4.2 软件断点的实现(利用0xcc指令)

调试器实现的依赖.

1.CPU所支持的调试功能

1.1 CPU中,有标志寄存器EFLAGS的IF,TF标志位用于开启调试功能,如果一个进程是以调试状态被创建,且其线程环境(CONTEXT)中的EFLSGS的TF标志位是1,那么这个进程执行一条指令后,将会产生一个异常,异常被处理后,TF自动被重置为0

1.2 CPU有DRx(DebugRegister)系列的寄存器可用于断点功能.




[*]- DR0~DR3这四个寄存器是断点地址存储器,用于保存断点的地址
[*]- DR6是调试状态寄存器用于指明DR0~DR3寄存器中哪一个产生了调试异常
[*]- DR6寄存器使用比特位B0~B3来指明DR0~DR3中哪个产生了调试异常.
[*]- DR7是断点属性控制器
[*]- DR7寄存器分别保存着DR0~DR3的断点地址对应的断点的属性,属性有如下几种:
[*]- L0~L3:这个比特位等于1,则断点为本地断点.
[*]- G0~G3:这个比特位等于1,则断点为全局断点.
[*]    R/W0-R/W3:
[*]    - 00:执行断点
[*]    - 01:数据写入断点
[*]    - 10:I/0读写断点
[*]    - 11:读写断点()读取指令不算)


2.操作系统提供的功能

Windows有一个调试子系统,所有的异常(包括CPU产生的异常)都会中断到调试子系统中,进程产生异常后,调试子系统会捕捉到这个异常,如果这个进程是以被调试状态创建,那么,调试子系统会将这个异常派发到产生异常的进程的父进程. 如果其父进程的代码用有函数WaitForDebugEvent(),那么,函数将会从等待状态中被唤醒,返回到其父进程的调用地点.并将异常信息保存到DEBUG_EVENT结构体中.

3. 调试器实现的原理

程序在运行起来后,其执行指令的速度是光速的.由于人类无法从光速中看清真相,程序出错也是光速的,常会在人无法预知的时候发生了无法预知的错误,让程序变慢,或者停下来是一种能够查明程序出错,或者程序运行机制的重要技能.

程序的运行,是通过CPU对指令流的处理.如果CPU能够听人指挥,一直在指令流的指定地点处执行,那么,程序的流程不会接着往下走,程序就自然而然的停下来了.

前面说的异常,其实就是让CPU告诉操作系统,大佬,我看到了一条让我停下来的指令(0xcc,遇到了DRx寄存器中保存的地址, 除0等),现在该怎么办. 操作系统遇到了这个情况,赶紧回想,自己没有发布这样的指令,于是就让它的马仔调试子系统去解决这个问题,调试子系统会查看进程是否被人调试了,一看,真是被调试了,于是找到了调试这个进程的人的电话(PrentProcessID),打了个电话给那个人. 如果那个人装了电话(WaitForDebugEvent),那么就能从这个电话中得到信息,调试子系统会跟那个人约定,你先去解决吧,我就在这等着,于是那个人就可以针对调试子系统给的信息做出对应的处理.最后就答复调试子系统有没有处理成功. 调试子系统就会拍拍屁股回去,让被调试的进程继续执行或是一直执行那条指令.

调试器在刚才说的作品中扮演的角色就是被调试进程的父进程. 下面就是调试器要完成的流程:



3.1 以调试创建一个进程或附加到一个进程

    /*创建进程*/
    CreateProcess(pszFilePath,//可执行模块路径
                  NULL,//命令行
                  NULL,//安全描述符
                  NULL,//线程属性
                  FALSE,//否从调用进程处继承了句柄
                  DEBUG_ONLY_THIS_PROCESS,//启动方式,这里是以只调试的方式创建一个还没有运行的进程
                  NULL,//新进程的环境块
                  NULL,//新进程的当前工作路径(当前目录)
                  &stcStartupInfo,//指定进程的主窗口特性
                  &stcProcInfo//接收新进程的识别信息
                  );

3.2 2.创建完进程后,需要安装个电话,静静的等待调试子系统找上门来:

    DEBUG_EVENTstcDeEvent={0};//这是保存调试子系统发来的信息的结构体
    WaitForDebugEvent(&stcDeEvent,//保存异常信息的结构体
                      INFINIT//等待时间
                      );

3.2.1 关于解读DEBUG_EVENT结构体的一些方法和注释

    typedefstruct_DEBUG_EVENT{
      DWORDdwDebugEventCode;//发生异常的是什么事
      DWORDdwProcessId;//触发异常的进程ID(如果被调试进程有多个进程,这个ID有可能是其子进程的)
      DWORDdwThreadId;//触发异常的线程ID(如果被调试进程有多个线程,这个ID有可能是其中的一个线程的
      union{
            EXCEPTION_DEBUG_INFOException;//异常类型信息
            CREATE_THREAD_DEBUG_INFOCreateThread;//创建线程时得到的信息结构体(有可能会创建多个线程)
            CREATE_PROCESS_DEBUG_INFOCreateProcessInfo;//创建进程时得到的信息结构体,有可能会得到多个
            EXIT_THREAD_DEBUG_INFOExitThread;//线程退出的信息结构体
            EXIT_PROCESS_DEBUG_INFOExitProcess;//进程退出的信息结构体
            LOAD_DLL_DEBUG_INFOLoadDll;//加载模块的信息结构体
            UNLOAD_DLL_DEBUG_INFOUnloadDll;//卸载模块的信息结构体
            OUTPUT_DEBUG_STRING_INFODebugString;//输出调试字串的信息结构体
            RIP_INFORipInfo;//系统调试错误时的信息结构体
      }u;//这是一个联合体,dwDebugEventCode决定联合体中哪个字段是有用的.
    }DEBUG_EVENT,*LPDEBUG_EVENT;

3.3 通过DEBUG_EVENT结构体来解读异常信息

首先在这里向大家道歉,我有罪,调试子系统打电话过来时,带各位带来的信息并非仅仅包含着异常信息.调试子系统给的结构体有大量的信息,它把这些信息打包成了上面的所述的结构体,其中,第一个字段DWORD dwDebugEventCode;会告诉大家,此次发来的信息是什么信息(调试子系统可能会给你打很多个电话,所以为了能够接到调试子系统的第二个电话,在这里我偷偷建议你,用一个while循环将电话包起来). 这就是调试子系统所能给你的全部的信息:


[*]- 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模块之后发送此类调试事件。

如果你仅仅想处理异常事件,那么我会这样建议你:

    if(stcDeEvent.dwDebugEventCode==EXCEPTION_DEBUG_EVENT)
    {
       //搞点事情
    }

完事之后,你需要给调试子系统的BB机回复一个信息:

    ContinueDebugEvent(stcDeEvent.dwProcessId,//指明是哪个被调试进程发的消息
                        stcDeEvent.dwThreadId,//指明是哪个被调试进程下的线程发的消息
                        DBG_CONTINUE// DBG_CONTINUE表示异常已经被处理
                     );

消息的内容,由于是BB机,所以只能发两个消息DBG_EXCEPTION_HANDLED和DBG_CONTINUE, 这个消息一旦发送出去,将产生决定性的效果,如果你不发,调试子系统会在另一头死等你回消息. 一旦你发了,它就会根据你发的消息内容来决定做什么事情:


[*]- DBG_CONTINUE 告诉调试子系统已经处理了,不用在找别人处理了.
[*]- DBG_EXCEPTION_HANDLED 告诉调试子系统,没有处理,但是可以找别人处理

流程如下:

由于这是一个影响深远的决定, 故而不能任性放荡地决定:
在回复之前,我们必须先要看看这个异常时什么情况,能不能处理.那么,异常来临的时候,我们应该需要什么信息才能做出处理呢? 首先,我们要正视自己,摸着自己的良心问自己,我们是谁,然后坚定的回答自己我们是调试器,然后在问我们要异常干什么,我们要用异常来把调试进程停止下来.好了,现在我们知道我们要什么了.
1 .起码要知道异常发生的地址,这样才能知道这是不是我们自己让程序停下来的地方

1. 其次是异常发生的真相,这样才知道这是不是我们用阴谋让程序停下来的原因

    typedefstruct_EXCEPTION_DEBUG_INFO{
      EXCEPTION_RECORDExceptionRecord;//一个结构体,里面保存的是异常发生的地址和原因
      DWORDdwFirstChance;//
    }EXCEPTION_DEBUG_INFO,*LPEXCEPTION_DEBUG_INFO;

查明层层目录结构体下异常的发生地址和原因之结构体:

    typedefstruct_EXCEPTION_RECORD{
      DWORD ExceptionCode;//异常发生的真相
      DWORD ExceptionFlags;
      struct _EXCEPTION_RECORD*ExceptionRecord;
      PVOID ExceptionAddress;//异常发生的地址
      DWORD NumberParameters;
      ULONG_PTR ExceptionInformation;
    }EXCEPTION_RECORD;


[*]- EXCEPTION_RECORD的第一个字段ExceptionCode,异常发生的真相有如下内容:
[*]- EXCEPTION_ACCESS_VIOLATION : 非法访问异常
[*]- EXCEPTION_DATATYPE_MISALIGNMENT: 内存对齐异常
[*]- EXCEPTION_ILLEGAL_INSTRUCTION : 无效指令
[*]- EXCEPTION_INT_DIVIDE_BY_ZERO : 除0错误
[*]- EXCEPTION_PRIV_INSTRUCTION : 指令不支持当前模式
[*]- EXCEPTION_BREAKPOINT : 断点异常0xcc
[*]- EXCEPTION_SINGLE_STEP: 单步或硬件断点异常TF和DR系列寄存器

这些真相也意味着我们能够用哪些方法让程序听话地停下来.但是一般仅仅使用后两种就足够了.

4. 让程序停下来实现

为了方便断点的管理,在这里约定了一些宏,定义了一个结构体来保存断点的信息

    #define MODE_HARD 1//硬件断点:DR0~DR3
    #define MODE_SOFT 2//软件断点:0xcc
    #define MODE_TF 3//TF断点:TF=1
    #define SOFTTYPE_ONCE 1//一次性断点
    #define SOFTTYPE_NORM 2//正常断点
    #define SOFTTYPE_COND 3//条件断点
    #define CONDITIONTYPE_ADDRE 0//条件断点的地址类型
    #define CONDITIONTYPE_TIMES 1//条件断点的次数类型
    #define TFTYPE_TF 1//正常的TF断点
    #define TFTYPE_RELOAD 2//重新安装软件断点
    #define TFTYPE_TIMES 3//次数断点
    #define UPDOWN_EQU 1//相等
    #define UPDOWN_UP 2//升序
    #define UPDOWN_DOWN 3//降序
    typedef struct BREAKPOINTERTYPE
    {
      //////////////////////////////////////////////////////////////////////////
      //断点方式硬件断点,软件断点,TF断点
      //分表对应不同的表或结构体
      //mode==MODE_HARD==>>保存在vector<BREAKPOINTERTYPE>
      //mode==MODE_SOFT==>>保存在map<SIZE_T,BREAKPOINTERTYPE>
      //mode==MODE_TF==>>保存在BREAKPOINTERTYPE
      DWORD mode:8;
      //------------------------------------------------------------------------
      //////////////////////////////////////////////////////////////////////////
      //不同的mod对应不同的type
      //mod==TF有两种type
      //type1:可继续的TF中断
      //type2:用于恢复软件中断
      //mod==SOFT或mod==HARD
      //type1:一次性断点
      //type2:正常断点
      //type3:条件断点
      //启动条件断点时,下面的结构体被启用
      //times:记录断下的次数
      //address:断点地址
      //upDown:记录是大于/小于/等于条件
      //condition:次数或地址,配合upDown产生作用.
      //
      //SOFT和HARD不一样的地方:
      //SOFT需要还原内存数据,将EIP减1,触发TF断点,重新写入0xcc,
      //HARD需要将对应寄存器的值清零,触发TF断点,重新写入寄存器的值
      DWORD type:8;//断点类型一次性,单步,条件
      //////////////////////////////////////////////////////////////////////////
      DWORD memData:8;//内存中原来的内容.下0xcc断点时替换出来的内容
      DWORD conditionType:4;//条件断点使用哪种条件:地址,次数
      DWORD upDown:4;//>,==,<
      SIZE_T address;//断点地址
      SIZE_T times;//断下的次数(一次性除外)
      SIZE_T condition;//限制条件:地址或者次数
    }BREAKPOINTERTYPE,*PBREAKPOINTERTYPE; lorf'ޝ]c

4.1 单步断点的实现(利用TF断点) :

首先单步断点利用的是将EFLAGS寄存器的TF标志位置1来实现 !

获取EFLAGS寄存器的方法是利用如下函数:

    GetThreadContext(HANDLE hThread,//线程句柄,被调试进程的线程句柄,保存在进程被创建时得到CREATE_PROCESS_DEBUG_INFO结构体中
                  CONTEXT* pContext//保存着线程上下文(就是寄存器组)
                  )

这个线程环境里面有很多内容,有通用寄存器,段寄存器,当然也有我们想要的标志寄存器 得到这个寄存器后,将其EFlags的TF比特位置为1 :pContext->EFlags|=0x100

更新线程环境

    SetThreadContext(HANDLE hThread,//线程句柄,被调试进程的线程句柄,保存在进程被创建时得到的CREATE_PROCESS_DEBUG_INFO结构体中
                  CONTEXT* pContext//线程环境(也叫线程上下文)
                  )

值得注意的是,TF标志为只能用一次,使用过后TF会被自动置零,这意味你需要再次将TF标志位置1

4.2 软件断点的实现(利用0xcc指令)

软件断点的实现就是将0xcc写入到调试进程的指定内存地址中,0xcc在汇编中是int3指令,我们需要将原来的内容读取出来,保存,然后将0xcc写入.

    ReadProcessMemory(HANDLEhProcess,//被读取内存信息的进程句柄,也就是被调试的进程的句柄
                     LPVOID lpAddress,//读取地址
                     LPVOID lpBuff,//函数将读取内容保存的缓冲区
                     SIZE_T nSize,//缓冲区大小,
                     SIZE_T* lpNumberOfBuffRead//函数读取了多少
                     );
    WriteProcessMemory(HANDLEhProcess,//被读取内存信息的进程句柄,也就是被调试的进程的句柄
                     LPCVOID lpAddress,//读取地址
                     LPCVOID lpBuff,//写入的内存
                     SIZE_T nSize,//缓冲区大小,
                     SIZE_T* lpNumberOfBuffRead//函数写入了多少
                     );

值得注意的是,内存断点被触发后需要将EIP减1,还原内存数据,这样做是为了什么呢?

E9 12345678这是一条跳转指令,下了0xcc断点后变成了这样:

CC 12345678,EIP不减1,CPU会从12345678处继续执行,这样下去,指令全部乱套.所以要将EIP减1 将EIP减1后,CPU重CC处执行,会继续触发异常,陷入死循环,所以要将内存数据还原 但是还原内存数据之后,这个断点下一次就不能使用了. 解决的办法就是恢复数据后触发TF断点,在TF断点触发时将CC重新写入.

原理大概就是这些了,有很多内容没讲太细,留给大伙思考吧(其实是我太懒了).

参考源码: https://github.com/enoorez/dbg3
页: [1]
查看完整版本: win32调试器的实现