调试器的原理总结

调试器的原理介绍

调试器不管对于软件开发还是逆向分析,漏洞挖掘和利用来说都是一个至关重要的工具。今天学习了下调试器的实现,这里总结下调试器的实现原理。

一、基础概念

涉及到调试,就不能只了解计算机顶层的知识,还需要知道与调试器的实现息息相关的汇编指令和寄存器的概念。

1. 汇编指令

首先是汇编指令,汇编指令精简的概况就是:机器指令的助记符。维基百科中的具体定义如下:

   汇编语言(英语:assembly language)是任何一种用于电子计算机、微处理器、微控制器,或其他可编程器件的低级语言。在不同的设备中,汇编语言对应着不同的机器语言指令集。一种汇编语言专用于某种计算机系统结构,而不像许多高级语言,可以在不同系统平台之间移植。
   使用汇编语言编写的源代码,然后通过相应的汇编程序将它们转换成可执行的机器代码。这一过程被称为汇编过程。
   汇编语言使用助记符(Mnemonics)来代替和表示特定低级机器语言的操作。特定的汇编目标指令集可能会包括特定的操作数。许多汇编程序可以识别代表地址和常量的标签(Label)和符号(Symbols),这样就可以用字符来代表操作数而无需采取写死的方式。普遍地说,每一种特定的汇编语言和其特定的机器语言指令集是一一对应的。

比较喜欢 Gray Hat Python: Python Programming for Hackers and Reverser 中对机器指令和汇编指令的类比解释 :

汇编指令之于机器指令就如同域名系统。汇编指令就像是网站域名,机器指令就像是经过域名解析后的IP地址。

例如汇编中的常用的中断指令int 3,会首先转化为0xCC才能被CPU执行。

2. 寄存器

寄存器是CPU内部的微型缓存,其主要用于在CPU执行过程中存储所需的变量。

在Inter推出的32位元指令集架构下,寄存器的种类有很多,但与这里主要涉及到的有:通用寄存器,程序状态与控制寄存器,调试寄存器,指令寄存器,每个寄存器均为32位。

通用寄存器包括:EAX, EBX, ECX, EDX, EBP, ESP, ESI, EDI。

EAX: 又称累加器, Accumulator Register。主要用于计算操作和存储函数执行完毕后的返回值。
EBX: 无特殊说明,可用于附加存储。
ECX: 计数寄存器, Count Registet。通常用于循环操作中的计数器。但是要注意,ECX用于计数时和我们平时写程序时从小到大递增技术不同,ECX是从大到小递减计数,比如当ECX需要控制100次的循环时,使用100开始,递减到0结束。
EDX: 数据寄存器, Data Register。主要用于对EAX计算操作的扩展,存储EAX进行的复杂计算中的额外附加值。
ESI: 源寄存器, Source Index Register。在循环对数据进行处理时存储输入数据流的位置和数据操作的源索引。
EDI: 目的寄存器, Destination Register。指向数据处理的结果地址或者目的索引。
EBP: 栈基址指针寄存器, Base Register。指向线程执行过程中当前函数栈空间的基址。
ESP: 栈顶指针寄存器, Stack Register。指向线程执行过程中当前函数栈空间的栈顶位置。

程序控制与状态寄存器:EFLAGS。

EFLAGS: 又称标志寄存器, Flag Register。一共有32位,每一个位元都具有特殊的标志含义。
主要的和程序调试相关的标志为: 
--> ZF(Zero Flag): 零标志位,标志某一个操作后结果是否为0.
--> CF(Carry Flag): 进位标志位,标志某一个操作过程中是否从高位借位。
--> OF(Over Flag): 溢出标志位,标志某一个操作后结果是否超出目的寄存器的最大可存储值。

指令寄存器:EIP。

EIP:指令寄存器。存储当前正在被执行的指令的位置。

调试寄存器:DR0~DR7。

DR0~DR3: 用于存储硬件中断的中断地址。
DR4,DR5: 是保留寄存器。
DR6: 用于状态寄存器。用来指示当硬件中断命中时触发的调试事件的类型。
DR7: 控制硬件中断的开关并设置不同的中断条件,可以设置的中断条件为:
    - 当执行指定地址的指令时触发中断
    - 当指定地址被写入数据时触发中断
    - 当指定基址被读或被写时(不执行)触发中断

DR7的32位控制标志中,第0~7位,每两位分别用于标志DR0~DR3寄存器指向地址的硬件中断是否开启,两位中一位为L(Local)位,一位为G(Global)位,用户模式下设置任意一位为1即可开启对应调试寄存器指向地址的中断。

第8~15位与正常的调试过程无太大关联,这里就不在叙述。

第16~31位定义了中断的类型和中断寄存器中指向地址的数据长度,每4位对应DR0~DR3中的一个,四位中前两位标识中断的类型,后两位标识中断指向地址的数据长度。

