在前几篇文章中,我实现的那个调试器只能被动接收调试事件并输出这些事件的信息。现在,我要将它修改成可以接收命令,并根据命令对被调试进程进行各种操作。首先从最基本的操作开始。

获取寄存器的值

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

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

用按位与操作就可以得知某个标志是否为1。例如,要检查OF是否为1:

if ((context.EFlags & 0x400) != 0) {
std::wcout << TEXT("OF ");
}

十六进制数0x400只有第11位是1,其余位都是0。对于其它的标志也是用同样的方法进行判断。

读取内存内容

我对Windows自带的16位调试器DEBUG的d命令印象很深刻,这个命令可以以十六进制和ASCII编码显示进程内存的内容,用于观察数据段的数据。现在我要在调试器中添加类似的功能。

读取进程的内存使用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异常。

下图是ReadProcessMemory调用成功的情况,灰色部分是用户模式地址空间中已分配的部分,虚线部分是由lpBaseAddress和nSize指定的范围:

[Win32]一个调试器的实现(四)读取寄存器和内存_调试器

只有虚线部分的长度小于等于灰色部分的长度时,ReadProcessMemory才会成功。

 

以下的几幅图是ReadProcessMemory调用失败的情况:

这引出了一个问题:如何处理ReadProcessMemory失败的情况?每个人的想法或许都不同,在这里我的处理方式是:对于导致ReadProcessMemory失败的字节,以“??”来表示。如下图所示:

[Win32]一个调试器的实现(四)读取寄存器和内存_寄存器_02

d 3FFFFFD0表示显示从地址0x3FFFFFD0开始的内存。由于0x40000000之前的地址空间是未分配的,所以前面的48个字节显示“??”。

要注意的是这种处理方式是以字节为单位的,也就是说对于每个字节都要调用一次ReadProcessMemory。如果要显示128个字节,则要调用ReadProcessMemory128次。你肯定会认为这样做的话效率会很低,不过经过实际的使用情况来看,显示速度是可以接受的,与调用一次ReadProcessMemory读取所有内存的做法几乎没有差别。

 

具体的做法可以参考示例代码。如果你有更好的做法,不妨一起分享一下。

至于右边的字符显示,我只使用了ASCII字符集对字节进行解码,而没有用ANSI字符集。原因是中文字符使用两个字节表示,如果一个中文字符的第一个字节位于一行的末尾,而第二个字节位于下一行的头部,这种情况该如何处理呢?想不出好的解决方法,因此只好放弃ANSI字符集了。

0x00~0x1F和0x81~0xFF之间的ASCII字符不能显示,我分别以“.”和“?”来代替。

示例代码

这次MiniDebugger的结构作了很大的改进,主要为了支持与用户的交互以及以后功能的添加。要注意,该调试器只支持单线程程序,如果用它来调试多线程程序,会导致两者都陷入阻塞状态。当然,要改成支持多线程也不是很难,你可以尝试这么做。代码中都作了注释,这里就不再赘述了。下面是当前支持的命令:

s path

启动被调试进程,开始进行调试。如果路径中有空格,则应该用双引号括住路径。例如:s C:\windows\notepad.exe。

t

结束被调试进程,停止调试。

g [c]

继续被调试进程的执行。如果不带参数c,则表示未处理异常;带参数c则表示已处理了异常。

r

显示被调试进程的寄存器的值。

d [address] [length]

显示被调试进程的内存。如果省略了length参数,则显示128个字节;如果两个参数都省略,则显示EIP地址处的128个字节。

q

结束调试并退出调试器。

如果想让被调试进程运行到某处停下来,以便测试各种命令,可以在代码中加入__asm int 3;语句,或者抛出一个异常,让调试器捕捉到异常即可。



作者:

​​Zplutor​