【系统篇】从int 3探索Windows应用程序调试原理

探索调试器下断点的原理

  在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,这样,当程序执行到这里时,将触发到调试器,调试器然后把这个地址处的值改回保存的值,这样程序就可以往下执行,从而达到了下断的目的而又不改变程序原来的指令。我们通过实验来证实这个原理。

使用VC++新建控控制台程序,在main函数中键入如下代码:

 1 int _tmain(int argc, _TCHAR* argv[])
 2 {
 3      unsigned char* pCode = NULL;
 4      __asm{
 5           push eax
 6           mov eax, asm_addr
 7           mov pCode, eax
 8           pop eax
 9      }
10      printf("0x%08X: %02X\n", pCode, *pCode);
11      __asm{
12 asm_addr:
13           nop
14           nop
15           nop
16           nop
17           nop
18      }
19      return 0;
20 }

代码很简单,两段内嵌汇编,其中第二段就是一段nop指令,也就是5个0x90。第一段是把标号asm_addr代表的地址,也就是第二段内嵌汇编的地址保存到局部变量pCode中,然后把这个地址的第一个字节打印出来。首先编译直接Ctrl+F5非调试运行,得到如下的结果:

读取到的内容是0x90,正是第一个nop指令。现在我们把光标定在第一个nop那一样,按下F9,设置一个断点。然后使用F5调试运行,输出的内容如下:

可以看到,在调试状态下读取到的内容成了0xCC,就是一条int 3指令。这印证了前面的描述。需要注意的是,当你使用VC++调试的内存查看窗口查看到的内容仍然是0x90,这是因为VC在给调试者呈现的时候屏蔽了它设置断点的操作,呈现的时候给你显示原来的数据。

我们再看一看使用OllyDbg的F2,同样的,启动OD打开上面生成的那个程序,然后在任意一处按下F2,如下图所示:

我选择了在地址0x01041790处按下了F2,可以看到OD已经将这个地址标注为红色,表示这里有一个断点。那么此时,这个地址处的第一个字节代码已经从图中的0x8B改变成0xCC了。同样和VC++有一样的问题,当你直接通过OD的内存查看窗口查看0x01041790的内存时它也会给你呈现原来的数据,这样就看不到变化了。为此,我们需要借助其他的工具。这里我选择使用PCHunter的内存查看功能,指定地址将这段内存的内容dump出来,如下图所示:

保存到文件打开如下所示:

对比OD中该地址处的指令代码,可以发现,确实第一个字节已经变成了一条int 3中断了。

对于WinDbg的bp命令使用的是同样的手段实现的,大家可以去尝试验证一下。

对于单步步入和单步步过调试,相信到这里大家应该有自己的猜想了,可以去验证一下,不再展开,进入今天的重点吧:int 3是如何让程序中断到调试器的呢?

Windows XP及其之后应用程序调试模型

仔细想想,在一次调试过程中,有哪些主要角色呢?至少有一个被调试进程,一个调试器吧。这是当然,那么除此之外呢?还需要操作系统层面的支持。下面看一张Windows下的应用程序调试简单模型图:

总体上有这么一个粗略的框架。下面就把这个结构一步步细化。

首先,对于一个调试器而言,它是作为调试会话的主动发起方。这通常有三种最常见的情景:

1、     打开调试器,文件——打开可执行文件——开始调试

2、     打开调试器,附加到一个正在运行的进程

3、     程序运行崩溃,选择一个调试器调试,其实这和2属于同一类。

无论怎样,调试器都是作为调试会话的主动发起方,通过调用DebugActiveProcess() API开启一次调试过程。对于一个调试器进程而言,它的核心工作就是进行一个调试信息获取然后处理的循环。这有点像开发使用SDK开发Windows 应用程序使用的GetMessage,然后再处理循环。如下图所示(这里使用一下张银奎先生著作《软件调试》第229页的截图):

调试器使用WaitForDebugEvent来捕获调试消息,然后进行调试消息处理,处理完毕之后使用ContinueDebugEvent使被调试线程继续运行等待下一个调试事件。非常简单的逻辑,简直和消息处理循环如出一辙啊。那么调试消息总共有哪些呢?查阅MSDN,在调试消息结构体DEBUG_EVENT中的第一个字段dwDebugEventCode 指出了所有的调试消息类型:

不多,就这几种。远比Windows消息少得多。

