软件断点探幽

软件断点

x86系列的处理器支持一条专门用来调试指令INT 3,也即是通常所说的“软件断点”,这条指令的目的是使CPU中断到调试器,以供调试者对执行现场进行分析。调试程序时,可以在可能有问题的地方插入INT 3指令。

INT 3

无论是Visual C++亦或是GCC都支持内联汇编指令,VC++示例如下:

#include<iostream>
int main()
{
    int a = 0;
    __asm INT 3;
    std::cout << a << std::endl;
    std::cin.get();
    return 0;
}

在IDE里点击运行,虽然左侧没有下断点,但是程序中断并返回到了IDE中的调试器

1548731159569

由此可见,插入一条INT 3指令相当于在那里设置了一个断点,这是手工设置断点的一种常用方法。此时查看程序的反汇编可以发现,程序中断在了该指令处

001618A3  sub         esp,0CCh  
001618A9  push        ebx  
001618AA  push        esi  
001618AB  push        edi  
001618AC  lea         edi,[ebp-0CCh]  
001618B2  mov         ecx,33h  
001618B7  mov         eax,0CCCCCCCCh  
001618BC  rep stos    dword ptr es:[edi]  
    int a = 0;
001618BE  mov         dword ptr [a],0  
    __asm INT 3;
001618C5  int         3  <-
001618C6  mov         esi,esp  

查看寄存器窗口,EIP的值为INT 3指令的地址

EAX = CCCCCCCC EBX = 0056B000 ECX = 00000000 EDX = 0016A57C ESI = 0016134D EDI = 0039FE78 EIP = 001618C5 ESP = 0039FDA0 

至此有一个疑问:INT 3属于陷阱异常,当CPU产生异常时,EIP指向的是导致异常的下一条指令。但是在寄存器的观察结果中EIP指向的是导致异常的指令——为什么会发生回跳?

调试器中的断点

当我们在调试器中对代码的某一行设置断点时,调试器会先把这里本来指令的第一个字节保存起来,然后写入一条INT 3指令。因为INT 3指令的机器码为0xCC,仅有一个字节,所以断点的设置和取消也只需要保存和恢复一个字节。调试器中指令的保存与替换是动态进行的,这也是为什么在非调试状态下我们可以在注释行设置断点的原因。当开始调试时,编译器会读取断点记录,并将这些真正的断点设置到目标代码的内存映像中,这个过程称为落实断点。在落实断点的过程中,IDE如果发现某个断点的位置对应不到内存中的代码段,会发出警告。

断点命中

当CPU执行到INT 3指令时,在跳转到处理例程之前,CPU会保存当前执行的上下文。下面的代码为实模式下INT 3的执行过程:

REAL-ADDRESS-MODE:
IF ((vector_number ? 4) + 3) is not within IDT limit ;检查根据中断向量号计算出的向量地址是否超出了了中断向量表的边界
    THEN #GP;产生保护性异常
FI;
IF stack not large enough for a 6-byte return information;检查栈上是否有足够的空间来保存寄存器
    THEN #SS;产生堆栈异常
FI;
Push (EFLAGS[15:0]);
IF ← 0; (* Clear interrupt flag *)
TF ← 0; (* Clear trap flag *)
AC ← 0; (* Clear AC flag *)
Push(CS);
Push(IP);
(* No error codes are pushed *)
CS ← IDT(Descriptor (vector_number ? 4), selector));将处理例程的基址压入CS
EIP ← IDT(Descriptor (vector_number ? 4), offset)); (* 16 bit offset AND 0000FFFFH *)
END

对于在实模式下运行的单任务系统,断点异常的处理例程通常是调试器程序注册的函数,CPU在中断后直接执行调试器的代码。当程序执行好调试功能,需要恢复被调试程序执行时,会执行中断返回指令IRET,令CPU从断点位置继续执行。保护模式下的INT 3指令的执行流程原理上与实模式一致。

