10.6 开发反汇编调试器

动态反汇编调试器是一种软件工具,用于分析和调试二进制程序的执行过程。它能够将二进制程序转换为可读的汇编代码,并提供了一系列的调试功能,帮助开发人员理解和控制程序的执行流程,本篇文章将重点分析动态反汇编调试软件中,软件断点,硬件断点,内存断点,寄存器参数,单步步入,步过,反汇编等功能的实现原理。

10.6.1 寄存器系列函数

寄存器(Registers)是计算机体系结构中的一组高速存储器,用于存储和执行指令时的临时数据。寄存器是位于CPU内部的存储单元,可以直接被CPU访问和操作,其速度非常快,通常用于存储指令操作数、计算中间结果和控制信息。

寄存器在计算机的执行过程中发挥重要作用,常见的寄存器包括:

  • 通用寄存器(General Purpose Registers):用于存储操作数和计算结果。通常有多个通用寄存器,如x86架构中的EAX、EBX、ECX、EDX等。

  • 索引寄存器(Index Registers):用于存储数组和数据结构的索引值,支持对数组元素和结构成员的访问。如x86架构中的ESI和EDI。

  • 指针寄存器(Pointer Registers):用于存储内存地址,支持对内存的访问。如x86架构中的EBP和ESP。

  • 标志寄存器(Flag Registers):用于存储条件码和状态信息,用于控制程序的执行流程和处理条件分支。如x86架构中的EFLAGS。

  • 程序计数器(Program Counter):用于存储下一条要执行的指令的地址,指示程序执行的位置。

不同的计算机体系结构和指令集架构会有不同数量和类型的寄存器,并且寄存器的名称和功能也会有所差异。寄存器的使用和操作是由处理器的指令集架构规定的,程序员可以通过指令操作寄存器的内容,以完成各种计算和数据处理任务。

对于调试其中针对寄存器的输出,一般我们需要定义一个CONTEXT结构体变量context,并将其ContextFlags成员设置为CONTEXT_ALL,表示获取所有寄存器的值。然后,再通过调用GetThreadContext函数获取指定线程的上下文信息,将获取到的上下文信息存储在context变量中,此时就可以获取到当前线程环境下所有寄存器的参数。

VOID GetRegister(HANDLE hThread)
{
CONTEXT context = { 0 };
context.ContextFlags = CONTEXT_ALL;

// 获取线程上下文
GetThreadContext(hThread, &context);

printf("\n");
printf("EAX = 0x%08X | EBX = 0x%08X | ECX = 0x%08X | EDX = 0x%08X \n", context.Eax, context.Ebx, context.Ecx, context.Edx);
printf("ESI = 0x%08X | EDI = 0x%08X | ESP = 0x%08X | EBP = 0x%08X \n", context.Esi, context.Edi, context.Esp, context.Ebp);

printf("\n");
printf("CS = 0x%04X | SS = 0x%04X | DS = 0x%04X \n", context.SegCs, context.SegSs, context.SegDs);
printf("ES = 0x%04X | FS = 0x%04X | GS = 0x%04X \n", context.SegEs, context.SegFs, context.SegGs);

printf("\n");
printf("EIP = 0x%08X \n", context.Eip);
printf("EFLAGS = 0x%08X \n\n", context.EFlags);
}

而如果读者需要修改某个寄存器内的参数,则可以调用SetThreadContext()函数,该函数与获取寄存器参数一致,调用它并传入特定寄存器CONTEXT结构,即可实现设置的功能。

对于堆栈参数的获取则可通过ReadProcessMemory函数读去当前CONTEXT上下文中的ESP参数获取到,该寄存器中存储的是当前线程的栈顶地址,ESP的值会在函数调用时动态地变化,我们可以在ESP指向的位置向下读入特定字节,来获取到当前堆栈的变化,实现代码也很容易。

VOID GetStack(HANDLE hProcess, HANDLE hThread)
{
PWORD buf[512] = { 0 };
DWORD dwRead = 0;
int x = 0;

CONTEXT context = { 0 };
context.ContextFlags = CONTEXT_ALL;
GetThreadContext(hThread, &context);

ReadProcessMemory(hProcess, (LPVOID)context.Esp, buf, 4 * 20, &dwRead);
while (x < 20)
{
printf_s("[ 0x%08X ] \t 0x%08X \n", context.Esp + x * 4, buf[x]);
x++;
}
}