现在我们知道调试器核心调试线程是一个不断获取调试消息并处理的过程。调试器在获取消息,那么谁在发送消息呢?不用猜也知道,被调试进程在发送消息。

暂且抛开调试不谈,让我们看看Windows中断与异常处理机制。

Intel x86平台上Windows的异常处理流程

很多书上都曾讲到,对于一个CPU,它内部有一个48bit的IDTR寄存器。在保护模式下,它指向了一个具有8*256项的一张表——IDT,中断描述符表。表中指定了当每个中断(或陷阱)出现时,CPU将要执行的处理函数——ISR,中断服务例程。

对于 int 3而言,当CPU执行它时将自动从IDT中取出向量号为3的ISR来执行。在Windows平台上,操作系统在这个表的3号向量ISR填充为_KiTrap03()。该函数做一些简单信息保存后调用nt!CommonDispatchException,该函数再调用nt!KiDispatchException()进行异常的分发过程。

nt!KiDispatchException()是Windows NT系列操作系统中Ring0级异常分发的核心枢纽。关于该函数的详细信息,《软件调试》一书中有详细的讲解。总体来说,nt!KiDispatchException()主要干两件重要的事:

第一,先把异常消息发送给调试子系统,等待调试子系统的处理。如果当前进程没有处于调试状态,或者调试子系统没有对该异常进行处理,那么将进行第二步。

第二,提交给Ring3的ntdll!KiUserExceptionDispatcher(),用于向量化异常处理和结构化异常处理。

对于一个处于调试状态的进程来说,异常发生时,首先得到通知的是调试器,如果调试器未处理异常,那么将进入第二步,比如通过结构化异常处理进入你的__except处理分支。

Windows平台被调试进程投递调试消息过程

这篇文章将重点关注第一点。在nt!KiDispatchException()中,调用nt!DbgkForwardException()向调试子系统报告异常信息。除了异常信息之外,Windows还定义了其他几种调试信息。如下所示:

在WRK中定义了一个被调试进程将要发送的调试消息类型枚举:

 1 typedef enum _DBGKM_APINUMBER
 2 {
 3     DbgKmExceptionApi,
 4     DbgKmCreateThreadApi,
 5     DbgKmCreateProcessApi,
 6     DbgKmExitThreadApi,
 7     DbgKmExitProcessApi,
 8     DbgKmLoadDllApi,
 9     DbgKmUnloadDllApi,
10     DbgKmMaxApiNumber
11 } DBGKM_APINUMBER;

在被调试进程Ring0级,和上面枚举值对应的调试消息发送者为下列函数:

1 nt!DbgkForwardException()     //发送异常消息
2 nt!DbgkCreateThread()       //发送线程/进程创建消息
3 nt!DbgkExitThread()        //发送线程退出消息
4 nt!DbgkExitProcess()                  //发送进程退出消息
5 nt!DbgkMapViewOfSection()       //发送DLL加载消息
6 nt!DbgkUnmapViewOfSection()    //发送DLL卸载消息

仔细观察这些函数以及发送的消息类型,和前面调试器获取的消息类型基本吻合。调试器获取到的哪些调试消息就是被调试进程通过这些函数发出的。

上面这些函数最终又会调用nt!DbgkpSendApiMessage()来发送调试信息。

那么,对于int 3,它属于哪种消息呢?对照上面几种消息类型,只能是异常。我们重点关注int 3所属的异常调试消息。异常类型的调试消息是通过函数nt!DbgkForwardException()发出的,上面说了,所有类型的消息都是通过nt!DbgkpSendApiMessage()发出的,而在发送消息之前,nt!DbgkpSendApiMessage()会执行一个操作:调用nt!DbgkpSuspendProcess()将当前进程中除自己所在线程外的所有其他线程全部冻结,也就是停止它们的执行。然后开始调用nt! DbgkpQueueMessage()真正开始投送消息了。

那么,消息如何发送?发送到哪里呢?一次调试会话中的两个重要角色:调试器与被调试进程是通过什么连接在一起呢?在Windows XP及以后的系统上,是一个通过调试对象的内核对象实现的。在WRK中有关于这个内核对象的定义:

1 typedef struct _DEBUG_OBJECT {
2     KEVENT EventsPresent;
3     FAST_MUTEX Mutex;
4     LIST_ENTRY EventList;
5     ULONG Flags;
6 } DEBUG_OBJECT, *PDEBUG_OBJECT;