对于工作在保护模式下的多任务系统,INT 3异常的处理函数是操作系统的内核函数KiTrap03。由于断点指令位于用户模式的程序代码中,在执行处理例程前CPU会从用户模式切换到内核模式,会经过几个内核函数的分发和处理。由于这个异常是来自用户模式,且该异常的拥有进程正在被调试(进程的Debug Port不为0),所以内核例程会把这个异常通过调试子系统以调试事件的形式分发给用户模式的调试器,内核的调试子系统会等待调试器的回复,收到调试器的回复后,调试子系统会返回到异常处理例程,异常处理例程执行IRET指令使被调试程序回复执行。

IDE对断点事件的处理

在IDE收到调试事件后,它会根据调试事件数据结构中的程序指针得到断点的位置,然后在自身内部的断点列表中寻找与其匹配的断点记录。如果能找到,则说明这是IDE自身设置的断点,执行一系列的准备动作后允许用户进行交互性调试;如果找不到,则说明该断点是程序内置的断点,会弹出异常。值得注意的是,在调试器中我们无法看见动态替换到程序的INT 3指令,大多数调试器的做法是在被调试程序中断到调试器时,会先将所有断点位置被替换到 指令恢复为原来的指令,然后再把控制权交还给用户,同时也会在反汇编窗口和内存观察窗口进行特殊的处理,让用户看到的始终是程序原本的内容。

在Windows中,操作系统的断点异常处理函数对于x86 CPU的断点异常会有一个特殊的处理:将EIP的值减1。出于这个原因,我们在调试器看到的程序指针指向的仍然是INT 3指令的位置,而不是它的下一条指令。这样处理的目的是:

  • 调试器在落实断点时只替换一个字节,如果程序指针发生改变指向了下一条指令的位置,指向的可能是原来多字节指令的第二个字节,不是一条完整的指令,造成程序的错误。
  • 由于断点的存在,被调试程序于断点位置的指令在断点触发时还未被执行,按照“程序指针总是指向将要执行的那条指令”的原则,应该让其指向原指令,即倒退一个字节,指向原指令起始位置。

至此,回跳的问题得到了解答。

当断点命中中断到调试器时,调试器会把所有断点处的INT 3替换成原本的内容,因此当用户发出恢复执行的命令后,调试器在通知系统真正恢复程序的执行前需要将断点列表所有断点全部落实一遍,但是对于命中的断点需要特殊处理——如果落实了命中断点,那么程序一恢复执行便会再次触发断点;如果没有落实,程序下次执行到该部分便不会中断。对于这种情况,大多数调试器的做法都是先单步执行一次,设置单步执行标志,然后恢复执行,将断点所在位置的指令执行完。由于设置了单步标志,CPU执行完断点位置的这条指令后会再次中断到调试器中,这次调试器不会通知用户,而是做一些内部操作后恢复程序的执行,而且将所有断点落实,这一过程一般称为“单步走出断点”,如果用户在恢复程序执行前取消了该断点,就不需要单步执行一次。

VS2017

VS2017的处理流程与书中VC6的动态替换不同,其中断的原理是在程序运行中插入对CheckForDebuggerJustMyCode,EAX中存储的API中对应的处理例程的地址。在windbg中查看该地址的内容如下:

Breakpoints!_3A1BB576_main@cpp:
0010f027 0101 add     dword ptr [ecx], eax

随后跳转入调试器的中断处理流程

