在前几篇文章中,我实现的那个调试器只能被动接收调试事件并输出这些事件的信息。现在,我要将它修改成可以接收命令,并根据命令对被调试进程进行各种操作。首先从最基本的操作开始。
获取寄存器的值
每个线程都有一个上下文环境,它包含了有关线程的大部分信息,例如线程栈的地址,线程当前正在执行的指令地址等。上下文环境保存在寄存器中,系统进行线程调度的时候会发生上下文切换,实际上就是将一个线程的上下文环境保存到内存中,然后将另一个线程的上下文环境装入寄存器。
获取某个线程的上下文环境需要使用GetThreadContext函数,该函数声明如下:
第一个参数是线程的句柄,第二个参数是指向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:
十六进制数0x400只有第11位是1,其余位都是0。对于其它的标志也是用同样的方法进行判断。
读取内存内容
我对Windows自带的16位调试器DEBUG的d命令印象很深刻,这个命令可以以十六进制和ASCII编码显示进程内存的内容,用于观察数据段的数据。现在我要在调试器中添加类似的功能。
读取进程的内存使用ReadProcessMemory函数,该函数声明如下:
要想成功读取到进程的内存,需要两个条件:一是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指定的范围:
只有虚线部分的长度小于等于灰色部分的长度时,ReadProcessMemory才会成功。
以下的几幅图是ReadProcessMemory调用失败的情况:
这引出了一个问题:如何处理ReadProcessMemory失败的情况?每个人的想法或许都不同,在这里我的处理方式是:对于导致ReadProcessMemory失败的字节,以“??”来表示。如下图所示:
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