很简单,就四个成员。其中EventList是最重要的,它作为头将所有的调试消息构成了一个双向链表。发送消息的时候向链表中插入一个节点,然后设置EventsPresent事件让调试器来取消息。取消息的时候从链表中取得一个节点,然后使用nt! KeClearEvent()来关闭EventsPresent事件。调试消息处理完毕从链表中将这个节点删除。同时为了调试器和被调试进程对这个链表的操作进行互斥,设置了一个Mutex。

消息链表中链接的节点是DEBUG_EVENT结构体,需要指出的是,调试器在Ring3调用kernel32!WaitForDebugEvent()获取到的调试消息也是一个叫DEBUG_EVENT的结构体,不过这两者并不相同。中间涉及到一些结构转化,关于这一点稍后再表。在这个调试消息队列中的节点,即Ring 0下的DEBUG_EVENT,结构在WRK中如下定义:

 1 typedef struct _DEBUG_EVENT {
 2     LIST_ENTRY EventList;      // Queued to event object through this
 3     KEVENT ContinueEvent;
 4     CLIENT_ID ClientId;
 5     PEPROCESS Process;         // Waiting process
 6     PETHREAD Thread;           // Waiting thread
 7     NTSTATUS Status;           // Status of operation
 8     ULONG Flags;
 9     PETHREAD BackoutThread;    // Backout key for faked messages
10     DBGKM_APIMSG ApiMsg;       // Message being sent
11 } DEBUG_EVENT, *PDEBUG_EVENT;

其中有一个成员叫ContinueEvent,顾名思义,”继续事件”。对于一个新的调试信息,被调试进程将这个节点的这个ContinueEvent事件初始化为无信号状态。将调试消息节点插入后,将开始来使用nt! KeWaitForSingleObject()来等待这个事件。由于之前已经使用nt!DbgkpSuspendProcess()将本进程其他线程都已经冻结了,这个等待将导致自己也停止运行。至此,被调试进程所有线程都将停止运行。所以在调试的时候中断后,被调试进程出现“卡死”的现象,就是这样实现的。

那么这个DEBUG_OBJECT放在哪里的呢?如何找到它?在被调试进程的EPROCESS结构中,有一个DebugPort成员,它就是作为一个指针,指向了自己进程所属的DEBUG_OBJECT。

Windows平台调试器获取调试消息及其处理过程

调试器又是如何取得这些消息的呢?

调试器调用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 断点完整过程

  至此,对于一个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。

全景展现

下面看一张着过过程的全景图,以加深对这个过程的认识和理解:

图中,实心箭头表示调用关于,虚线空心箭头表示对对象进行操作,蓝色虚线箭头是调试器的调试循环。

如果当前进程没有处于调试状态,那么进程的EPROCESS中的DebugPort字段将为NULL,nt!DbgkForwardException()在发现其为空后将直接返回。不再继续进行异常消息的传递。将回到nt!KiDispatchException(),而这个函数发现没有调试将进一步进入进入应用层的异常处理模型。具体的调用ntdll!RtlDispatchException()开始SEH链表中寻找异常处理器。如果在int 3外使用了__try __exception进行捕获则程序正常运行,否则将进入SEH的底端,最后弹出一个框宣告程序挂了。

最后,再附一张各个阶段中调试消息的转换过程图:

参考资料:

张银奎:《软件调试》

WRK

ReactOS

时间: 2024-08-28 23:21:35

【系统篇】从int 3探索Windows应用程序调试原理的相关文章

Windows内部程序运行原理

