很多人都应该见过“烫烫烫”这个神一般存在的字符串,一旦“烫烫烫”出现的时候,就说明你玩坏了——指针越界,访问到了非法内存。
那么为啥是“烫烫烫”,跟断点有啥关系?
INT 3
我们在用VC进行调试时,常常会观察到一块刚分配的内存或字符串被填满了“CC”,而0xCCCC正好是“烫”这个汉字的GB2312编码。另外很巧的是 0xCC又正好是INT3指令的机器码。这显然不是什么巧合,而是我们的编译器故意这么做的。至于原因,先看INT3这条指令是干嘛的?
x86架构下提供了一条专门用来支持调试的指令,即INT 3。这条指令的目的是使CPU中断到调试器。
看下面一个简单的例子:
在调试状态下执行INT 3指令,程序就会断下来并提示这是一个Break instruction exception(上图右半部分)。并且从上图可以看到INT 3的机器码是0xCC。
所以,编译器在调试状态下会把未初始化的缓冲区填充为0xCC(0xCD),目的就是为了因缓冲区溢出等原因程序指针指向了这块区域,遇到INT 3指令而中断到调试器。
PS: 在实际的代码中,INT3也是能派上用场的。曾经在调试一个程序的时候,需要在一个宏里面下断点,而通过VS是没法直接在宏里面下断点的。所以当时做了一件事情,就是在宏里面需要断下来的地方加入了INT 3,这样程序一旦跑起来到这个地方就会自动断下来。
软件断点
软件断点是我们最常用的断点,用来在程序代码中设置断点。当代码执行到断点所在行时程序便会断下来,这个时候可以通过调试器观察,修改此时寄存器上下文,内存数据等。
软件断点实现的原因正是通过INT 3指令来实现的
我们在VS,Windbg等调试器中设置一个断点的时候,究竟发生了什么?
l 调试器首先会在内存映射中找到对应的断点位置;
l 将断点位置的第一个字节替换成0xCC,即INT 3,然后将被替换的这个字节保存起来;
l 一旦程序执行到INT 3指令,就会产生一个断点中断到调试器,这就是断点命中;
l 当用户恢复程序运行时,调试器实际做的事情就是恢复INT 3指令替换的那个字节,让程序按照原指令执行。
我们做个实验来验证一下:
对于如下代码:进程为breakpoint.exe
我们用VS在第10行printf语句设置一个断点,然后将程序在VS下运行起来,主要不要让代码跑到断点处。
这个时候我们用Windbg也挂住breakpoint.exe进程,查看第9行代码的反汇编:
可以看到第一个指令就是INT 3,现在我们用VS中同样查看一下第9行汇编:
为啥同样的进程状态下,两个调试器看到的指令不一样,因为VS是设置断点的时候存储了被INT 3指令替换的那个字节的内容,所以在UI上展示的时候VS可以还原原始代码的情况,而实际上Windbg展示的才是进程当前真实的指令。原始代码本身是“movesi, esp”指令,机器码是0x8bf4,因为第一个字节0x8b被替换成了0xCC,所以导致windbg下面看到的下一个字节0xf4被解析成了“hlt”指令。
使用INT 3指令产生的断点是依靠插入指令和软件中断机制工作的吗,因此把这类断点称为软件断点。但是软件断点也有局限性:
l 可以让CPU执行到代码的某个地址停下来,但不适用于数据段和I/O空间;
l 对于在ROM中执行程序,无法动态的添加软件断点,因为目标内存是只读的。
l 依赖于中断机制的正常运行,如果中断向量表或者中断描述表没有准备好或者被破坏,软件断点是无法正常工作的。
硬件断点
硬件断点之所以“硬”,是因为硬件断点依赖硬件。英特尔从386开始,增加了调试寄存器和硬件断点的特性。
IA-32架构定义了8个调试寄存器,其中4个用来存储断点地址,2个寄存器保留,1个调试控制寄存器,1个调试状态寄存器。也就是说,最多可以设置4个硬件断点。
硬件断点有什么作用:
l 读写内存中的数据中断;
l 执行内存中的代码中断(作用类似于软件断点);
l 读写I/O端口时中断;
我们日常工作中最常用到的硬件断点的场景就是“读写内存中的数据中断”,即监控某个内存地址读写,也叫内存断点。特别是在多线程环境下监控某些全局变量的状态,有时候能起到奇效。
设置一个硬件断点,本质上就是将要监控的地址写入到一个调试寄存器,以及把相关的控制选项写入到控制寄存器。一旦满足调试寄存器中设置的状态,断点就会被触发。看个例子:
如上代码,变量flag初始化后并没有被使用,但是打印出来的值却不是0x123。
当然,以上这个例子很容易发现对数组a的访问越界了,而实际项目出问题的代码往往是很难直接看出问题原因的。
我们来调试一下以上代码,看看究竟是什么时候flag的值被修改的。
1. 首先用Windbg启动被调试程序breakpoint.exe;
2. 设置断点到main函数:bpbreakpoint!wmain;
3. 运行程序到断点处:main函数的入口处;
4. 通过dv /V命令查看当前栈帧的局部变量信息;
5. 知道了flag变量的地址是002cf9f0,设置一个硬件断点监控地址为002cf9f0的写行为。
下面是8个调试寄存器的值:
dr0被设置成了我们要监控的地址,dr6,dr7分别是调试状态寄存器,调式控制寄存器。
上图展示了两个断点,第一个是我们之前设置的软件断点,第二个就是硬件断点:参数w表示只监控该地址的写行为,4表示监控长度为4个字节。
6. 继续运行程序,会遇到第一次中断:
断下来的这句指令是将栈帧部分填充为0xCCCCCCCC,“烫烫烫”又出现了。另外这段初始化指令只在调试版本中才会有。
7. 继续运行程序,第二次断下来:
这次是因为对变量flag赋值为123而断下来,意料之中。
8. 第三次断住:
以上指令是将ecx的值5赋值给正好是flag所在的内存地址:ebp + eax * 4 – 20h == ebp– 0x0C。
是第18行代码干的,也就是说因为数组访问越界从而影响到了flag的值!
硬件断点功能强大,但是最大的缺点受限于硬件——数量限制,最多4个。