堆栈参数获取,通过GetThreadContext得到堆栈中的ESP位置,并直接调用ReadProcessMemory读出前20字节,最终输出堆栈参数,如下图所示;

10.6.2 软件断点函数

软件断点(Software Breakpoint),用于在程序执行过程中主动中断程序的执行。它是通过在代码中插入特殊的指令或标记来实现的,当程序执行到该指令或标记时,会触发一个中断信号,使程序停止执行并进入调试器。

软件断点的作用是在特定的代码位置设置断点,用于调试程序或跟踪代码执行流程。通过设置软件断点,可以在程序执行到指定的位置时中断,然后可以检查变量的值、查看内存状态、跟踪函数调用等,以便进行调试和分析。

在实现上,软件断点可以通过不同的机制来实现,常见的方式包括:

  • 修改指令:将要中断的指令替换为中断指令,例如软中断指令(int3)或特殊的NOP指令。当程序执行到替换后的指令时,会触发中断信号。

  • 插入中断指令:在代码中插入中断指令,例如软中断指令(int)或调用中断处理函数的指令。当程序执行到插入的中断指令时,会触发中断信号。

  • 使用调试寄存器:调试寄存器(Debug Register)是处理器提供的专门用于调试的寄存器。可以将调试寄存器配置为监视指定的内存地址或指令地址,在目标地址发生读取或写入操作时触发中断信号。

软件断点是调试器的核心功能之一,本章所讲解的断点实现方法是通过在对端内存中写入0xcc指令实现的,在设置断点时,首先通过OpenProcess打开一个目标进程,并通过ReadProcessMemory将特定内存位置处一个字节数据保存在内存方便后期恢复,接着通过WriteProcessMemory函数将0xCC停机写出到特定内存中,此时即可实现内存断点的设置功能。

BOOL SetSoftBreakPoint(DWORD PID, LPVOID Address, BOOL BpAttribute)
{
HANDLE ProcessHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, PID);

// 存放CC断点信息
CCBREAKPOINTINFO ccInfo = { 0 };

// 下断点地址
ccInfo.address = Address;

// 是否为永久CC断点
ccInfo.BpAttribute = BpAttribute;

// 读出一个字节保存起来
ReadProcessMemory(ProcessHandle, Address, &ccInfo.code, 1, NULL);

// 写出CC断点
WriteProcessMemory(ProcessHandle, Address, "\xCC", 1, NULL);

// 保存断点信息
BreakPointList.push_back(ccInfo);

CloseHandle(ProcessHandle);

return TRUE;
}

相对于设置软件断点,移除断点则变得较为复杂一些,在移除之前首先判断boole是否为真,如果为真则需要移除指定断点,首先通过循环遍历BreakPointList这个内存结构,该结构内保存的就是当前所设置的所有完整断点信息,通过判断BreakPointList[i].address地址是否是我们需要移除的内存,如果是则首先GetThreadContext获取到当前进程上下文,得到的EIP地址减去1并将短短设置到0xCC之上,接着使用WriteProcessMemory函数将原始指令写出替换到特定位置,如果为假的,则表明需要移除所有断点。

VOID RemoveSoftBreakPoint(PROCESSINFO ProcessInfo, BOOL boole, LPVOID addr)
{
// 如果 "boole" 参数为 TRUE,则只移除指定的地址处的断点。
if (boole == TRUE)
{
// 遍历 BreakPointList 查找要移除的断点
for (int i = 0; i < BreakPointList.size(); i++)
{
if (BreakPointList[i].address == addr)
{
CONTEXT ct = { CONTEXT_CONTROL };

// 获取当前线程上下文
GetThreadContext(ProcessInfo.hThread, &ct);

// 将上下文减去1
ct.Eip -= 1;

// 设置新的上下文
SetThreadContext(ProcessInfo.hThread, &ct);

// 在目标进程中恢复断点原始的指令
WriteProcessMemory(ProcessInfo.hProcess, BreakPointList[i].address, &(BreakPointList[i].code), 1, NULL);
}
}
}
// 如果 "boole" 参数为 FALSE,则移除所有已设置的断点。
else
{
// 遍历 BreakPointList 移除所有断点
for (int i = 0; i < BreakPointList.size(); i++)
{
// 在目标进程中恢复断点原始的指令
WriteProcessMemory(ProcessInfo.hProcess, BreakPointList[i].address, &(BreakPointList[i].code), 1, NULL);
}
}
}

