关于debug:调试器的原理总结

12次阅读

共计 6351 个字符,预计需要花费 16 分钟才能阅读完成。

调试器的原理介绍

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

一、根底概念

波及到调试,就不能只理解计算机顶层的常识,还须要晓得与调试器的实现非亲非故的汇编指令和寄存器的概念。

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) 即可。

正文完
 0