9.2.1 调试

9.2.1 调试

从软件代码静态分析中我们可以看出,静态分析的特点就在于通过各种方式获知关于程序结构方面的信息,以及与程序相关的代码逻辑。但是,我们很容易就能发现,无论得到了多少信息,都无法确定程序在实际运行中会展现出怎样的效果。例如,我们很难通过静态分析直观地感受一个程序运行时弹出的窗口的位置。更重要的是,我们很难通过静态分析去调试和修改程序的运行流程并感受其中的变化。要想完成这些工作,就需要对软件进行软件调试(也可以说是动态分析),在软件运行过程中实时观察程序的运行效果,甚至控制并修改程序。

针对本地x86程序,从类型上一般将软件调试分为本地调试和远程调试,从调试实现原理上一般分为Windows调试原理(下面简称为调试原理)技术、内核调试技术和虚拟机调试技术。本书并不关心内核调试,其实内核调试也是使用一般调试原理实现的。下面简单介绍调试原理和一种不常见的调试技术——伪调试技术。

1.调试原理

一般调试原理在x86指令集系统中是指CPU等相关硬件在设计时内置的一系列用于调试目的的功能的工作原理。但是,在这里我们要讨论的一般调试原理特指这些技术在Windows平台的体现。我们要讨论的内容包括系统的调试函数,系统对异常的处理机制以及硬件相关调试的设计。

在Windows系统的设计中提供了一些与软件调试息息相关的函数,这些函数被称为调试API(当然,这些函数还可以完成很多其他工作,但主要用于调试),举例如下。

以上列出的是一部分专为调试而设计的函数,以及几个调试程序必须使用的函数。其实,很多能够跨进程的函数在调试当中也经常用到,如VirtualProtectEx()函数,但这些函数不是专为调试而设计的。下面简单解释以上部分函数。

(1)IsDebugger Present()函数用于检测进程是否处于调试过程中。

(2)Check RemoteDebuggerPresent()函数与IsDebuggerPresent()函数功能相同,只不过它可以检测其他进程。

(3)DebugBreak()函数用于触发调试中断,其功能只相当于INT3软中断。在无法直接嵌入汇编的高级语言中,可以通过调用该函数来实现INT3软中断。

(4)WaitFor DebugEvent()函数用于在调试器中等待被调试进程的调试事件发生,是调试器中最为重要的函数。调试器通过该函数来接收被调试进程的各种调试事件,Windows中定义了以下9种调试事件,其中每一种事件在DEBUG_EVENT中都有相应的结构定义,这里不再一一列述。

(5)ContinueDebugEvent()函数用于继续执行被调试的程序。当一个调试事件发生以后,系统会暂停被调试程序,然后通过WaitFor Debug Event()函数将事件传递给调试器,因此,调试器必须使用ContinueDebugEvent()函数恢复被调试程序的运行。

(6)Debug ActiveProcess()函数用于调试一个正在运行的进程。如果一个程序并不是由调试器以调试方式启动的,要想在该程序的运行过程中调试该程序,就可以使用这个函数将程序转换到调试状态。

(7)Debug ActiveProcessStop()函数与Debug ActiveProcess()函数相反,其用途是将一个处于调试状态的进程与调试器分离,分离过后该程序恢复正常状态,同时调试器也不再收到该程序的调试事件。

(8)DebugBreak Process()函数用于中断一个处于调试状态但正在运行的程序。

(9)ReadProcess Memory()函数是另外一个在调试器设计中必须用到的函数,其功能是读取其他进程空间中的数据。

(10)WriteProcess Memory()函数用于修改目标进程中的内存数据。

(11)Get ThreadContext()函数用于获取一个线程的上下文环境,包括各种寄存器的值和标记。

(12)Set ThreadContext()函数用于设置一个线程的上下文环境。通过该函数可以修改被调试程序的线程上下文数据。

通过一系列调试API就可以设计出Olly Dbg这样的调试器所拥有的大部分调试功能。

在软件破解技术中,有时我们不只利用这些调试函数来实现调试器的功能,还可以利用这些函数来完成许多自动化处理工作。

在这种调试原理下,调试事件的处理是其核心,而调试事件其实是另外一个事件的包装,这就是异常。在一般的调试事件当中,最为重要的事情就是处理程序的异常事件,系统通过将发生在程序中的软件或硬件异常包装成一个调试事件从而将控制权传递给调试器处理。因此,我们有必要进一步了解系统处理异常的流程。