如下图所示则是该函数执行后的输出效果,通过SetBreakPoint可以设置一个内存断点,通过ShowBreakPoint则是循环BreakPointList并输出,通过DelBreakPoint则是删除一个特定内存断点。

10.6.3 内存断点函数

内存断点(Memory Breakpoint),用于在程序访问指定内存地址时中断程序的执行。与软件断点不同,内存断点是通过监视特定的内存地址来触发中断信号,而不是通过修改指令或插入中断指令。此类断点用于监视变量的读取或写入操作,检测内存访问错误、内存泄漏、越界访问等问题。当程序执行到设置的内存断点所监视的内存地址时,会触发中断信号,使程序停止执行并进入调试器。

内存断点和软件断点,它们在触发中断信号的方式和使用场景上有所不同。软件断点是通过修改指令来实现的,用于调试特定的代码段和函数,而内存断点是通过监视特定的内存地址来实现的,用于监视内存访问和变化。

在本例中内存断点的设置通过VirtualProtectEx函数来实现,该函数可以修改特定内存空间的属性值,而内存空间的属性值通常包括了,PAGE_NOACCESS内存访问,PAGE_EXECUTE_READ内存读写执行,以及PAGE_READWRITE内存读写这三种形式,当在某一个区域设置断点后,系统则会监控这片区域,一旦有执行流访问则会产生一个特定的异常,至此程序将被断下,在设置内存属性时,需要将设置之前的内存属性值保存在MemoryBreakList链表中,当清除时会用到。

VOID SetMemBreakPoint(HANDLE hProcess, char* flag, DWORD Address)
{
MEMORYPOINTINFO mbp = { 0 };
// mbp.address = Address & 0xFFFFF000;
mbp.address = Address;

for (int i = 0; i < MemoryBreakList.size(); i++)
{
if (mbp.address == MemoryBreakList[i].address)
{
printf_s("[-] 目标内存页已存在内存断点 \n");
return;
}

}

if (!strcmp(flag, "r"))
{
// 内存访问断点
mbp.dwNewProtect = PAGE_NOACCESS;
}
else if (!strcmp(flag, "w"))
{
// 设置读与执行
mbp.dwNewProtect = PAGE_EXECUTE_READ;
}
else if (!strcmp(flag, "e"))
{
// 设置读写
mbp.dwNewProtect = PAGE_READWRITE;
}
else
{
printf("[-] 不存在页面属性 \n");
return;
}

// 设置内存属性
if (!VirtualProtectEx(hProcess, (LPVOID)mbp.address, 0x1000, mbp.dwNewProtect, &mbp.dwOldProtect))
{
printf_s("[-] 内存断点下达失败 \n");
return;
}
MemoryBreakList.push_back(mbp);
}

清除内存断点时,我们只需要在MemoryBreakList链表中提取出特定的内存属性,并依次循环WriteProcessMemory将内存属性还原即可,如下则是循环查找符合条件的内存地址,并删除内存断点的实现流程。

VOID ClearMemoryBreakPoint(PROCESSINFO ProcessInfo, DWORD address)
{
for (int i = 0; i < MemoryBreakList.size(); i++)
{
if (MemoryBreakList[i].address == address)
{
// 修复后删除
WriteProcessMemory(ProcessInfo.hProcess, (DWORD *)MemoryBreakList[i].address, &(MemoryBreakList[i].dwOldProtect), 1, NULL);
MemoryBreakList.erase(MemoryBreakList.begin() + i);
}
}
}

读者可自行使用课件内的调试软件,通过SetMemBreakPoint设置内存断点,通过ShowMemBreakPoint显示当前断点列表,通过DelMemBreakPoint移除内存断点,如下图所示;

10.6.4 硬件断点函数

与上述软件断点实现原理不同,硬件断点的设置需要依赖于处理器的调试寄存器(debug registers)来实现,在x86架构中,处理器提供了多个调试寄存器(DR0、DR1、DR2、DR3),它们可以用来设置断点和监视点。每个调试寄存器可以存储一个内存地址,并在程序执行到该地址时触发断点。

