[Win32]一个调试器的实现(七)断点

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

 

什么是断点

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

 

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

 

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

 

如果你使用过前几篇文章中的MiniDebugger来调试程序,肯定会注意到在被调试程序刚开始运行的时候总会有一个发生在高地址处的断点异常(通过异常代码是0x80000003来判别),这个断点就是初始断点。如果Windows检测到一个程序正在被调试,那么在这个程序初始化完成之后,就会引发一个断点异常,告诉调试器一切就绪。调试器可以在接收到这个断点时进行准备工作,例如加载调试符号。初始断点是不可避免的,只要在Windows下调试程序都会引发这个断点。

 

断点异常的分发

断点实际上是异常,所以它同样也会经历第三篇文章所说的异常分发的过程。那么,它是属于错误异常还是陷阱异常呢?不妨通过实验来证实。这里使用上一篇文章的MiniDebugger作为调试器,以下面代码生成的程序作为被调试程序:

1 int wmain(int argc, wchar_t** argv) {
2    __asm { int 3 };
3    return 0;
4 }

 

首先启动被调试程序,跳过初始断点,使它执行__asm {int 3};语句,引发断点异常:

 

执行lr命令查看源代码和寄存器: 

 

可以看到,执行完int 3指令后,EIP指向了下一条指令,如果以g c命令恢复执行,就会执行return语句,被调试进程就会结束。得出结论:断点异常属于陷阱异常。

 

上文说过Windows对于断点异常的处理有微妙的不同,现在让我们看一下有什么不同。执行g命令,不处理异常,在第二次接收到异常时执行lr命令: 

 

这时EIP回退了一个字节,指向了引发断点异常的那条指令。Windows在分发其它异常时并不会修改EIP的值,这就是它们的区别。

 

另外,调试器只会接收到一次初始断点,无论以DBG_CONTINUE还是DBG_EXCEPTION_NOT_HANDLED调用ContinueDebugEvent,都不会再接收到初始断点。

 

陷阱标志

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

 

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

 

断点功能原理

上面的例子在被调试程序中插入了一条int 3指令,那是为了实验的需要,但是在正常的程序中不可能会有这样的指令。为了可以在任何指令处设置断点,调试器要将指令的第一个字节替换成0xCCint 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已经将EIP1了。无论何时处理异常,调试器都要将0xCC替换回原来的0xC7,然后以DBG_CONTINUE继续被调试进程执行。

 

我建议在第一次接收断点异常时进行处理,因为如果第一次接收时不处理,Windows会执行额外的代码,这会给单步执行功能带来一些麻烦。

 

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

 

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

 

 

实现断点功能

了解了断点功能的原理,下面就来逐步实现这个功能。这里只描述大概的思路,具体如何实现可以参考示例代码。

 

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

 

确定地址之后就要替换指令第一个字节。读取这个字节可以使用ReadProcessMemory函数,写入字节可以使用WriteProcessMemory函数。前者已经在第四篇文章中介绍过,而后者的使用方法与之非常相似,这里不再详述了。恢复指令也是使用WriteProcessMemory函数。

 

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

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

 

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

 

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

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

 

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

1 context.EFlags |= 0x100;

 

在处理单步执行异常时,不能简单认为EIP1就是原断点的地址,因为断点所在指令的长度是不确定的。为了重新设置断点,需要保存该断点的地址,或者干脆将所有断点都重新设置一次。具体使用什么方法则因人而异了。

 

最后提醒一下,设置断点之后使用d命令观察断点处的内存时会“露馅”,看到替换之后的0xCC。通常应该对用户隐藏这个事实,所以在处理d命令时应该将断点处原来的内容显示出来。

 

Main函数设置断点

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

main

wmain

WinMain

wWinMain

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

 

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

1 BOOL WINAPI SymFromName(
2     HANDLE hProcess,
3     PCTSTR Name,
4     PSYMBOL_INFO Symbol
5 );

 

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

 1 typedef struct _SYMBOL_INFO {
 2     ULONG SizeOfStruct;
 3     ULONG TypeIndex;
 4     ULONG64 Reserved[2];
 5     ULONG Index;
 6     ULONG Size;
 7     ULONG64 ModBase;
 8     ULONG Flags;
 9     ULONG64 Value;
10     ULONG64 Address;
11     ULONG Register;
12     ULONG Scope;
13     ULONG Tag;
14     ULONG NameLen;
15     ULONG MaxNameLen;
16     TCHAR Name[1];
17 } SYMBOL_INFO, *PSYMBOL_INFO;

这个结构体有很多字段,但目前我们只关注Address,它就是符号的起始地址。关于SYMBOL_INFO这个结构体,在后面的文章中还会提到。

 

获取Main函数地址的函数大概像下面那样: 

 1 DWORD GetEntryPointAddress() {
 2 
 3    static LPCTSTR entryPointNames[] = {
 4       TEXT("main"),
 5       TEXT("wmain"),
 6       TEXT("WinMain"),
 7       TEXT("wWinMain"),
 8    };
 9 
10    SYMBOL_INFO symbolInfo = { 0 };
11    symbolInfo.SizeOfStruct = sizeof(SYMBOL_INFO);
12 
13    for (int index = 0; index != sizeof(entryPointNames) / sizeof(LPCTSTR); ++index) {
14 
15        if (SymFromName(g_hProcess, entryPointNames[index], &symbolInfo) == TRUE) {
16 
17          return (DWORD)symbolInfo.Address;
18       }
19    }
20 
21    return 0;
22 }

 

示例代码

这次为MiniDebugger添加了b命令,其功能是设置断点,命令格式如下:

 

b [address [d]]

address为断点的地址,以十六进制表示。如果带d参数,表示删除断点,否则设置断点。如果不带任何参数,则显示所有已设置的断点。

 

这个版本的MiniDebugger示范了如何在第二次接收断点异常时进行处理,正如上文所说,这会给单步执行功能带来麻烦,所以在添加了单步执行功能之后会改回第一次接收时处理,请大家留意。另外,该版本的MiniDebugger没有对d命令进行额外处理以隐藏断点的0xCC机器码。

 

https://files.cnblogs.com/zplutor/MiniDebugger7.rar

posted on 2011-04-02 23:10  Zplutor  阅读(10508)  评论(0编辑  收藏  举报