简易调试器的实现(一)

一、前言

  看过SEH结构化异常处理,看了<软件调试>这本书,觉得调试真是一件特别棒的事情,于是在网上搜索调试器怎么做,前人种树,后人庇荫。

   程序为MFC界面,VS2010开发

 

二、实现思路

  1.调试对象为windows下32位程序,故对于PE文件判断是否符合

  2.创建被调试进程,传入DEBUG_ONLY_THIS_PROCESS值,使用WaitForDebugEvent等待调试事件发生,使用ContinueDebugEvent继续执行

  3.使用GetThreadContext读取寄存器值,使用SetThreadContext写入寄存器值

  4.使用ReadProcessMemory和WriteProcessMemory读取和写入子进程内存,比如cc断点

 

三、代码实现

  1.判断PE文件

  将MFC属性设置为可拖入文件,加入消息函数。

void CDebugger1Dlg::OnDropFiles(HDROP hDropInfo)
{
    DragQueryFile(hDropInfo,0,m_FilePath,MAX_PATH);//取得第一个文件的路径

    //拷贝文件到内存中
    if(LoadFileData(m_FilePath,&m_szFileData,&m_ulLow)==FALSE)
    {
        free(m_szFileData);
        return ;
    }
    if(IsPEFile(m_szFileData,&PeType)==FALSE)
    {
        MessageBox(L"不是PE文件",L"PECheck",0);

        return;
    }
    if(PeType==PE)
    {
        MessageBox(L"32位 MZ",L"MZ");   
   //这里后面创建进程
    // ULONG OepRVA = GetOEP(m_szFileData);
    //    ULONG Base = GetBase(m_szFileData);
    //    OEP = (ULONG_PTR)OepRVA + (ULONG_PTR)Base; //获得进程OEP 之前想在OEP设断,
    //结果根本不会再这段下来,所以在进程创建事件到达的时候,对于结构体中的pInfo->lpStartAddress设置断点

 

    }
    else if(PeType==PE64)
    {
        MessageBox(L"64位 MZ",L"MZ");  
        CDialog::OnDropFiles(hDropInfo);
    }
    else if(PeType==Unkonw)
    {
        MessageBox(L"未知",L"MZ");  
        CDialog::OnDropFiles(hDropInfo);
    }
}

  这部分根据PE结构判断PE文件是否合法,目前只支持exe文件,本来想在OEP设置断点的,结果根本断不下来,最后通过创建事件到达的lpStartAddr设置的断点,这里可以查阅一下程序最开始从哪里执行的?肯定不是OEP

 

  2.以调试模式创建子进程

  Windows有一个调试子系统,所有的异常(包括CPU产生的异常)都会中断到调试子系统中,进程产生异常后,调试子系统会捕捉到这个异常,如果这个进程是以被调试状态创建,那么,调试子系统会将这个异常派发到产生异常的进程的父进程.

  Windows中,父进程创建子进程,传入DEBUG_ONLY_THIS_PROCESS,这样设置之后,子进程发生的异常会首先通知给父进程处理

STARTUPINFO stcStartupInfo = { sizeof(STARTUPINFO) };
PROCESS_INFORMATION stcProcInfo = { 0 }; // 进程信息

if (!CreateProcess( /* 创建调试线程 */
pStc->path, // 可执行模块路径
NULL, // 命令行
NULL, // 安全描述符
NULL, // 线程属性是否可继承
FALSE, // 否从调用进程处继承了句柄
DEBUG_ONLY_THIS_PROCESS, // 以“只”调试的方式启动
NULL, // 新进程的环境块
NULL, // 新进程的当前工作路径(当前目录)
&stcStartupInfo, // 指定进程的主窗口特性
&stcProcInfo)) // 接收新进程的识别信息
{
//Fail
return 1;
}

  如果其父进程的代码用有函数WaitForDebugEvent(),那么,函数将会从等待状态中被唤醒,返回到其父进程的调用地点.并将异常信息保存到DEBUG_EVENT结构体中.

