软件中断
INT 3 指令,也被称为软件中断,是一条专门用于调试的指令,在X86系列处理器中广泛使用。它的作用是在程序执行时,触发一个中断信号,使得CPU进入调试模式,供调试器对执行现场进行各种分析。
INT 3的应用
在使用Visual C++编程时,我们可以使用内联汇编指令来插入INT 3指令,以便在需要的位置设置软件断点。示例如下:
VS没有下断点,程序会自动断在INT 3 指令所在的位置。这正是通过注入代码手工设置断点的方法。
反汇编窗口如下:
内存地址002719CE 处有INT 3 指令。
打开寄存器窗口,EIP=002719CE
INT 3属于陷阱异常,当CPU产生异常时,EIP指向的是导致异常的下一条指令。但是EIP指向的是导致异常的指令——为什么会发生回跳?
断点命中
当CPU执行INT 3指令时,在执行异常处理例程之前,CPU会保存当前的执行上下文。
实模式下INT 3 指令的执行过程:
1 REAL-ADDRESS-MODE:
2 IF ((vector_number ∗ 4) + 3) is not within IDT limit //检查根据向量号计算出向量地址是否超出了边界
3 THEN #GP;//发生保护性错误异常
4 FI;//IF语句的结束语句
5 IF stack not large enough for a 6-byte return information //检查栈是否有空间保存寄存器
6 THEN #SS;//堆栈不足以保存要压入的6字节内容(CS、IP和EFLAGS的低16位),产生堆栈异常
7 FI;//IF语句的结束语句
8 Push (EFLAGS[15:0]);
9 IF ← 0; (* Clear interrupt flag *) //清除IF
10 TF ← 0; (* Clear trap flag *) //清除TF
11 AC ← 0; (* Clear AC flag *) //清除AC
12 Push(CS); //保存当前段寄存器
13 Push(IP); //保存程序指针寄存器
14 (* No error codes are pushed *)
15 CS ← IDT(Descriptor (vector_number ∗ 4), selector)); //将异常处理例程入口地址加载到CS和IP寄存器
16 EIP ← IDT(Descriptor (vector_number ∗ 4), offset)); (* 16 bit offset AND 17 0000FFFFH *)
在实模式的单任务操作系统,CPU直接执行调试器注册的断点异常处理例程。然后执行中断返回指令(IRET),恢复被调试程序,从断点位置继续执行。
保护模式下的INT 3指令的执行流程原理上与实模式一致。
Windows保护模式下的多任务操作系统,INT 3 异常的处理函数是内核函数KiTrap03。断点指令在用户模式下的应用程序代码中,CPU会从用户模式转入内核模式。经过几个内核函数分发和处理。由于这个异常是来自用户模式,且该异常的拥有进程正在被调试(进程的Debug Port不为0),所以内核例程会把这个异常通过调试子系统以调试事件的形式分发给用户模式的调试器,内核的调试子系统会等待调试器的回复,收到调试器的回复后,调试子系统会返回到异常处理例程,异常处理例程执行IRET指令使被调试程序回复执行。
在调试器收到调式事件后,会在内部寻找与其匹配的断点记录。如果能找到,则允许用户进行交互式调试。如果找不到,则说明该断点是程序内置的断点,会弹出异常。
在Windows中,操作系统的断点异常处理函数对于x86 CPU的断点异常会有一个特殊的处理:将EIP的值减1。出于这个原因,我们在调试器看到的程序指针指向的仍然是INT 3指令的位置,而不是它的下一条指令。这样处理的目的是:
-
调试器在落实断点时只替换一个字节,如果程序指针发生改变指向了下一条指令的位置,指向的可能是原来多字节指令的第二个字节,不是一条完整的指令,造成程序的错误。 -
由于断点的存在,被调试程序于断点位置的指令在断点触发时还未被执行,按照“程序指针总是指向将要执行的那条指令”的原则,应该让其指向原指令,即倒退一个字节,指向原指令起始位置。
至此,回跳的问题得到了解答。
恢复执行
当用户结束分析希望恢复被调试程序执行时,调试器通过调试API通知调试子系统,这会使系统内核的异常分发函数返回到异常处理例程,然后异常处理例程通过IRET/IRETD指令触发一个异常返回动作,使CPU恢复执行上下文,从发生异常的位置继续执行。
当断点命中中断到调试器时,调试器会把所有断点处的INT 3替换成原本的内容,因此当用户发出恢复执行的命令后,调试器在通知系统真正恢复程序的执行前需要将断点列表所有断点全部落实一遍,但是对于命中的断点需要特殊处理——如果落实了命中断点,那么程序一恢复执行便会再次触发断点;如果没有落实,程序下次执行到该部分便不会中断。对于这种情况,大多数调试器的做法都是先单步执行一次,设置单步执行标志,然后恢复执行,将断点所在位置的指令执行完。由于设置了单步标志,CPU执行完断点位置的这条指令后会再次中断到调试器中,这次调试器不会通知用户,而是做一些内部操作后恢复程序的执行,而且将所有断点落实,这一过程一般称为“单步走出断点”,如果用户在恢复程序执行前取消了该断点,就不需要单步执行一次。
INT 3指令的特殊用途
由于INT 3 指令的特殊性,对应的机器码是0xCC,对应的汉字是“烫”。编译器在编译调试版本时会用0xCC填充刚刚分配的缓冲区,就是下图经常见到的情形:
编译器还用INT 3 指令来填充函数或代码段末尾的空闲区域,即用它来做内存对齐。
断点API
用户模式,使用DebugBreak() API ,内核模式下使用DbgBreakPoint() 或DbgBreakPointWithStatus() API 主动插入断点。
DebugBreak() 反汇编如下,只是对INT 3指令的简单包装:
1 lkd> u nt!DbgBreakPoint
2 nt!DbgBreakPoint:
3 804df8c4 cc int 3
4 804df8c5 c3 ret
DbgBreakPointWithStatus()允许向调试器传递一个整型参数:
lkd> u nt!DbgBreakPointWithStatus
804df8d1 8b442404 mov eax,[esp+0x4]
804df8d5 cc int 3
其中[esp+0x4]
代表DbgBreakPointWithStatus
函数的第一个参数。
0xCD03
INT 3指令与当n=3时的INT n指令不同,INT n指令对应的机器码是0xCD后跟1字节的n值,比如INT 23H会被编译为0xCD23。与此不同的是,INT 3指令具有独特的单字节机器码0xCC。用户可以通过_EMIT伪指令来直接嵌入机器码。
#include
int main()
{
// 手工断点
_asm INT 3;
printf("Hello INT 3!\n");
_asm
{
mov eax, eax
__asm _emit 0xcd __asm _emit 0x03
nop
nop
}
//或者使用Windows API
DebugBreak();
return 0;
}
C++程序在执行的过程中会中断到调试器,但是继续执行会报访问冲突错误。使用windbg打开可执行文件,在反编译窗口中发现了0xCD03指令
00421a9e cc int 3
00421a9f 68c47b4200 push offset test!`string' (00427bc4)
00421aa4 e89ef9ffff call test!ILT+1090(_printf) (00421447)
00421aa9 83c404 add esp,4
00421aac 8bc0 mov eax,eax
00421aae cd03 int 3
00421ab0 90 nop
00421ab1 90 nop
00421ab2 8bf4 mov esi,esp
00421ab4 ff155cb04200 call dword ptr [test!_imp__DebugBreak (0042b05c)]
反汇编程序将0xCD03翻译成了INT 3指令,继续执行,windbg会报以下错误
(c84.1e34): Break instruction exception - code 80000003 (first chance)
eax=0000000d ebx=01058000 ecx=23648826 edx=79dc4a6c esi=004213e3 edi=00fef828
eip=00421aaf esp=00fef75c ebp=00fef828 iopl=0 nv up ei pl nz na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000206
test!main+0x2f:
00421aaf 0390908bf4ff add edx,dword ptr [eax-0B7470h] ds:002b:fff48b9d=????????
其中80000003是系统定义的断点异常代码,此时程序的EIP=0X00421aaf,这指向的是位于0x00421aae的0xCD03指令的第二个字节。由于EIP指向的是一条指令的中间而不是起始处,后面的指令都错位了。以下为对比
#中断前的反汇编
00421a9e cc int 3
00421a9f 68c47b4200 push offset test!`string' (00427bc4)
00421aa4 e89ef9ffff call test!ILT+1090(_printf) (00421447)
00421aa9 83c404 add esp,4
00421aac 8bc0 mov eax,eax
00421aae cd03 int 3
00421ab0 90 nop
00421ab1 90 nop
00421ab2 8bf4 mov esi,esp
00421ab4 ff155cb04200 call dword ptr [test!_imp__DebugBreak (0042b05c)]
#中断后的反汇编
(c84.1e34): Access violation - code c0000005 (!!! second chance !!!)
eax=0000000d ebx=01058000 ecx=23648826 edx=79dc4a6c esi=004213e3 edi=00fef828
eip=00421aaf esp=00fef75c ebp=00fef828 iopl=0 nv up ei pl nz na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00010206
test!main+0x2f:
00421aaf 0390908bf4ff add edx,dword ptr [eax-0B7470h] ds:002b:fff48b9d=????????
可以看到,中断后余下的指令都已变得面目全非。由于EIP总是指向将要执行的指令,因此程序会尝试访问eax-0B7470h的内存地址,该地址为非法,因此会导致访问失效错误。
导致该EIP错位的原因是KiTrap03在分发这个异常前总是会将EIP减1,对于单字节的INT 3指令,这样的减法过后刚好指向INT 3指令或原来指令的起始地址。但是对于双字节的0xCD03指令,执行后EIP指向的是该指令的第二个字节处。解决方法为在断点命中后手动修改EIP,重定向至原本的下一指令处,调整后程序可以继续执行。
0:000> r eip = eip+1
# 修改eip后的反汇编
KERNELBASE!DebugBreak+0x2:
76aff092 cc int 3
76aff093 c3 ret
76aff094 8bff mov edi,edi
76aff096 55 push ebp
76aff097 8bec mov ebp,esp
76aff099 68ffff0080 push 8000FFFFh
76aff09e 6a03 push 3
76aff0a0 ff7504 push dword ptr [ebp+4]
归纳与提示
软件断点具有以下局限性:
-
属于代码类断点,适用于代码段,不使用于数据段和I/O空间 -
对在ROM中执行的程序(如BIOS)无法动态加载软件断点 -
在VDT或IDT还未准备就绪或被破坏的情况下,软件断点无法正常工作
以上就是良许教程网为各位朋友分享的Linu系统相关内容。想要了解更多Linux相关知识记得关注公众号“良许Linux”,或扫描下方二维码进行关注,更多干货等着你 !