导言
本文总计八千余字,十余张图,浏览时间较长,建议先mark
在Windows上做开发的程序猿们都知道,x86架构处理器有一条特殊的指令——int 3,也就是机器码0xCC,用于调试所用,当程序执行到int 3的时候会中断到调试器,如果程序不处于调试状态则会弹出一个错误信息,之后程序就结束。使用VC开发程序时,在Debug版本的程序中,编译器会向函数栈帧中填充大量的0xCC,用于调试使用。因此,经常我们的程序发生缓冲区溢出时,会看到大量的“烫烫烫…”,这是因为“烫”的编码正是两个0xCC。
那么?为什么int 3可以让程序中断到调试器呢?没有调试运行的时候,遇到int 3又怎么出现程序崩溃呢?使用VS调试时F9下的断点是如何工作的?使用WinDbg的bp下的断点是如何工作的?使用OllyDbg使用F2下的断点呢?单步步入,单步步过怎么实现的呢?别着急,这篇文章将带领你从一个简单的int 3开始探索Windows系统至上而下的调试原理。Let’s go!
其实,无论使用VC++中的F9下断点也好,还是使用WinDbg中的bp下断点也好,也包括OllyDbg使用F2下断点,它们的工作原理都是一样的:使用了int 3。具体怎么做的呢?我们以VC++为例,当我们将光标定位到源代码的一行,按下F9后,VC++就会记下位置,随即我们使用F5启动调试程序后,VC++将会把下断点位置的机器指定第一个字节先保存起来,然后改为0xCC,这样,当程序执行到这里时,将触发到调试器,调试器然后把这个地址处的值改回保存的值,这样程序就可以往下执行,从而达到了下断的目的而又不改变程序原来的指令。我们通过实验来证实这个原理。
int main(int argc, char* argv[])
{
unsigned char* pCode = NULL;
__asm{
push eax
mov eax, asm_addr
mov pCode, eax
pop eax
}
printf("0x%08X: %02X\n", pCode, *pCode);
__asm{
asm_addr:
nop
nop
nop
nop
nop
}
return 0;
}
对比OD中该地址处的指令代码,可以发现,确实第一个字节已经变成了一条int 3中断了。
对于WinDbg的bp命令使用的是同样的手段实现的,大家可以去尝试验证一下。
对于单步步入和单步步过调试,相信到这里大家应该有自己的猜想了,可以去验证一下,不再展开,进入今天的重点吧:int 3是如何让程序中断到调试器的呢?
总体上有这么一个粗略的框架。下面就把这个结构一步步细化。
首先,对于一个调试器而言,它是作为调试会话的主动发起方。这通常有三种最常见的情景:
1、 打开调试器,文件——打开可执行文件——开始调试
2、 打开调试器,附加到一个正在运行的进程
3、 程序运行崩溃,选择一个调试器调试,其实这和2属于同一类。
不多,就这几种。远比Windows消息少得多。
现在我们知道调试器核心调试线程是一个不断获取调试消息并处理的过程。调试器在获取消息,那么谁在发送消息呢?不用猜也知道,被调试进程在发送消息。
暂且抛开调试不谈,让我们看看Windows中断与异常处理机制。
typedef enum _DBGKM_APINUMBER
{
DbgKmExceptionApi,
DbgKmCreateThreadApi,
DbgKmCreateProcessApi,
DbgKmExitThreadApi,
DbgKmExitProcessApi,
DbgKmLoadDllApi,
DbgKmUnloadDllApi,
DbgKmMaxApiNumber
} DBGKM_APINUMBER;
nt!DbgkForwardException() //发送异常消息
nt!DbgkCreateThread() //发送线程/进程创建消息
nt!DbgkExitThread() //发送线程退出消息
nt!DbgkExitProcess() //发送进程退出消息
nt!DbgkMapViewOfSection() //发送DLL加载消息
nt!DbgkUnmapViewOfSection() //发送DLL卸载消息
typedef struct _DEBUG_OBJECT {
KEVENT EventsPresent;
FAST_MUTEX Mutex;
LIST_ENTRY EventList;
ULONG Flags;
} DEBUG_OBJECT, *PDEBUG_OBJECT;
typedef struct _DEBUG_EVENT {
LIST_ENTRY EventList; // Queued to event object through this
KEVENT ContinueEvent;
CLIENT_ID ClientId;
PEPROCESS Process; // Waiting process
PETHREAD Thread; // Waiting thread
NTSTATUS Status; // Status of operation
ULONG Flags;
PETHREAD BackoutThread; // Backout key for faked messages
DBGKM_APIMSG ApiMsg; // Message being sent
} DEBUG_EVENT, *PDEBUG_EVENT;
调试器又是如何取得这些消息的呢?
调试器调用kernel32!WaitForDebugEvent()获取调试消息,这个函数内部将使用ntdll!DbgUiWaitStateChange()进一步进入Ring0的nt!NtWaitForDebugEvent()进行调试消息的获取。该进程将从前面的DEBUG_OBJECT中提取调试消息。提取之前将判断EventsPresent是否为有信号状态,前面说了,一旦被调试进程向链表中插入一个新的消息后,将会把这个事件置为有信号状态。当获取到一个新的调试消息后nt!NtWaitForDebugEvent()对消息结构体进行一个转换,就依次返回到Ring3上的调用者。然后开始对这个获取到的调试消息进行处理。
和被调试进程一样的问题,调试器又如何找到这个DEBUG_OBJECT呢?被调试进程是通过自己的EPROCESS中的DebugPort域找到的。对于调试器而言,它保存了DEBUG_OBJECT这个内核对象的句柄到调试器工作线程(DWT)的TEB的DbgSsReserved中。TEB的DbgSsReserved是一个含有两个成员的数组。其中DbgSsReserved[0]是一个指针,指向了DBGSS_THREAD_DATA结构构成的一个单向链表,这个结构描述了被调试进程的所有线程信息。DbgSsReserved[1]便是DEBUG_OBJECT的一个HANDLE。调试器通过这个句柄获取到DEBUG_OBJECT内核对象。而这个HANDLE通过ntdll!DbgUiWaitStateChange()进入Ring0时从TEB中获取后传递给了nt!NtWaitForDebugEvent()。
对于一个int 3断点异常消息而言,调试器收到这个消息以后,判断如果这个断点是自己设置的(比如F9(VC++)或F2(OD)或bp(WinDbg)),就将原来写在这个地方的指令改写回去。然后让程序继续执行。
调试器处理完一个调试消息后,使用kernel32!ContinueDebugEvent()让被调试进程继续运行。那它又是怎么做的呢?在kernel32!ContinueDebugEvent()内部调用了ntdll!DbgUiContinue(),最后调用了nt!NtDebugContinue(),该系统服务如前面所述将从消息链表中将处理的这个消息从链表中摘除。随后,调用nt! DbgkpWakeTarget()将这个节点的ContinueEvent成员置为有信号状态。如此一来,原来被调试进程中等待这个事件的线程将从等待状态中“苏醒”过来,继续开始执行。
被调试进程中等待这个事件的线程也就是原来投递调试消息的这个线程“苏醒”过来后就代表着这个消息已经被处理完毕。随后负责消息投递的nt! DbgkpQueueMessage()完成返回到nt!DbgkpSendApiMessage()后,随即调用nt! DbgkpResumeProcess()将其他线程全部解冻。到这里,被调试进程的所有线程都已经全部恢复运行了。
至此,对于一个int 3断点中断到调试器的完整过程简化如下描述:
Step 1: CPU执行 int 3时,将通过IDTR寄存器从其中断描述符表中获取中断服务例程,也就是nt!_KiTrap03(),这一点是在CPU硬件级别完成的。
Step 2: nt!_KiTrap03()按照nt!CommonExceptionDispatch()--->nt!KiDispatchException()--->nt!DbgkForwardException()的路线进入公共的调试消息发送例程:nt!DbgkpSendApiMessage()。
Step 3: 公共调试消息发送例程nt!DbgkpSendApiMessage()首先将除自身外其他所有线程冻结。然后调用nt!DbgkpQueueMessage()开始消息投递。投递的时候然后通过自身EPROCESS找到DEBUG_OBJECT,在等待互斥体Mutex后,向消息队列EventList插入一个新的调试消息。然后把DEBUG_OBJECT中的EventsPresent事件置为有信号状态,以此来通知调试器:现在有新的调试消息产生,快来读取吧。完成这个动作后,便开始等待消息中的ContinueEvent事件,从而整个进程停止运行。
Step 4:当调试器得到通知后,也就是EventsPresent事件变有信号状态后,便沿着kernel32!WaitForDebugEvent()--->ntdll!DbgUiWaitStateChange()--->nt!NtWaitForDebugEvent()进入Ring0,从DEBUG_OBJECT的消息链中提取出调试消息后原路返回到Ring3。
回到Ring3后,调试器交互界面便开始等待我们的操作。这个时候我们的程序看到的现象就是中断到了调试器。直到我们继续运行程序(比如F5(VC++/WinDbg)或者F9(OllyDbg)),调试器才开始进行调用kernel32!ContinueDebugEvent()一路进入内核把调试消息消息的ContinueEvent置为有信号,从而“解放”被中断的进程。
总体来看,DEBUG_OBJECT是连接被调试进程和调试器的核心数据结构。当调试器使用kernel32!DebugActiveProcess()时将会产生一个DEBUG_OBJECT内核对象,将句柄保存在自己线程的DbgSsReserved[1]中,把地址保存到被调试进程的EPROCESS中。同时将被调试进程的PEB中的BeingDebugged字段标示为TRUE。
下面看一张整个过程的全景图,以加深对这个过程的认识和理解:
由于微信公众平台图片清晰度限制,可以点击阅读原文链接获取高清大图,提取码:6idt
参考资料:
张银奎:《软件调试》
WRK
ReactOS
扫码关注,更多精彩
点个在看,我就写的更来劲了
本文分享自微信公众号 - 编程技术宇宙(xuanyuancoding)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。