while (WaitForDebugEvent(&debugEvent, INFINITE) == TRUE) {

if (DispatchDebugEvent(&debugEvent) == TRUE) {  //我们自己的处理函数

ContinueDebugEvent(g_processID, g_threadID, DBG_EXCEPTION_NOT_HANDLED);
}

  用一个调试事件,用于等待子进程调试事件的到达
  如果ContinueDebugEvent第三个参数传递DBG_CONTINUE则表示调试器已经处理了该异常
  如果传递DBG_EXCEPTION_NOT_HANDLED,则表示调试器没有处理异常,交给内核处理器处理,内核处理器没有处理则在次交给调试器处理。

  DEBUG_EVENT结构体为

typedef struct _DEBUG_EVENT{
  DWORD dwDebugEventCode;//发生异常的是什么事
  DWORD dwProcessId;//触发异常的进程ID(如果被调试进程有多个进程,这个ID有可能是其子进程的)
  DWORD dwThreadId;//触发异常的线程ID(如果被调试进程有多个线程,这个ID有可能是其中的一个线程的
  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_INFOR ipInfo;//系统调试错误时的信息结构体
  }u;//这是一个联合体,dwDebugEventCode决定联合体中哪个字段是有用的.
}DEBUG_EVENT,*LPDEBUG_EVENT;

  我们的处理函数是对于DEBUG_EVENT中的调试事件类型的分派

//根据调试事件的类型调用不同的处理函数。
BOOL DispatchDebugEvent(const DEBUG_EVENT* pDebugEvent) {

    switch (pDebugEvent->dwDebugEventCode) {

    case CREATE_PROCESS_DEBUG_EVENT:
        return OnProcessCreated(&pDebugEvent->u.CreateProcessInfo);

    case CREATE_THREAD_DEBUG_EVENT:
        return OnThreadCreated(&pDebugEvent->u.CreateThread);

    case EXCEPTION_DEBUG_EVENT:
        return OnException(&pDebugEvent->u.Exception);

    case EXIT_PROCESS_DEBUG_EVENT:
        return OnProcessExited(&pDebugEvent->u.ExitProcess);

    case EXIT_THREAD_DEBUG_EVENT:
        return OnThreadExited(&pDebugEvent->u.ExitThread);

    case LOAD_DLL_DEBUG_EVENT:
        return OnDllLoaded(&pDebugEvent->u.LoadDll);

    case OUTPUT_DEBUG_STRING_EVENT:
        return OnOutputDebugString(&pDebugEvent->u.DebugString);

    case RIP_EVENT:
        return OnRipEvent(&pDebugEvent->u.RipInfo);

    case UNLOAD_DLL_DEBUG_EVENT:
        return OnDllUnloaded(&pDebugEvent->u.UnloadDll);

    default:
        return FALSE;
    }
}

  这里我们主要关心的是进程创建和异常到达

对于创建进程的调试事件
BOOL OnProcessCreated(const CREATE_PROCESS_DEBUG_INFO* pInfo) {

    InitializeBreakPointHelper(); //初始化断点列表

    //初始化符号处理器
    InitSymbol();

    //在程序入口设置断点
    SetCCBreakPointAt((SIZE_T)pInfo->lpStartAddress,TRUE);
    IsWow64Process(g_hProcess,&m_bIs32bitProcess);
    CloseHandle(pInfo->hFile);
    return TRUE;
}

  事件到达的结构体为

typedef struct _CREATE_PROCESS_DEBUG_INFO {
    HANDLE hFile;
    HANDLE hProcess;
    HANDLE hThread;
    LPVOID lpBaseOfImage;
    DWORD dwDebugInfoFileOffset;
    DWORD nDebugInfoSize;
    LPVOID lpThreadLocalBase;
    LPTHREAD_START_ROUTINE lpStartAddress;
    LPVOID lpImageName;
    WORD fUnicode;
} CREATE_PROCESS_DEBUG_INFO, *LPCREATE_PROCESS_DEBUG_INFO;

  其中,lpStartAddress为程序的入口点,我们对此处设置断点,可以让程序断下来。

  异常处理,是对于EXCEPTION_DEBUG_EVENT事件的处理,包括程序的各种异常

/发生异常的时候应该通知用户,交由用户来处理,所以应返回FALSE。
BOOL OnException(const EXCEPTION_DEBUG_INFO* pInfo) {

    switch (pInfo->ExceptionRecord.ExceptionCode) {

    case EXCEPTION_ACCESS_VIOLATION:      // 非法访问异常
        {
            break;
        }    
    case EXCEPTION_DATATYPE_MISALIGNMENT: // 内存对齐异常
        {    
            break;
        }
    case EXCEPTION_ILLEGAL_INSTRUCTION:   // 无效指令
        {    
            break;
        }
    case EXCEPTION_INT_DIVIDE_BY_ZERO:    // 除0错误
        {    
            break;
        }
    case EXCEPTION_PRIV_INSTRUCTION:      // 指令不支持当前模式
        {    
            break;
        }
    case EXCEPTION_BREAKPOINT:
        {
            /*OnShowSourceLines();*/
            OnDump(0);
            ShowAsm(20);
            return OnSoftBreakPoint(pInfo);   //return false 则中断停下来
        }

    case EXCEPTION_SINGLE_STEP:  //TF断点
        {
            ShowAsm(20);
            /*OnShowSourceLines();*/
            OnDump(0);
            return OnSingleStep(pInfo);
        }
    }

    CString strStatusMsg;
    strStatusMsg.Format(L"First Chance: Exception at %p",(DWORD)pInfo->ExceptionRecord.ExceptionAddress);
    MainDlg->m_wndStatusBar.SetPaneText(0,strStatusMsg);//在状态条上显示文字
    g_debuggeeStatus = STATUS_INTERRUPTED;
    return FALSE;
}