Windows的异常处理流程如图9.23所示。当一个软件程序触发异常后,会经过许多个步骤和流程,其中的IAT和系统异常分类步骤都是在系统内核中处理的,一般我们在保护层无法干涉。我们要了解两方面的信息:一是通过异常处理流程可以知道,任何一个异常,尤其是软件异常,都需要通过内核过滤,然后在保护层与内核层来回交换,因此,异常处理的速度是相当慢的;二是调试器处理异常的优先级在保护层中是最高的,系统内核无法处理的异常都会优先传递给调试器来处理。

另外,如果一个进程未处于调试状态,那么内核会将处理异常的机会交给进程自身。因此,在Windows的异常处理机制中,程序拥有处理自身异常的能力,并且在处理异常的过程中,程序自身可以修改一些Ring3层不允许修改的寄存器,如DR系列寄存器,这带来了相当强大的威力,故被许多保护系统所使用。

由于整个软件调试技术的核心都在软件异常的处理上,因此,一般的软件调试器在设计断点功能的时候也采用构造异常的办法来实现。在一般的调试器设计中,软中断断点通过人为构建INT3中断异常来实现,具体过程如图9.23所示。

图9.23 软中断实现流程

假设有如下段代码指令,如图9.24所示,如果我们想在这段代码的00916003处被执行时使程序暂停,可以将00916003处的指令修改为INT3指令,这样,程序在执行到00916003处的时候就会引发一个软件异常,控制权就会由系统转交给调试器,从而实现中断的目的。当我们需要继续运行程序的时候,可以将00916003处的指令恢复成原始的“push eax”,这样程序就可以正常执行了。

图9.24 示例代码指令

上面这个过程就是普通断点的实现过程。从这个过程中我们也可以看出,由于普通断点是通过修改原始的代码指令实现的,因此,无论如何这样的断点都带来了对原始指令的改变。许多保护系统就是通过检测这种变化来判断程序是否正在被调试的。

在一般的调试原理中,还有一种设置断点的方式称为硬件断点。

硬件断点是通过CPU设计时提供的调试寄存器实现的。在设计CPU的时候,除了常用的寄存器之外,还有一组DR系列寄存器,有DR0~DR7共8个寄存器,这组寄存器主要用于实现对代码指令的调试和跟踪。由于寄存器的数据有限,因此只能同时设置4个硬件断点。硬件断点的好处在于其中断由CPU硬件发出,因此无须对被调试指令进行修改,而且在内存访问断点上执行效率非常高。

在软件调试过程中,只在代码指令上中断还不足以快速搞清楚程序的执行流程和意图。因此,如果我们能够监视程序指令访问了哪些数据,或者在程序向某个内存地址写入数据的时候进行中断,将会使分析更加有力,这就是内存断点能够做到的事情。在软件调试中,内存断点也是通过修改内存页的属性来引发访问异常而实现的,其实现过程与软断点相似。

2.伪调试技术

从调试原理中我们可以了解,尽管通过一般调试原理能够实现很强的控制能力,但由于一般调试原理是系统的组成部分之一,其特点是将被调试程序的进程转换到另外一个工作状态,因此,从程序运行环境的角度来说,只要程序处于被调试状态,那么其状态是一定有变化的,系统内核一定能够判断程序是否处于被调试状态,否则系统将无法正确转发调试事件。更重要的是,这一点不仅软件分析者知道,保护系统的设计者也是知道的。所以,对许多保护系统来说,无论如何隐藏被调试进程的特征,都能被侦测到,尤其是现在,众多的游戏保护系统都使用了内核技术来侦测进程的状态。笔者在这里介绍另外一种调试技术,称为“伪调试技术”。这种技术可以使我们不必将被调试程序转换到调试模式,因此,许多针对一般调试原理进行检测的保护系统都无法侦测到这种调试器的存在。

下面简单介绍伪调试技术的技术原理。

伪调试技术就是通过一系列技术手段实现对目标程序的各种调试功能,但不改变目标程序的工作环境和状态。为了实现这个目的,显然不能使用Windows系统自带的一般调试技术,但要抛开所有设计去重新实现一条完整的调试原理也是非常困难和浩大的工程。根据介绍的Windows异常处理流程可以发现,如果在一个软件内部引发一个异常,当软件未处于调试状态时,系统同样会将处理异常的权力转交给该程序本身,且所有异常都是通过同一个入口(KiUser ExceptionDispatcher()函数)传入程序进程的,这就给了我们介入的机会——我们完全可以在该程序的进程内注入一些代码,通过接管KiUser ExceptionDispatcher()函数入口从而在该程序处理任何异常前得到对异常的优先处理权。然后,我们可以在这个节点将异常处理的各种信息都传递给一个外部调试器,这样就等于模拟了系统转交调试事件的过程。当调试器处理完异常,需要恢复程序的执行时,我们同样可以将未处理的异常继续传递到程序自身代码中,或者直接返回内核,过滤已经被调试器处理的异常。因此,我们可以构建一种异常机制处理,流程如图9.25所示。