与软件断点相比,硬件断点具有以下优点:

  • 硬件断点不需要修改程序的代码,因此对目标程序的影响较小,适用于调试无法修改源代码的程序。
  • 硬件断点可以设置在任意的内存地址上,包括只读或只执行的内存区域。
  • 硬件断点可以设置多个,可以同时监视多个内存地址。

然而,硬件断点也有一些限制和注意事项:

  • 处理器提供的调试寄存器数量有限,通常为4个,因此硬件断点的数量也有限。
  • 硬件断点可能受到处理器架构和调试器的限制,例如一些处理器可能限制硬件断点只能设置在特定的地址对齐位置上。
  • 在多线程程序中,硬件断点可能会影响整个进程的执行,而不仅仅是触发断点的线程。

对于实现硬件断点我们只能够通过设置寄存器组来实现,通常我们需要将需要设置的断点地址填充至context.Dr寄存器内,并同时将context.Dr7寄存器设置为对应的0x00000004,0x00000010,0x00000040至此当有断点产生,系统会为我们捕捉并产生断点异常事件,而对于清除断点则是相反的流程,通过对context.Dr7 & 0xFFFFFFFE取消设置属性,并通过context.Dr将寄存器设置为0实现清空效果。

void SetHardwareBreakpoint(HANDLE hProcess, DWORD_PTR address)
{
CONTEXT context;
memset(&context, 0, sizeof(CONTEXT));
context.ContextFlags = CONTEXT_DEBUG_REGISTERS;

// 获取进程的上下文
if (!GetThreadContext(hProcess, &context))
{
return;
}

// 设置断点
if (context.Dr0 == 0)
{
context.Dr0 = address;
context.Dr7 |= 0x00000001; // 设置Dr0为触发断点
}
else if (context.Dr1 == 0)
{
context.Dr1 = address;
context.Dr7 |= 0x00000004; // 设置Dr1为触发断点
}
else if (context.Dr2 == 0)
{
context.Dr2 = address;
context.Dr7 |= 0x00000010; // 设置Dr2为触发断点
}
else if (context.Dr3 == 0)
{
context.Dr3 = address;
context.Dr7 |= 0x00000040; // 设置Dr3为触发断点
}
else
{
return;
}

// 应用修改后的上下文
if (!SetThreadContext(hProcess, &context))
{
return;
}
}

硬件断点的设置需要指定断点的长度以及断点类型,通常该类型取值空间为读(r)、写(w)和执行(x),读者可通过SetHbreakPoint设置一个硬件断点,通过ShowHbreakPoint输出当前断点寄存器,并通过DelHbreakPoint清空一个硬件断点,如下图所示。

10.6.5 反汇编功能函数

在之前的文章中我们已经介绍了反汇编引擎的使用方法,本节内容将重点实现反汇编功能,在对一段内存进行反汇编时我们需要完成两个步骤,首先需要通过RemoveSoftBreakPoint清除改地址处的0xCC指令,接着在调用ReadProcessMemory函数在传入内存地址处向下读取指定长度的内存,接着通过调用cs_disasm函数对读取的内存空间进行反汇编,结果存储在ins中,并返回反汇编指令的数量nCount,此时我们只需遍历反汇编指令数组ins,并逐个输出每条指令的地址、机器码和反汇编结果,最后需要调用RecoverSoftBreakPoint函数再次对内存增加断点。

VOID Disassembler(PROCESSINFO ProcessInfo, DWORD addr, DWORD num)
{
cs_insn* ins = nullptr;

DWORD dwWrite = 0;
PCHAR buff = new CHAR[num * 16]();

// 清除CC
RemoveSoftBreakPoint(ProcessInfo, FALSE, NULL);

// 读取指定长度的内存空间
ReadProcessMemory(ProcessInfo.hProcess, (LPVOID)addr, buff, num * 16, &dwWrite);

// 接收反汇编指令
size_t nCount = cs_disasm(ProcessInfo.capHandle, (uint8_t*)buff, num * 16, (uint64_t)addr, 0, &ins);
for (DWORD i = 0; i < nCount && i < num; ++i)
{
printf("%08X | ", (UINT)ins[i].address);

int tmp = 0;

// 循环打印机器码
while (ins[i].size)
{
printf_s("%02X ", ins[i].bytes[tmp]);
tmp++;
ins[i].size -= 1;
}

// 补充空余位
for (int x = tmp; x <= 9; x++)
{
printf(" ");
}

// 输出对应的反汇编
printf(" | %s %s \n", ins[i].mnemonic, ins[i].op_str);
}

// 释放动态分配的空间
delete[] buff;
cs_free(ins, nCount);

// 恢复CC
RecoverSoftBreakPoint(ProcessInfo, FALSE, NULL);
}