中断类型标识对应:
00——当执行指定地址的指令时触发中断
01——当指定地址被写入数据时触发中断
11——当指定基址被读或被写时(不执行)触发中断
中断长度标识对应:
00——1 bytes
01——2 bytes(WORD)
11——4 bytes(DWORD)

二、调试器

1. 获取对进程的调试权限的方式

调试器如果要调试进程,就需要获取对应进程的访问权限。而调试器获取进程调试权限的方式主要有两种:

  1. 调试器创建可调试进程来对进程进行调试附加到进程中进行调试。
  2. 调试器附加到已启动进程上。

上述两种方式均是通过操作系统提供的进程调试接口实现。

(1) 通过创建可调试进程来获取对进程的调试权限

以Windows为例,通过创建可调试进程来获取对进程的调试权限的方式主要是通过CreateProcess()函数实现。

CreateProcessA的函数原型如下:

BOOL CreateProcessA(
  LPCSTR                lpApplicationName,
  LPSTR                 lpCommandLine,
  LPSECURITY_ATTRIBUTES lpProcessAttributes,
  LPSECURITY_ATTRIBUTES lpThreadAttributes,
  BOOL                  bInheritHandles,
  DWORD                 dwCreationFlags,
  LPVOID                lpEnvironment,
  LPCSTR                lpCurrentDirectory,
  LPSTARTUPINFOA        lpStartupInfo,
  LPPROCESS_INFORMATION lpProcessInformation
);
    > lpApplicationName: 被加载的文件名。
    >
    > lpCommandLine: 将会被执行的命令行。
    >
    > lpProcessAttributes: 指向 SECURITY_ATTRIBUTES 结构体,来决定新进程对象的句柄是否可被子进程继承。
    >
    > lpThreadAttributes: 指向 SECURITY_ATTRIBUTES 结构体,来决定新线程对象的句柄是否可被子进程继承。
    >
    > bInheritHandles: 决定调用进程中的句柄是否可被子进程继承。
    >
    > dwCreateFlags: 控制进程创建的优先级和类型。
    >
    > lpEnvironment: 指向新进程的环境块。
    >
    > lpCurrentDirectory: 当前进程所在的目录。
    >
    > lpStartupInfo: 指向 STARTUPINFO或STARTUPINFOEX结构体。
    >
    > lpProcessInformation: 指向 PROCESS_INFORMATION 结构体来接受新进程的标识信息。

上述的这些参数中,调试过程中需要注意的是lpApplicationNamedwCreateFlags

lpApplicationName用于指定我们想要创建的可调试进程的文件映像。

dwCreateFlags:用于实际标识我们创建的进程是一个被调试进程。

(2) 通过附加方式获取进程可调试权限

依旧以Windows作为例子,通过Windows的接口函数DebugActiveProcess()既可以获取到对指定进程的调试权限,按照根据WIndows的权限,特性,我们首先要获取到对指定进程的全部访问权限,通过OpenProcess实现。

DebugActiveProcess和OpenProcess函数原型如下:

HANDLE WINAPI OpenProcess(
DWORD dwDesiredAccess,//指明针对指定进程想要获取到的访问权限类型:可读、可写、可执行
BOOL bInheritHandle,  //这里设置为FALSE
DWORD dwProcessId      //想要附加调试的进程PID
);
BOOL DebugActiveProcess(
  DWORD dwProcessId        //想要附加调试的进程PID
);

为了实现调试功能,在 OpenProcess()中,我们要设置 dwDesiredAccessPROCESS_ALL_ACCESS来获取到指定进程的全部访问权限句柄。

2. 中断

对于调试器来说,最重要的功能就是可以在指定地址处设置中断,以使得我们可以更好的控制进程的行为,根据需求快速定位进程代码中的主要功能点。

中断重要有三种类型:软中断,硬中断,内存中断。

这三种中断的功能是相似的,都是在指定地址处下断点,以使得当指定地址的数据被访问时停止当前执行流,便于调试人员获取此刻的线程上下文状态。但这三种中断的具体实现原理是不同的,接下来分别对他们的实现方式进行介绍。

(1) 软中断

软中断是通过修改指定地址处指令执行代码的方式实现。

假设我们想要在 0x0041397B 处设置中断

软中断-原

则调试器会将该位置的指令代码0x8BC7的第一个字节0x8B修改为0xCC( int 3中断的机器代码),具体如下,

软中断-实现

并存储原来的指令代码0x8B。一旦指令执行到0x0041397B位置时,即会执行机器指令0xCC引发一个int 3中断。而调试器则根据中断的类型来做出相应的处理。

另外,要注意,这种中断实现方式修改了内存空间的代码,对于某些恶意病毒来说,可能会通过对内存空间的代码进行完整性校验来检查设置软中断的行为,以阻止病毒分析人员对病毒的分析,加大分析难度。

(2) 硬中断

硬中断的实现方式则与调试寄存器DR0~7有关,也即是说硬中断的实现不影响内存空间的代码完整性。

但是如我们上面对调试寄存器的介绍中所说,只有DR0~3调试器可以用来存储硬中断的地址,也即是说,我们只能设置4个硬中断的中断点。