1.相互关系图 操作系统把它能够完成的功能以函数的形式提供给应用程序使用,应用程序对这些函数的调用就是系统调用.这些函数的集合就是windows操作系统提供给应用程序的编程接口Application Programming Interface(即windwos API) 2.消息机制 typedefstruct tagMSG( HWND hwnd,//handle to window,窗口标识的类型 UINT uMsg,//unsigned int 表示一个具体的消息(WM开头WM_KEYDOW

IBM WebSphere Application Server Network Deployment 8.5.0.X 安装配置及补丁升级指南之Windows系统篇

自从IBM WebSphere Application Server(简称WAS)8.0发布以来,这个版本以及更高版本的安装与之前的WAS 5.x.6.x.7.x版本(也称传统的WAS版本)直接安装的方式做了一些变动,都需要通过IBM Installation Manager(简称IIM)安装管理工具来进行. 为方便新用户在安装时更加容易地完成安装与配置,特书写此安装指南以备参考. 下载地址为:http://www.java2class.net/bbs/viewthread.php?tid=24

C/C++笔试忍法帖01——系统篇

1.进程和线程的差别. 线程是指进程内的一个执行单元,也是进程内的可调度实体. 与进程的区别: (1)调度:线程作为调度和分配的基本单位,进程作为拥有资源的基本单位 (2)并发性:不仅进程之间可以并发执行,同一个进程的多个线程之间也可并发执行 (3)拥有资源:进程是拥有资源的独立单位,线程不拥有系统资源,但可以访问隶属于进程的资源. (4)系统开销:在创建或撤消进程时,由于系统都要为之分配和回收资源,导致系统的开销明显大于创建或撤消线程时的开销. 2.如果只想让程序有一个实例运行,不能运行两个.

Android学习系列(39)--Android主题和样式之系统篇(上)

Android学习系列(39)--Android主题和样式之系统篇(上) [基于最新的Android4.4的源码分析] 每家公司或者每个移动团队无不想开发出一套自己的UI框架,融入自己的设计和特性,这必然会去修改android的ui.所以,学习和理解android的UI设计是最基础和非常有必要的.android ui设计最重要的就是主题和样式. 1.位置在Android的frameworks/base/core/res/res/values目录下有一下几个文件: 1 2 3 4 themes.x

【系统篇】小议三种函数调用约定

小议三种函数调用约定 __cdecl.__stdcall.__fastcall是C/C++里中经常见到的三种函数调用方式.其中__cdecl是C/C++默认的调用方式,__stdcall是windows API函数的调用方式,只不过我们在头文件里查看这些API的声明的时候是用了WINAPI的宏进行代替了,而这个宏其实就是__stdcall了. 三种调用方式的区别相信大家应该有些了解,这篇文章主要从实例和汇编的角度阐述这些区别的表现形态,使其对它们的区别认识从理论向实际过渡. __cdecl: C

【系统篇】从C/C++语言到进程启动背后的故事

我们需要运行一个程序或者软件,双击之即可完成.不过从你双击到程序的窗口产生的这“短暂”的时间内,Windows为你做了很多的工作. 首先,系统有一个进程监测到了你的双击操作,这个进程就是系统shell,没错,就是资源管理器explorer.exe,不是IE浏览器了,那是另一个进程IExplorer.exe.你可以尝试打开任务管理器将这个进程结束掉,然后桌面的一切元素都没有了,任务栏,图标什么的都消失了.只剩下墙纸一张,此时,右键菜单也不复存在···因为平时负责这些东西的explorer.exe已

Android系统篇之----Android中的智能指针

一.前言 今天我们开启Android系统篇的文章了,其实一直想弄,只是之前一直没有太多深入的了解,最近又把这块拿出来好好看了一下,所以想从新梳理一下,来看看Android中的这块知识,首先我们今天来看一下:Android中的智能指针的概念,为什么说先看一下智能指针这个知识呢?因为我们在看Android源码的时候,会发现几乎好多地方都用到了这个东东,所以我们在介绍后面的知识点,先来看看这个吧. 二.问题 那么Android中的智能指针是个什么东西呢?我们知道Android用的Java语言开发的,J

基础教程系列之装系统篇

[科普]基础教程系列之装系统篇 鉴于每天有很多新人询问安装系统问题,我决定花一些时间来系统性的整理一下安装系统的步骤,带领新人一步步来安装好系统. 目录 0.前言 1.物质准备 2.制作启动盘 3.选择自己需要的系统 4.其他准备工作 5.进入win pe 6.举例进入win pe 7-1.确保正确安装系统的准备工作 7-2.确保正确安装系统的准备工作 8-1.使用原版镜像安装系统 8-2使用ghost安装系统 8-3使用nt6 hdd installer安装系统 9.重启进入操作系统 10.安

Android系统篇之----编写系统服务并且将其编译到系统源码中

在之前已经介绍了一篇关于如何编写简单的驱动以及访问该驱动的小程序,最后将程序编译到Android内核源码中通过程序访问驱动验证是可以通过的,那么本文就继续这个知识点,把这个驱动程序通过JNI连接创建一个系统服务,提供给上层应用访问改服务功能,可以看到前一篇介绍驱动程序的功能是属于内核层的,而本文介绍的内容是Framework层的知识. 声明:本文内容参考罗升阳的书籍:<Android系统源代码情景分析> 如果想了解更详细的内容非常建议购买此书 非常感谢罗神的这本书,给我带来很多未知的知识,大神