//这里暂时只处理了EXCEPTION_BREAKPOINT和EXCEPTION_SINGLE_STEP类型的异常

 

  3、使用GetThreadContext/SetThreadContext获得寄存器信息

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

  我们来看下Context结构,每个Context包含了线程的一些信息

typedef struct _CONTEXT {

    DWORD ContextFlags;
    DWORD   Dr0;
    DWORD   Dr1;
    DWORD   Dr2;
    DWORD   Dr3;
    DWORD   Dr6;
    DWORD   Dr7;
    FLOATING_SAVE_AREA FloatSave;
    DWORD   SegGs;
    DWORD   SegFs;
    DWORD   SegEs;
    DWORD   SegDs;
    DWORD   Edi;
    DWORD   Esi;
    DWORD   Ebx;    
    DWORD   Edx;
    DWORD   Ecx;
    DWORD   Eax;
    DWORD   Ebp;    //栈底
    DWORD   Eip;     //下一条执行指令
    DWORD   SegCs;              // MUST BE SANITIZED
    DWORD   EFlags;             // MUST BE SANITIZED
    DWORD   Esp;   //栈顶
    DWORD   SegSs;  //SS
    BYTE    ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];

} CONTEXT;

  感兴趣的可以研究下线程切换、异常的内核处理过程等研究Context的作用。

 

  获取某个线程的上下文环境需要使用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。

 

//获取被调试进程的主线程的上下文环境。
BOOL GetDebuggeeContext(CONTEXT* pContext) {

    Wow64SuspendThread(g_hThread);
    pContext->ContextFlags = CONTEXT_FULL;

    if (GetThreadContext(g_hThread, pContext) == FALSE) {
        CString strStatusMsg;
        strStatusMsg.Format(L"GetThreadContext failed");
        MainDlg->m_wndStatusBar.SetPaneText(0,strStatusMsg);//在状态条上显示文字
        return FALSE;
    }
    ResumeThread(g_hThread);
    return TRUE;
}

 

  4.跨进程读写内存

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

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

 

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

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

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

 

分区

地址范围

空指针赋值分区

0x00000000~0x0000FFFF

用户模式分区

0x00010000~0x7FFEFFFF

64KB禁入分区

0x7FFF0000~0x7FFFFFFF

内核模式分区

0x80000000~0xFFFFFFFF

 

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

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

 

 

四、关于异常

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


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

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

  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.异常没有被处理,程序以“应用程序错误”结束。

 

  简单来说就是有调试程序器存在的进程产生异常,会先给调试父进程通知,如果没处理则回到内核寻找(应该是SEH结构),内核没处理则再次转交给调试父进程,如果还没有处理则结束进程。

 

  软件异常:是程序主动触发异常,比如int 3,是陷阱异常,恢复的时候eip会指向指令的下一个

  硬件异常: 是cpu的异常,比如除0的异常,是错误异常,恢复的时候eip会指向从出错的地方,继续执行。

 

   我们之前在进程创建事件到达之后对于lpStartAddress设置了断点,

 ...SetCCBreakPointAt((SIZE_T)pInfo->lpStartAddress,TRUE);
   ... 
   PointInfo newBp; newBp.lpPointAddr
= address; newBp.chOldByte = SetBreakPointMemory(newBp.lpPointAddr); //保存原来的字节,用来恢复 newBp.isOnlyOne = TRUE; //一次性断点 newBp.nPtNum = g_BpList.size()+1; newBp.ptType = BP_CC; //CC断点类型 g_BpList.push_back(newBp); 这是自己构造的一个结构体,构造好了之后存入链表当中

  当程序断下来之后我们得到异常处理结构体