此外,因为寄存器的值和执行流程相关,而线程才是代码执行的主体,所以硬中断的实现虽然不修改进程空间代码,但是需要修改线程执行的上下文信息。但是注意,因为一个进程可以有多个线程,所以当设置一个硬中断时,要设置进程中所有线程执行的上下文信息。

对于Windows系统来说,通过GetThreadContext()获取到的线程执行上下文的结构体的大致内容如下:

typedef struct CONTEXT {
    DWORD ContextFlags;
    DWORD Dr0;
    DWORD Dr1;
    DWORD Dr2;
    DWORD Dr3;
    DWORD Dr6;
    DWORD Dr7;
    FLOATING_SAVE_AREA FloatSave;
    DWORD SegGs;
    DWORD SegFs;
    DWORD SegEs;
    DWORD SegDs;
    DWORD Edi;
    DWORD Esi;
    DWORD Ebx;
    DWORD Edx;
    DWORD Ecx;
    DWORD Eax;
    DWORD Ebp;
    DWORD Eip;
    DWORD SegCs;
    DWORD EFlags;
    DWORD Esp;
    DWORD SegSs;
    BYTE ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
};

所以,对线程执行上下文的设置,就是对该结构体的设置。具体的实现伪代码如下:

//遍历指定进程中所有的线程
threadIdList = EnmulateThreadByProcessId(dwProcessId);

//获取DR0~DR3中空余的硬件断点寄存器索引
drIndex = GetFreeDebugRegister();

// 设置所有线程的执行上下文中调试寄存器的值,实现硬中断
for threadId in threadIdList:
    // 获取线程上下文
    threadContext = GetThreadContext(threadId);
    
    //设置空余的调试寄存器的值为断点位置
    threadContext.DR[drIndex]=BreakpointAddress;
    //开启对应的调试寄存器
    threadContext.DR7.DR[drIndex].LocalFlag=True
    threadContext.DR7.DR[drIndex].GlobalFlag=True
    //设置对应的中断的条件和长度
    threadContext.DR7.DR[drIndex].type = breakpointType // 00, 01, 11
    threadContext.DR7.DR[drIndex].length = breakpointLength // 00,01,11
    
    //将修改写回线程中
    SetThreadContext(threadContext);
(3) 内存中断

内存中断的实现方式和系统对内存页的保护机制有关。其实现的原理是通过设置内存页的访问权限来使得对内存的某些访问会引发冲突,调试器捕获这些冲突并执行相应的操作。

以Windows系统为例,操作系统对一个页(操作系统分配内存的最小单位)的权限设置包括:

可执行页 Page execution:当进程尝试对该页进程读写操作时会引发一个访问异常。
可读页 Page read:当进程尝试对该页进程执行和写操作时会引发一个访问异常。
可写页 Page write:当进程尝试对该页进程执行和读操作时会引发一个访问异常。
守护页 Guard page: 进程对该页的任何访问都会引发一个异常,之后,该页变为普通页。

也即是说,通过对指定内存设置不同的访问权限,即可对内存设置不同类型的断点(内存访问断点、内存读断点、内存写断点等)。

三. 总结

调试器的基础原理涉及的实际内容并不难。调试器的实现其实都是通过对进程或线程中某些数据的修改实现对进程执行过程中的控制,或者说劫持。

但是关于调试器的内容里,需要注意和思考以下几点:

1.为什么软中断只需要修改一次,硬中断需要对所有的线程执行上下文进行修改?
    |这里需要理解的是进程和线程的区别。进程是操作系统分配资源的最小单位,线程是实际的进程中代码的执行流,对于多线程进程,为了保证       |不同线程切换后能够正常回复,需要在当前执行线程被切换前保存当前的线程上下文(即寄存器中的内容, 存储为上面所说的 CONTEXT 结构     |体),而在该线程恢复执行时,操作系统通过读取存储的线程上下文来恢复到线程被切换时的状态。显然,通过对线程上下文的修改,即可实        |现对线程控制流的控制。
    |而之所以软中断只需要修改一次,硬中断需要对所有线程执行上下文进行修改,是因为所有线程共享进程的代码空间,所以软中断对进程代码     |的修改可以影响到所有的线程。而从我们前面所说,不同的线程具有自己的执行上下文(即执行状态, CONTEXT 结构体),所以为了保证中断    |对所有线程都有效果,才需要修改所有线程上下文中的调试寄存器的值。
2.中断能够实现的更底层原因是什么?
    |指令集的中断指令
    |操作系统的调试接口

此外,并没有在文中说明调试器如何获取到中断事件,以及如何对中断事件进程处理,emmmm.....这个其实和Windows的窗口消息机制相似,操作系统提供了函数对不同种类的中断事件进行捕获并,调试器通过系统API监听其感兴趣的中断事件,并在这些事件发生时,进行相应的处理,再将被调试进程的执行流恢复(也是通过系统API)即可。

阅读 230

推荐阅读