读者可以通过Dissasembler对当前EIP位置处向下反汇编,该命令接受一个反汇编长度,可传入不同的反汇编长度,输出效果如下所示;

10.6.6 单步步过/步进函数

单步步过是指单步执行一条汇编指令,执行完当前指令后立即停止并返回调试器,然后让程序处于暂停状态等待用户的下一步操作。如果当前指令是函数调用,那么单步步过将会进入函数执行,在函数内部执行完返回到调用处的下一条指令时停止并返回调试器。

步进是指程序在当前位置执行完所有指令之后停止,并等待用户输入下一步操作,下一步可以是单步步过,也可以是继续运行直到下一个断点或程序结束。

两者的区别在于,单步步过是一种精细的调试方式,可以逐条执行代码,以查看每一行代码执行的结果,以及监视寄存器和内存中的值的变化,而步进则更适用于在特定位置进行调试,以及查看程序运行流程和检测异常行为。

在调试器中,单步模式的实现依赖于EFLAGS寄存器中的第8位(即TF标志位),当TF标志位被设置为1时,CPU在执行每条指令后会触发一个中断,称为单步中断或单步异常。这个中断会暂停程序的执行,将控制权交给调试器。此时调试器可以利用这个机会进行下一步调试操作,例如查看寄存器的值、内存的内容,或者修改寄存器和内存的值,对于设置单步模式则可通过SetTFFlag函数直接设置。

VOID SetTFFlag(HANDLE hThread)
{
CONTEXT ct = { 0 };
ct.ContextFlags = CONTEXT_CONTROL;
GetThreadContext(hThread, &ct);
ct.EFlags |= 0x100;
SetThreadContext(hThread, &ct);
}

当有了单步步进后,我们就可以在该模式的基础之上实现单步步过功能,步过功能的实现,首先通过GetThreadContext函数获取当前线程的上下文信息,然后获取当前线程执行指令的地址。读取该地址处的指令并通过Capstone库中的cs_disasm函数对其进行反汇编,以便判断该指令是不是call或者rep指令。

如果该指令是call或者rep指令,则在call指令的下一条指令处需要设置软件断点,以防止程序执行运行起来,如果该指令不是call或者rep指令,则直接设置TF标志位,然后程序进入单步执行状态。

VOID SetStepFlag(PROCESSINFO ProcessInfo)
{
CONTEXT ct = { 0 };
ct.ContextFlags = CONTEXT_CONTROL;
GetThreadContext(ProcessInfo.hThread, &ct);
DWORD Address = ct.Eip;
cs_insn* ins = nullptr;
PCHAR buf[16] = { 0 };

ReadProcessMemory(ProcessInfo.hProcess, (LPVOID)Address, buf, 16, NULL);

cs_disasm(ProcessInfo.capHandle, (uint8_t*)buf, (size_t)16, (uint64_t)Address, 0, &ins);

// 如果是call或者是rep 则需要在 call下面增加一个断点 防止跑飞
if (!memcmp(ins->mnemonic, "call", 4) || !memcmp(ins->mnemonic, "rep", 3))
{
// 设置断点
SetSoftBreakPoint(ProcessInfo.PID, (LPVOID)(Address + ins->size), TRUE);
}
else
{
// 步进
SetTFFlag(ProcessInfo.hThread);
}
}

当执行StepOut步过时,遇到call不进入直接跳过,而是直接执行完整个call调用并将指针指向call指令的下一条指令上,

而如果读者使用的是StepIn命令,则遇到call指令则会自动的切换到call调用内部,并继续执行直到遇到返回才会退出,读者可通过两图对比两者之间的区别。