Breakpoints!ILT+720(__CheckForDebuggerJustMyCode:
001012d5 e9d6170000 jmp     Breakpoints!__CheckForDebuggerJustMyCode (00102ab0)

Breakpoints!__CheckForDebuggerJustMyCode:
00102ab0 55             push    ebp
00102ab1 8bec           mov     ebp, esp
00102ab3 83ec08         sub     esp, 8
00102ab6 894dfc         mov     dword ptr [ebp-4], ecx
00102ab9 8b45fc         mov     eax, dword ptr [ebp-4]
...

VS2017中的反汇编与windbg反汇编的对比如下,可以发现并无指令的替换。

;VS2017
001020FC 8D BD 34 FF FF FF    lea         edi,[ebp-0CCh]  
00102102 B9 33 00 00 00       mov         ecx,33h  
00102107 B8 CC CC CC CC       mov         eax,0CCCCCCCCh  
0010210C F3 AB                rep stos    dword ptr es:[edi]  
0010210E B9 27 F0 10 00       mov         ecx,offset _3A1BB576_main@cpp (010F027h)  
00102113 E8 BD F1 FF FF       call        @__CheckForDebuggerJustMyCode@4 (01012D5h)  
    int a = 10;
00102118 C7 45 F8 0A 00 00 00 mov         dword ptr [a],0Ah  
    a += 1;
0010211F 8B 45 F8             mov         eax,dword ptr [a]  
00102122 83 C0 01             add         eax,1  
00102125 89 45 F8             mov         dword ptr [a],eax  
    return 0;
00102128 33 C0                xor         eax,eax 

;windbg
001020fc 8dbd34ffffff   lea     edi, [ebp-0CCh]
00102102 b933000000     mov     ecx, 33h
00102107 b8cccccccc     mov     eax, 0CCCCCCCCh
0010210c f3ab           rep stos dword ptr es:[edi]
0010210e b927f01000     mov     ecx, offset Breakpoints!_NULL_IMPORT_DESCRIPTOR <PERF> (Breakpoints+0x1f027) (0010f027);设置中断地址
00102113 e8bdf1ffff     call    Breakpoints!ILT+720(__CheckForDebuggerJustMyCode (001012d5);引发中断
00102118 c745f80a000000 mov     dword ptr [ebp-8], 0Ah
0010211f 8b45f8         mov     eax, dword ptr [ebp-8] ss:002b:006ff76c=0000000a
00102122 83c001         add     eax, 1
00102125 8945f8         mov     dword ptr [ebp-8], eax
00102128 33c0           xor     eax, eax

“烫烫烫”

由于INT 3指令的特性,它有一些特殊的用途。比如在VC中喜闻乐见的“烫烫烫”

观察变量a所对应的内存空间,发现其位置被0xcc所填满

0x00F5FB71  cc cc cc cc cc cc cc cc cc cc cc  ???????????
0x00F5FB7C  cc cc cc cc cc cc cc cc cc cc cc  ???????????
0x00F5FB87  cc cc cc cc cc 44 fc ad 0e a4 fb  ?????D??.??
0x00F5FB92  f5 00 5e 2e aa 00 01 00 00 00 a8 

0xcc又是汉字“烫”的简码,所以许多的“烫”便堆在了一块。这是bug吗?不尽然。0xccINT 3指令的机器码,为了辅助调试,编译器在编译调试版本时会使用0xcc来填充刚刚分配的缓冲区,如果因为缓冲区或者堆栈溢出导致了程序指针意外地指向了这些区域,便会因为遇到INT 3指令而马上中断到调试器。事实上,编译器还会用INT 3指令来填充函数或代码段末尾的空闲区域,用来做内存对齐。

扩展:未初始化的栈变量写入的是0xcc,动态变量写入0xcd,动态变量周围写入0xfd来检测数组越界,被销毁的内存中写入0xdd

断点API

Windows操作系统提供了供应用程序向自己的代码中插入断点的API,在用户模式下使用DebugBreak(),内核模式下为DbgBreakPoint()或向调试器传递整型参数的DbgBreakPointWithStatus()。通过反汇编可以看出,这些API是对INT 3指令的简单封装

nt!DbgBreakPointWithStatus:
fffff802`4de8e930 cc              int     3
fffff802`4de8e931 c3              ret

0xCD03

INT 3指令与当n=3时的INT n指令不同,INT n指令对应的机器码是0xCD后跟1字节的n值,比如INT 23H会被编译为0xCD23。与此不同的是,INT 3指令具有独特的单字节机器码0xCC,而且系统会给予INT 3指令一些特殊待遇,比如在虚拟8086模式下免于IOPL检查等。因此编译器在编译INT 3时会将其编译为0xCC,但是用户可以通过_EMIT伪指令来直接嵌入机器码

#include<iostream>
#include<stdlib.h>
int main()
{
    char* a = new char[10];
    std::cout << a << std::endl;
    __asm _emit 0xcd __asm _emit 0x03;
    std::cin.get();
    return 0;
}

程序在执行的过程中会中断到调试器,但是继续执行会报访问冲突错误。使用windbg打开可执行文件,在反编译窗口中发现了0xCD03指令

001027ab e82febffff   call    Breakpoints!ILT+730(__RTC_CheckEsp) (001012df)
001027b0 cd03         int     3
001027b2 8bf4         mov     esi, esp

反汇编程序将0xCD03翻译成了INT 3指令,继续执行,windbg会报以下错误

0:000> g
(122c.23cc): Break instruction exception - code 80000003 (first chance)
eax=537fabe0 ebx=00c31000 ecx=246eb1f4 edx=53733a68 esi=00eff850 edi=00eff934
eip=001027b1 esp=00eff850 ebp=00eff934 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
Breakpoints!main+0x61:
001027b1 038bf48b0d98    add     ecx,dword ptr [ebx-67F2740Ch] ds:002b:98d09bf4=????????

其中80000003是系统定义的断点异常代码,此时程序的EIP=0X001027B1,这指向的是位于0x001027b00xCD03指令的第二个字节。由于EIP指向的是一条指令的中间而不是起始处,后面的指令都错位了。以下为对比

#中断前的反汇编
001027b0 cd03         int     3
001027b2 8bf4         mov     esi, esp
001027b4 8b0d98d01000 mov     ecx, dword ptr [Breakpoints!_imp_?cinstd (0010d098)]
001027ba ff159cd01000 call    dword ptr [Breakpoints!_imp_?get?$basic_istreamDU?$char_traitsDstdstdQAEHXZ (0010d09c)]
001027c0 3bf4         cmp     esi, esp
001027c2 e818ebffff   call    Breakpoints!ILT+730(__RTC_CheckEsp) (001012df)

#中断后的反汇编
001027b1 038bf48b0d98 add     ecx, dword ptr [ebx-67F2740Ch] ds:002b:988d6bf4=????????
001027b7 d010         rcl     byte ptr [eax], 1
001027b9 00ff         add     bh, bh
001027bb 159cd01000   adc     eax, offset Breakpoints!_imp_?get?$basic_istreamDU?$char_traitsDstdstdQAEHXZ (0010d09c)
001027c0 3bf4         cmp     esi, esp
001027c2 e818ebffff   call    Breakpoints!ILT+730(__RTC_CheckEsp) (001012df)

可以看到,中断后余下的指令都已变得面目全非。由于EIP总是指向将要执行的指令,因此程序会尝试访问ebx-67F2740Ch的内存地址,该地址为非法,因此会导致访问失效错误。

(1ca4.508): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=5567abe0 ebx=007fe000 ecx=568273d8 edx=55343a68 esi=008ffdc0 edi=008ffea4
eip=001027b1 esp=008ffdc0 ebp=008ffea4 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010246
Breakpoints!main+0x61:
001027b1 038bf48b0d98    add     ecx,dword ptr [ebx-67F2740Ch] ds:002b:988d6bf4=????????

导致该EIP错位的原因是KiTrap03在分发这个异常前总是会将EIP减1,对于单字节的INT 3指令,这样的减法过后刚好指向INT 3指令或原来指令的起始地址。但是对于双字节的0xCD03指令,执行后EIP指向的是该指令的第二个字节处。解决方法为在断点命中后手动修改EIP,重定向至原本的下一指令处,调整后程序可以继续执行。

0:000> r eip = eip+1
# 修改eip后的反汇编
001027b2 8bf4         mov     esi, esp
001027b4 8b0d98d01000 mov     ecx, dword ptr [Breakpoints!_imp_?cinstd (0010d098)]
001027ba ff159cd01000 call    dword ptr [Breakpoints!_imp_?get?$basic_istreamDU?$char_traitsDstdstdQAEHXZ (0010d09c)]
001027c0 3bf4         cmp     esi, esp
001027c2 e818ebffff   call    Breakpoints!ILT+730(__RTC_CheckEsp) (001012df)

归纳与提示

软件断点具有以下局限性:

  • 属于代码类断点,适用于代码段,不使用于数据段和I/O空间
  • 对在ROM中执行的程序(如BIOS)无法动态加载软件断点
  • 在VDT或IDT还未准备就绪或被破坏的情况下,软件断点无法正常工作

虽然软件断点有些许不足,但是因为其使用方便,而且没有数量限制,目前仍被广泛使用。

发表评论

电子邮件地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据