typedef struct _EXCEPTION_DEBUG_INFO {
  EXCEPTION_RECORD ExceptionRecord;
  DWORD dwFirstChance;
} EXCEPTION_DEBUG_INFO, *LPEXCEPTION_DEBUG_INFO;

  dwFirstChance表示是第一次被调试器捕获还是第二次
  如果R3调试器第一次捕获了,在ContinueDebugEvent函数传递DBG_EXCEPTION_NOT_HANDLED继续调试,表示这个错误没有处理,让内核异常处理器去处理,内核处理器不干了,就在此把异常给R3调试器,这时dwFirstChance为FALSE,表示是第二次收到异常。


  之前在看的资料是软件断点恢复的地方是 (异常发生的地方的BreakEIP) + 1,所以调试器在处理的时候EIP要 减一,这样就从断开的指令重新开始执行,不然可能指令被破坏(因为一般CC设置在指令的开始),但是在我调试的时候发现,在处理FirstChance的时候EIP为 BreakEIP + 1,但是在第二次的时候EIP就已经是BreakEIP,所以在第一次异常的时候处理需要EIP-1,第二次异常的时候就不需要EIP-1了,系统已经自动从那个地方执行了。

//处理断点异常
BOOL OnSoftBreakPoint(const EXCEPTION_DEBUG_INFO* pInfo) {

    //第一次接收到断点
    if (pInfo->dwFirstChance == TRUE) {

        //获取断点类型
        int bpType = GetBreakPointType((DWORD)pInfo->ExceptionRecord.ExceptionAddress);

        if(bpType==BP_CC)
        {
            //遍历断点列表,恢复CC
            RecoverUserBreakPoint((DWORD)pInfo->ExceptionRecord.ExceptionAddress);
            SaveResetUserBreakPoint((DWORD)pInfo->ExceptionRecord.ExceptionAddress);

            //软件断点,恢复的地方在eip+1的地方,所以要重断点的地方重新执行
            CONTEXT context;
            GetDebuggeeContext(&context);
            --context.Eip;  
            SetDebuggeeContext(&context);
        

            //设置TF标志,让CPU执行一步就中断下来
            SetTrapFlag();
        }
    }
    else {

        //如果第二次处理则不需要EIP--;
        //RecoverUserBreakPoint((DWORD)pInfo->ExceptionRecord.ExceptionAddress);
        //SaveResetUserBreakPoint((DWORD)pInfo->ExceptionRecord.ExceptionAddress);

        //第二次的时候已经-1了,不需要
    
        //设置TF标志,方便恢复
        //SetTrapFlag();
    }

    //如果是代码中的断点或者用户设置的断点,则暂停被调试进程的执行,并通知用户。
    CString strStatusMsg;
    strStatusMsg.Format(L"A break point occured at %p",(DWORD)pInfo->ExceptionRecord.ExceptionAddress);
    MainDlg->m_wndStatusBar.SetPaneText(0,strStatusMsg);//在状态条上显示文字
    
    g_alwaysContinue = TRUE;  //用户按Go会执行ContinueDebugEvent,传入DEBUG_CONTINUE
    g_debuggeeStatus = STATUS_INTERRUPTED;
    return FALSE; //程序会断下来
}


  寄存器的TF位表示CPU每执行一次就会触发一个EXCEPTION_SINGLE_STEP异常

  我们把CC断点恢复之后就设置TF位,使程序断下来,这样我们就可以重新设置CC断点(如果是一次性断点就不用恢复了)

//单步执行异常的处理函数。
BOOL OnSingleStep(const EXCEPTION_DEBUG_INFO* pInfo) {
	
	if(g_resetUserBpAddress != 0)
	{
		for (std::list<PointInfo>::iterator it = g_BpList.begin(); 
		it != g_BpList.end();
		++it) {

			if (it->lpPointAddr == g_resetUserBpAddress) 
			{
				if(it->isOnlyOne==FALSE) //如果不是一次断点则重新设置断点
				{
					SetBreakPointMemory(it->lpPointAddr);	
				}
				g_resetUserBpAddress = 0;
			}
		}
	}
	HandledException(TRUE);
	return TRUE;	
}

 

  目前实现的功能是显示汇编代码,在程序入口断下来,显示寄存器,内存。

  参考

  http://www.cnblogs.com/zplutor/archive/2011/03/04/1971279.html

  http://bbs.pediy.com/showthread.php?t=206292

    代码 

  http://pan.baidu.com/s/1skFefEH 密码 yzf1

posted on 2016-03-30 00:58  ciyze0101  阅读(1871)  评论(0编辑  收藏  举报

导航