图9.25 异常处理机制流程图

通过构建这样的异常处理流程,我们就赋予调试器一个有限处理所有传递到保护层的异常的机会,从而在此基础上实现一般调试技术能够实现的调试功能。

这种技术的优势在于,在整个过程中都不会影响被调试程序的工作状态,尤其是在系统内核部分,由于进程在正常运行中“被调试”,因此,基于任何技术的反调试都无法准确判定进程是否处于调试状态。而且,由于我们在被调试程序进程内转发了异常,因此,在实现断点方面,我们拥有比一般调试技术更多的选择。例如,我们完全可以不通过构造异常来达到设置断点的目的,而是通过代码HOOK达到同样的功能(因为对代码HOOK的检测将更加困难)。

以上介绍了伪调试技术的核心思路,并从理论上说明了这种技术的可行性,但是要真正实现这样一种功能,还有非常多的工作要做。例如,我们需要重写一套调试API来替代系统提供的调试API,需要对断点和异常进行有效管理,需要处理线程之间的调度问题等。

3.本地调试

本地调试也称本机调试,是指所调试的程序就运行在本地系统中,并与调试器处于同一个会话中。如图9.26所示,被调试程序与调试器处于同一桌面。调试器和被调试程序往往可以通过系统技术进行交互,并可以相互影响,因此,这也成为反调试中最重要的技术依据。

图9.26 本机调试示意图

一般调试技术的原理如图9.27所示。可以看出,在这种调试原理中,调试器与被调试程序之间主要通过提供的系统函数来交换调试数据,因此调试事件的反应速度相当快。因为许多调试器都是针对这一类型的“调试模式”开发的,所以本地调试环境经常在调试器发行时就已经完成了自动化配置。例如,Olly Dbg调试器根本不需要过多的设定就可以直接载入程序进行调试。

图9.27 调试技术原理

尽管本地调试展现出相当快的调试事件反应速度,但是其原理决定了调试环境和被调试程序的运行环境必须处于同一个环境,这就带来了局限性,因此还需要其他调试方式的配合。

4.远程调试

远程调试是指被调试的程序和调试器处于相互分离的执行环境,中间通过远程连接来交换调试信息,其一般调试原理如图9.8所示。

图9.28 远程调试原理

远程调试最大的特点在于调试器与被调试程序运行于两个执行环境(这两个执行环境可以人为地用同一个执行环境代替),这使被调试的程序执行环境不需要完全具备调试环境所需的条件,如程序的调试符号信息、开发环境等。被调试程序执行环境的结构与一般调试的结构没有太大的区别。在被调试程序的执行环境当中,被调试的进程同样是通过系统提供的调试层(如调试API)来操作的,而这些操作一般由一个调试服务进程发出,这个调试服务进程通过与远程环境中的调试器沟通,从而向被调试程序发出调试指令。这个调试服务进程实际上类似于一个代理程序。这种结构上的特点使远程调试不像本地调试那样可以直接将调试启动功能内置到调试器中去,因此需要手动配置。这里简单给出利用Win DBG调试器进行远程调试的示例。

在WinDBG调试器中,有多个程序提供调试服务功能,可以查阅WinDBG调试器的相关帮助文档。这里使用cdb的-server功能来实现调试服务的功能。

在一个执行环境中使用指令启动需要调试的进程,如图9.29所示。

图9.29 WinDGB调试(a)

通过上面的指令,我们使用cdb调试程序以调试服务的方式启动了Windows的“记事本”程序(在64位Windows系统中,c:\windows\notepad.exe为64位程序,需要使用相应版本的cdb.exe)。运行此命令,如图9.30所示。

图9.30 WinDGB调试(b)

可以看到,cdb.exe已经成功启动了进程notepad.exe,但是notepad.exe没有显示任何界面,因此可以判断是在等待调试器操作。此时,我们在另外一个执行环境中使用WinDBG的Connect To Remote Debugger Session工具连接到启动的服务进程,如图9.31所示。

图9.31 WinDGB调试(c)

当看到如图9.31所示的界面时,表示成功连接到远端执行环境并能进行调试了。