专栏首页编程技术宇宙Windows应用程序调试原理全景图

Windows应用程序调试原理全景图

导言

本文总计八千余字,十余张图,浏览时间较长,建议先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,这样,当程序执行到这里时,将触发到调试器,调试器然后把这个地址处的值改回保存的值,这样程序就可以往下执行,从而达到了下断的目的而又不改变程序原来的指令。我们通过实验来证实这个原理。

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

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;
}

代码很简单,两段内嵌汇编,其中第二段就是一段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是如何让程序中断到调试器的呢?

WindowsXP之后应用程序调试模型

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

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

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

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

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

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

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

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

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

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

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

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处理分支。

调试进程投递调试消息过程

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

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

typedef enum _DBGKM_APINUMBER
{
    DbgKmExceptionApi,
    DbgKmCreateThreadApi,
    DbgKmCreateProcessApi,
    DbgKmExitThreadApi,
    DbgKmExitProcessApi,
    DbgKmLoadDllApi,
    DbgKmUnloadDllApi,
    DbgKmMaxApiNumber
} DBGKM_APINUMBER;

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

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

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

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

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

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

typedef struct _DEBUG_OBJECT {
    KEVENT EventsPresent;
    FAST_MUTEX Mutex;
    LIST_ENTRY EventList;
    ULONG Flags;
} DEBUG_OBJECT, *PDEBUG_OBJECT;

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

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

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;

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

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

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

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

调试器调用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。

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

由于微信公众平台图片清晰度限制,可以点击阅读原文链接获取高清大图,提取码:6idt

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

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

参考资料:

张银奎:《软件调试》

本文分享自微信公众号 - 编程技术宇宙(ProgramUniverse),作者:轩辕之风

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-11-13

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 看过无数Java GC文章,这5个问题你也未必知道!

    垃圾回收针对不同的分区又分为MinorGC和FullGC,不同分区的触发条件又有不同。总体来说GC的触发分为主动和被动两类:

    轩辕之风
  • 高并发基石|深入理解IO复用技术之epoll

    又到周六了,不过这周有点忙新文章还没有写,为了不跳票,就想着把早期还不错的文章,重新排版修改发一下,因为当时读者很少,现在而言完全可以当作一篇新文章(有种狡辩的...

    轩辕之风
  • 一个DNS数据包的惊险之旅

    “小子,快去查一下www.paypal.com的IP地址,我急用,晚了我弄你!”,暴躁老哥一把关上了门,留我一个DNS数据包在冷冰冰的房间。

    轩辕之风
  • 【专业技术】从4行代码看右值引用

    概述   右值引用的概念有些读者可能会感到陌生,其实他和C++98/03中的左值引用有些类似,例如,c++98/03中的左值引用是这样的: int i = 0;...

    程序员互动联盟
  • 想学FM系列(21)-SAP FM模块:派生规则推导策略(4)-派生规则推导步骤-功能模块

    4.1.6 功能模块 在推导步骤过程中调用一个功能函数,将源数据字段值传入功能函数的参数值,再把功能函数执行后的结果数据值传出到目标字段。 在选择步骤类型为功能...

    SAP最佳业务实践
  • 数据告诉你,为什么抖音网红城市都在西部?

    抖音是主要平台之一。根据抖音在《2018短视频与城市形象研究白皮书》中公布的数据,重庆、西安和成都是城市形象短视频播放量最高的三个城市,排名首位的重庆播放量达1...

    CDA数据分析师
  • 漫谈原型模式

    如果对象的创建成本比较大,而同一个类的不同对象之间差别不大(大部分字段都相同),在这种情况下,我们可以利用对已有对象(原型)进行复制(或者叫拷贝)的方式来创建新...

    汐楓
  • Python自建collections模块

    本篇将学习python的另一个内建模块collections,更多内容请参考:Python学习指南 collections是Python内建的一个集合模块,提...

    用户1174963
  • “城市大脑”能为百万车辆川流的城市带来什么?

    我们去年讲到城市大脑,其实自从人类有了互联网技术,让我们可以去重新思考一下我们城市的问题,其实我也是这一年才明白城市到底是什么。

    DT数据侠
  • 为什么抖音网红城市都在西部?

    抖音是主要平台之一。根据抖音在《2018短视频与城市形象研究白皮书》中公布的数据,重庆、西安和成都是城市形象短视频播放量最高的三个城市,排名首位的重庆播放量达1...

    华章科技

扫码关注云+社区

领取腾讯云代金券