65.9K
CodeProject 正在变化。 阅读更多。
Home

SRDF - 编写自己的调试器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (24投票s)

2013年10月27日

CPOL

22分钟阅读

viewsIcon

48607

downloadIcon

891

在本文中,我将教你如何使用安全研究与开发框架(SRDF)编写一个功能齐全的调试器……如何反汇编指令,收集进程信息并处理PE文件……以及如何设置断点并使用你的调试器。

 目录



1. 引言

你想编写自己的调试器吗?……你是否有新技术,但发现OllyDbg或IDA Pro等现有产品不具备这项技术?……你是否在OllyDbg和IDA Pro中编写插件,但需要将其转换为独立的应用程序?……本文就是为你准备的。
在本文中,我将教你如何使用安全研究与开发框架 (SRDF) 编写一个功能齐全的调试器……如何反汇编指令,收集进程信息并处理 PE 文件……以及如何设置断点并使用你的调试器。

2. 为什么要调试?

调试通常用于检测应用程序错误并跟踪其执行……此外,当您没有应用程序的源代码时,它还用于逆向工程和分析应用程序。
逆向工程主要用于检测漏洞、分析恶意软件或破解应用程序。
本文不会讨论如何将调试器用于这些目的……但我们将描述如何使用 SRDF 编写调试器……以及如何在此基础上实现您的想法。

3. 安全研究与开发框架

这是一个免费的开源开发框架,旨在支持编写安全工具和恶意软件分析工具,并将安全研究和想法从理论方法转化为实际实现。
这个开发框架主要用于支持恶意软件领域,以便轻松创建恶意软件分析工具和反病毒工具,而无需重复造轮子,并激励创新思维在该领域撰写研究报告并使用 SRDF 实现它们。
在用户模式部分,SRDF 为您提供了许多有用的工具……它们是
  • 汇编器和反汇编器
  • x86 模拟器
  • 调试器
  • PE 解析器、ELF 解析器、PDF 和 Android 解析器
  • 进程分析器(已加载的 DLL、内存映射等)
  • MD5、SSDeep 和 Wildlist 扫描器 (YARA)
  • API Hooking、IAT Hooking 和进程注入
  • 后端数据库、XML 序列化器
  • Pcap 文件分析器
  • 数据包分析
  • 协议分析,例如:TCP、UDP、ICMP、HTTP、DNS 等
  • 网络流分析和网络隔离
  • 还有很多
在内核模式部分,它试图简化编写自己的过滤设备驱动程序(不是 WDF 和回调)的过程,并提供一个易于使用、面向对象(尽我们所能)的开发框架,具有以下功能:
  • 面向对象且易于使用的开发框架
  • 简易 IRP 调度机制
  • SSDT Hooking
  • 分层设备过滤
  • TDI 防火墙
  • 文件和注册表管理器
  • 内核模式易于使用的互联网套接字
  • 文件系统过滤器
内核模式仍在开发中,未来将添加更多功能。
请访问其网站:www.security-framework.com
在 Twitter 上关注我们:@winSRDF

4. 收集进程信息

如果您决定调试一个正在运行的应用程序或启动一个应用程序进行调试。您需要收集有关您要调试的此进程的信息,例如
  • 进程内部已分配的内存区域
  • 应用程序在其内存中的位置以及应用程序在内存中的大小
  • 应用程序内存中已加载的 DLL
  • 读取内存中的特定位置
此外,如果您需要附加到已运行的进程……您还需要知道该应用程序的进程文件名和命令行

4.1. 开始进程分析

要收集有关内存中进程的信息,您应该使用需要分析的进程的 ProcessId 创建一个 cProcess 类对象。
cProcess  myProc(792);
如果您只有进程名而没有进程ID,您可以像这样从SRDF中的ProcessScanner获取进程ID
cProcessScanner ProcScan;
然后从 cProcessSanner 类中的 ProcessList 字段获取进程名称和 ID 的哈希……此项是 cHash 类的一个对象。
cHash 类是一个用于表示键值哈希的类,它们之间的关系是一对多,因此每个键可以有多个值。
在我们的例子中,键是进程名称,值是进程 ID。您可能会在系统上看到多个名称相同的进程。例如,要获取进程“Explorer.exe”的第一个 ProcessId……您将执行以下操作
ProcScan.ProcessList["explorer.exe"] 
这将返回一个包含进程 ProcessId 的 cString 值。要将其转换为整数,您将使用 atoi() 函数……如下所示
atoi(ProcScan.ProcessList["explorer.exe"])

4.2. 获取已分配内存

要获取已分配的内存区域,有一个名为 MemoryMap 的内存区域列表,其类型为 cList。
cList 是一个类,用于表示固定大小的缓冲区列表或特定结构的数组。它有一个名为 GetNumberOfItems 的函数,此函数获取列表中项目的数量。在以下代码中,我们将看到如何使用 cList 函数获取内存区域列表。
for(int i=0; i<(int)(myProc->MemoryMap.GetNumberOfItems()) ;i++)
{
cout<<"Memory Address "<< ((MEMORY_MAP*)myProc->MemoryMap.GetItem(i))->Address;
cout << " Size:  "<<hex<<((memory_map*)myproc->MemoryMap.GetItem(i))->Size << endl;
}
结构 MEMORY_MAP 描述了进程中的一个内存区域……它是
struct MEMORY_MAP
{
    DWORD Address;
    DWORD Size;
    DWORD Protection;
};
在之前的代码中,我们遍历 MemoryMap 列表中的项目,并获取每个内存区域的地址和大小。

4.3. 获取应用程序信息

要获取应用程序在内存中的位置……您只需像这样获取 cProcess 类中的 Imagebase 和 SizeOfImage 字段
cout<<"Process: "<< myProc->processName<< endl;
cout<<"Process Parent ID: "<< myProc->ParentID << endl;
cout<< "Process Command Line: "<< myProc->CommandLine << endl;
    
cout<<"Process PEB:\t"<< myProc->ppeb<< endl;
cout<<"Process ImageBase:\t"<<hex<<>ImageBase<< endl;
cout<<"Process SizeOfImageBase:\t"<SizeOfImage<< " bytes"<< endl;
如您所见,我们获得了有关进程及其在内存中位置 (Imagebase) 和在内存中大小 (SizeOfImage) 的最重要信息。

4.4. 已加载的DLL和模块

加载的模块是 cProcess 类中名为“modulesList”的 cList,它表示结构“MODULE_INFO”的数组,如下所示
struct MODULE_INFO
{
    DWORD moduleImageBase;
    DWORD moduleSizeOfImage;
    cString* moduleName;
    cString* modulePath;
};
要获取进程中加载的 DLL,此代码表示如何获取已加载的 DLL
for (int i=0 ; i<(int)( myProc->modulesList.GetNumberOfItems()) ;i++)
{
cout<<"Module "<< ((MODULE_INFO*)myProc->modulesList.GetItem(i))->moduleName->GetChar();
cout <<" ImageBase:  "<< hex <<((MODULE_INFO*)myProc->modulesList.GetItem(i))->moduleImageBase << endl;    
}

4.5. 在进程上进行读、写和执行操作

要读取此进程内存中的某个位置,cProcess 类提供了一个名为 Read(...) 的函数,它会在您的内存中分配一个空间,然后读取此进程内存中的特定位置并将其复制到您的内存中(您内存中新分配的空间)。
DWORD Read(DWORD startAddress,DWORD size)
对于写入进程,您有另一个名为 Write 的函数,它像这样
DWORD Write (DWORD startAddressToWrite ,DWORD buffer ,DWORD sizeToWrite)
此函数接受您要写入的位置、包含要写入数据的进程中的缓冲区以及缓冲区的大小。
如果 startAddressToWrite 为 null,则 Write() 函数将在内存中分配一个用于写入的位置,并返回指向此位置的指针。
要仅在进程内分配空间……您可以使用 Allocate() 函数在进程内分配内存,它像这样
Allocate(DWORD preferedAddress,DWORD size)
您还可以选择通过在进程内创建新线程或使用这些函数将 DLL 注入进程内来在此进程内执行代码
DWORD DllInject(cString DLLFilename)
DWORD CreateThread (DWORD addressToFunction , DWORD addressToParameter)
这些函数返回新创建线程的 ThreadId。

5. 调试应用程序

要编写一个成功的调试器,您需要在调试器中包含以下功能
  • 可以附加到正在运行的进程或打开 EXE 文件并调试它
  • 可以收集寄存器值并修改它们
  • 可以在特定地址设置 Int3 断点
  • 可以设置硬件断点(在读取、写入或执行时)
  • 可以设置内存断点(在内存特定页面上的读取、写入或执行时)
  • 可以在应用程序运行时暂停
  • 可以处理异常、加载或卸载 DLL 或创建或终止线程等事件。
在本部分中,我们将介绍如何使用 SRDF 的调试器库轻松完成所有这些操作。

5.1. 打开Exe文件并调试……或附加到进程

打开 EXE 文件并进行调试
cDebugger* Debugger = new cDebugger("C:\\upx01.exe");
或使用命令行
cDebugger* Debugger = new cDebugger("C:\\upx01.exe",”xxxx”);
如果文件成功打开,您将看到 cDebugger 类中的 IsFound 变量设置为 TRUE。如果发生任何问题(文件未找到或任何其他问题),您将看到它等于 FALSE。在继续之前,请务必检查此字段。
如果您想调试一个正在运行的进程……您将使用您想要的 ProcessId 创建一个 cProcess 类,然后将调试器附加到它
cDebugger* Debugger = new cDebugger(myProc);
要开始运行应用程序……您将像这样使用 Run() 函数
Debugger->Run();
或者您可以使用 Step() 函数只运行一条指令,如下所示
Debugger->Step();
此函数返回以下输出之一(截至目前,可能会扩展)
  • DBG_STATUS_STEP
  • DBG_STATUS_HARDWARE_BP
  • DBG_STATUS_MEM_BREAKPOINT
  • DBG_STATUS_BREAKPOINT
  • DBG_STATUS_EXITPROCESS
  • DBG_STATUS_ERROR
  • DBG_STATUS_INTERNAL_ERROR
如果它返回 DBG_STATUS_ERROR,您可以检查 ExceptionCode 字段和 debug_event 字段以获取更多信息。

5.2. 获取和修改寄存器

要从调试器获取寄存器……cDebugger 类中包含所有寄存器,例如
  • 注册器 [0 -> 7]
  • Eip
  • EFlags
  • DebugStatus -> 用于硬件断点的 DR7
要更新它们,您可以修改这些变量,然后在修改后使用函数“UpdateRegisters()”使其生效。

5.3. 设置Int3断点

主要调试器的断点是指令“int3”,它在二进制(或原生)形式中转换为字节“0xCC”。调试器在需要断点的指令开头写入 int3 字节。
之后,当执行到达此指令时,应用程序停止并以异常:STATUS_BREAKPOINT 返回调试器。
要设置 Int3 断点,调试器有一个名为 SetBreakpoint(...) 的函数,如下所示
Debugger->SetBreakpoint(0x004064AF);
您可以像这样为断点设置 UserData
DBG_BREAKPOINT* Breakpoint = GetBreakpoint(DWORD Address);
断点结构如下所示
struct DBG_BREAKPOINT
{
    DWORD Address;
    DWORD UserData;
    BYTE  OriginalByte;
    BOOL  IsActive;
    WORD  wReserved;
};
因此,您可以为自己设置一个 UserData……例如指向另一个结构或某些东西的指针,并将其设置为每个断点。
当调试器的 Run() 函数返回“DBG_STATUS_BREAKPOINT”时,您可以通过 Eip 获取断点结构“DBG_BREAKPOINT”并从中获取 UserData……并操作有关此断点的信息。
此外,您可以通过使用 cDebugger 类中名为“LastBreakpoint”的变量来获取最后一个断点,如下所示
cout << "LastBp: " << Debugger->LastBreakpoint << "\n";
要禁用断点,您可以使用 RemoveBreakpoint(...) 函数,如下所示
Debugger->RemoveBreakpoint(0x004064AF);

5.4. 设置硬件断点

硬件断点是基于 CPU 调试寄存器的断点。这些断点可以在访问或写入内存位置时停止,或者可以在地址执行时停止。您只有 4 个可用断点。如果您需要添加更多断点,则必须删除一个。
这些断点不会修改应用程序的二进制文件来设置断点,因为它们不会在地址上添加 int3 字节来停止。因此,它们可以用于在打包代码上设置断点,以便在解包时中断。
要为内存中的某个位置(用于访问、写入或执行)设置硬件断点,您可以像这样设置它
Debugger->SetHardwareBreakpoint(0x00401000,DBG_BP_TYPE_WRITE,DBG_BP_SIZE_2);
Debugger->SetHardwareBreakpoint(0x00401000,DBG_BP_TYPE_CODE,DBG_BP_SIZE_4); 
Debugger->SetHardwareBreakpoint(0x00401000, DBG_BP_TYPE_READWRITE,DBG_BP_SIZE_1); 
对于纯代码,请使用 DBG_BP_SIZE_1。但对于其他情况,您可以使用等于 1 字节、2 字节或 4 字节的大小。
如果您的断点没有备用位置,此函数将返回 false。因此,您必须为此删除一个断点。
要删除此断点,您将使用 RemoveHardwareBreakpoint(...) 函数,如下所示
Debugger->RemoveHardwareBreakpoint(0x004064AF);

5.5. 设置内存断点

内存断点很少见到。它们在 OllyDbg 或 IDA Pro 中并不完全相同,但它们是很好的断点。它类似于 OllyBone。
这些断点基于内存保护。如果您在写入时设置断点,它们会将内存中的读/写位置设置为只读。或者,如果您设置读/写断点,它们会将内存中的某个位置设置为无访问权限,依此类推。
这类断点没有限制,但它在大小为 0x1000 字节的内存页面上设置断点。因此,它并不总是准确的。而且您只有访问断点和写入断点。要设置断点,您需要这样做
Debugger->SetMemoryBreakpoint(0x00401000,0x2000,DBG_BP_TYPE_WRITE);
当 Run() 函数返回 DBG_STATUS_MEM_BREAKPOINT 时,表示触发了内存断点。您可以使用 cDebugger 类的变量“LastMemoryBreakpoint”获取访问的内存位置(精确地)。
您还可以像 Int3 断点一样通过使用 GetMemoryBreakpoint(...) 并传入您设置断点(从 Address 到 (Address + Size))的内存中的任何指针来设置 UserData。它返回一个指向结构""的指针,该结构描述了内存断点,您可以在其中添加您的用户数据。
struct DBG_MEMORY_BREAKPOINT
{
    DWORD Address;
    DWORD UserData;
    DWORD OldProtection;
    DWORD NewProtection;
    DWORD Size;
    BOOL IsActive;
    CHAR cReserved;                //they are written for padding
    WORD wReserved;
};
您可以在内部看到真实的内存保护,并且可以在断点内部设置您的用户数据。
要移除断点,可以使用 RemoveMemoryBreakpoint(Address) 来移除断点。

5.6. 暂停应用程序

要在应用程序运行时暂停它,您需要在执行 Run() 函数之前创建另一个线程。这个线程将调用 Pause() 函数来暂停应用程序。这个函数将调用 SuspendThread 来暂停被调试进程(您正在调试的进程)中的被调试线程。
要再次恢复,您应该调用 Resume(),然后再次调用 Run()。
您还可以通过调用 Terminate() 函数来终止被调试进程。或者,如果您需要退出调试器并让被调试进程继续运行,您可以使用 Exit() 函数来分离调试器。

5.7. 处理事件

要处理调试器事件(加载新 DLL、卸载新 DLL、创建新线程等),您有 5 个函数来接收这些事件的通知,它们是
  • DLLLoadedNotifyRoutine
  • DLLUnloadedNotifyRoutine
  • ThreadCreatedNotifyRoutine
  • ThreadExitNotifyRoutine
  • ProcessExitNotifyRoutine
您需要继承 cDebugger 类并覆盖这些函数以接收通知。
要获取有关事件的信息,您可以从 debug_event 变量中获取信息

6. PE文件格式

DOS MZ 头
DOS 存根
PE 头
节表
第1节
第2节
第3节
第n节

 

我们将介绍 PE 头(EXE 头),以及如何从它和 SRDF 中的 cPEFile 类(PE 解析器)获取信息
EXE 文件以“MZ”字符和 DOS 头(名为 MZ 头)开头。此 DOS 头用于 EXE 文件开头的 DOS 应用程序。
这个 DOS 应用程序的创建是为了在 DOS 上运行时说“这不是一个 Win32 应用程序”。
MZ 头包含一个(从文件开头算起的)偏移量,指向 PE 头的开头。PE 头是 Win32 应用程序的真实头。

DOS PE 头
签名:PE,0,0
文件头
可选头
数据目录

它以签名“PE”和 2 个空字节开头,然后是 2 个头:文件头和可选头。
要在调试器中获取 PE 头,cPEFile 类包含指向它的指针(在进程应用程序文件的内存映射文件中),如下所示
cPEFile* PEFile = new cPEFile(argv[1]);
image_header* PEHeader = PEFile->PEHeader;
文件头包含节数(稍后描述)和此应用程序应运行的 CPU 架构和型号……例如 Intel x86 32 位等等。
此外,它还包含可选头(下一个头)的大小以及应用程序的特性(EXE 文件或 DLL)。

可选头包含 PE 的重要信息,如下表所示
字段 含义
AddressOfEntryPoint 执行开始
ImageBase PE 文件在内存中的起始位置(默认)
节对齐 映射时内存中的节对齐
文件对齐 硬盘中的节对齐(~一个扇区)
MajorSubsystemVersion
MinorSubsystemVersion
win32 子系统版本
SizeOfImage PE 文件在内存中的大小
SizeOfHeaders 所有头大小的总和
Subsystem GUI、控制台、驱动程序或其他
数据目录 指向重要头部的指针数组

要从 SRDF 中的 cPEFile 类获取这些信息……您可以在类中找到以下变量
bool FileLoaded;
image_header* PEHeader;
DWORD Magic;
DWORD Subsystem;
DWORD Imagebase;
DWORD SizeOfImage;
DWORD Entrypoint;
DWORD FileAlignment;
DWORD SectionAlignment;
WORD DataDirectories;
short nSections;
DataDirectory 是指向其他标头(可选标头……可能找到,也可能指针为空)的指针数组,以及标头的大小。
它包括
  • 导入表:从 DLL 导入 API
  • 导出表:向其他应用程序导出 API
  • 资源表:用于图标、图像等
  • 重定位表:用于重定位 PE 文件(将其加载到不同位置……与 Imagebase 不同)
我们包含了导入表的解析器……因为它包含所有导入的 DLL 和 API 的数组,如下所示
    cout << PEFile->ImportTable.nDLLs << "\n";
    for (int i=0;i <  PEFile->ImportTable.nDLLs;i++)
    {
      cout << PEFile->ImportTable.DLL[i].DLLName << "\n";
      cout << PEFile->ImportTable.DLL[i].nAPIs << "\n";
      for (int l=0;l<pefile->ImportTable.DLL[i].nAPIs;l++)
      {
        cout << PEFile->ImportTable.DLL[i].API[i].APIName << "\n";
        cout <ImportTable.DLL[i].API[i].APIAddressPlace << "\n";
      }
    }
在头之后,是节头。应用程序文件被分成几个节:代码节、数据节、资源节(图像和图标)、导入表节等等。
各节是可扩展的……因此,您可能会发现它在硬盘(或文件)中的大小小于在内存中(作为进程加载时)的大小……所以下一个节的位置将与硬盘和内存中的位置不同。
加载为进程时,节相对于文件在内存中开始位置的地址称为 RVA (Relative virtual address)……节相对于文件在硬盘中开始位置的地址称为 Offset 或 PointerToRawData。

这是节标题提供的信息
字段 含义
名称 节名称
VirtualAddress 节的 RVA 地址
虚拟大小 节的大小(在内存中)
SizeOfRawData 节的大小(在硬盘中)
PointerToRawData 指向文件开头(硬盘)的指针
Characteristics 内存保护(执行、读取、写入)

你可以像这样在 cPEFile 类中操作节
cout << PEFile->nSections << "\n";
for (int i=0;i< PEFile->nSections;i++)
{
    cout << PEFile->Section[i].SectionName << "\n";
    cout << PEFile->Section[i].VirtualAddress << "\n";
    cout << PEFile->Section[i].VirtualSize << "\n";
    cout << PEFile->Section[i].PointerToRawData << "\n";
    cout << PEFile->Section[i].SizeOfRawData << "\n";
    cout << PEFile->Section[i].RealAddr << "\n";
}
真实地址是此节在内存映射文件中的起始地址。换句话说,就是打开文件中的起始地址。
要将 RVA 转换为 Offset 或将 Offset 转换为 RVA,您可以使用这些函数
DWORD RVAToOffset(DWORD RVA);
DWORD OffsetToRVA(DWORD RawOffset);

7. 反汇编器

要理解如何使用汇编器和反汇编器,您应该理解指令的形状等等。
这是 x86 指令格式
  • 前缀是保留字节,用于描述指令中的某些内容,例如
    • 0xF0: 锁前缀……用于同步
    • 0xF2/0xF3: Repne/Rep……字符串操作的重复指令
    • 0x66: 操作数覆盖……用于 16 位操作数,例如:mov ax,4556
    • 0x67: 地址覆盖……用于 16 位 ModRM……可忽略
    • 0x64: FS 的段覆盖……例如:mov eax, FS:[18]
  • 操作码
    • 操作码编码信息关于
    • 操作类型,
    • 操作数,
    • 每个操作数的大小,包括立即操作数的大小
    • 像 Add RM/R, Reg (8 位) à 操作码: 0x00
    • 操作码可以是 1 字节、2 字节或 3 字节
    • 操作码可以使用 ModRM 中的“Reg”作为操作码扩展……这被称为“操作码组”
  • Modrm:描述操作数(目标和源)。它描述了目标或源是寄存器、内存地址(例如:dword ptr [eax+ 1000])还是立即数。
  • SIB:Modrm 的扩展……用于内存地址中的缩放,例如:dword ptr [eax*4 + ecx + 50]
  • 偏移量:括号 [] 中的值……例如 dword ptr [eax+0x1000],因此偏移量是 0x1000……它可能是一个字节、2 个字节或 4 个字节
  • 立即数:如果源或目的地的任何一个是数字(例如 move ax,1000),则为源或目的地的值……因此立即数是 1000

这就是 x86 指令格式的简要介绍……您可以在 Intel 参考手册中找到更多详细信息。
要在 SRDF 中使用 PokasAsm 类进行汇编和反汇编……您将创建一个新类并像这样使用它
CPokasAsm* Asm = new CPokasAsm();
DWORD InsLength;
char* buff;
buff = Asm->Assemble("mov eax,dword ptr [ecx+ 00401000h]",InsLength);
cout << "The Length: " << InsLength << "\n";
cout << "Assembling mov eax,dword ptr [ecx+ 00401000h]\n\n"; 
for (DWORD i = 0;i < InsLength; i++)
{
        cout << (int*)buff[i] << " ";
}
cout << "\n\n";
cout << "Disassembling the same Instruction Again\n\n";
cout << Asm->Disassemble(buff,InsLength) << " ... and the instruction length : " << InsLength << "\n\n";

输出

The Length: 6
Assembling mov eax,dword ptr [ecx+ 00401000h]
FFFFFF8B FFFFFF81 00000000 00000010 00000040 00000000
Disassembling the same Instruction Again
mov eax ,dword ptr [ecx + 401000h] ... and the instruction length : 6

此外,我们还增加了一种有效的方法来检索指令信息。我们创建了一个反汇编函数,它返回一个描述指令的结构体“DISASM_INSTRUCTION”,它看起来像这样
struct DISASM_INSTRUCTION
{
      hde32sexport hde;
      int entry;
      string* opcode;
      int ndest;
      int nsrc;
      int other;
      struct
      {
             int length;
             int items[3];
             int flags[3];
      } modrm;
      int (*emu_func)(Thread&,DISASM_INSTRUCTION*);
      int flags;
};
反汇编函数看起来像
DISASM_INSTRUCTION* Disassemble(char* Buffer,DISASM_INSTRUCTION* ins);
它接受要反汇编的缓冲区的地址以及函数将结构返回到其中的缓冲区
我们来解释一下这个结构
  • hde:这是一个由 Hacker Disassembler Engine 创建的结构,用于描述操作码……重要的字段是
    • len: 指令的长度
    • opcode: 操作码字节……如果操作码是 2 字节,则还要查看 opcode2
    • Flags: 这是标志,它有一些重要的标志,例如“F_MODRM”和“F_ERROR_XXXX”(XXXX在这里表示任何内容)
  • Entry: 未使用
  • Opcode: 操作码字符串……使用“string”类而不是“cString”
  • Other: 用于乘法保存 imm……除此之外……未使用
  • Modrm: 它是一个结构,描述了 RM 内部的内容(如果存在),例如“[eax*2 + ecx + 6]”……它看起来像
    • 长度:内部项目的数量……例如“[eax+ 2000]”包含 2 个项目
    • Flags[3]: 这描述了 RM 中的每个项目,最大值为 3……它的标志是
      • RM_REG: 该项是一个寄存器,例如“[eax …”
      • RM_MUL2: 此寄存器乘以 2
      • RM_MUL4: 乘以 4
      • RM_MUL8: 乘以 8
      • RM_DISP: 这是一个位移,例如“[0x401000 + …”
      • RM_DISP8: 与 RM_DISP 一起使用……表示位移为 8 位
      • RM_DISP16: 位移为 16 位
      • RM_DISP32: 位移为 32 位
      • RM_ADDR16: 这意味着……modrm 处于 16 位寻址模式
    • Items[3]: 这给出了 modrm 中项目的值……例如,如果项目是寄存器……那么它将包含此寄存器的编号(例如:ecx à item = 1)
    • 如果该项是一个位移……那么它将包含位移值,例如“0x401000”,依此类推。
    • emu_func: 未使用
    • Flags: 这些标志描述指令……一些描述指令形状,一些描述目的地,一些描述源……我们来看看
      • 指令形状:有一些标志描述指令,例如
        • NO_SRCDEST: 此指令没有源或目标,如“nop”
        • SRC_NOSRC: 此指令只有目标,如“push dest”
        • INS_UNDEFINED: 此指令在反汇编器中未定义……但您仍然可以从 hde.len 获取其长度
        • OP_FPU: 此指令是 FPU 指令
        • FPU_NULL: 表示此指令没有任何目标或源
        • FPU_DEST_ONLY: 表示此指令只有一个目标
        • FPU_SRCDEST: 表示此指令具有源和目标
        • FPU_BITS32: FPU 指令是 32 位
        • FPU_BITS16: 表示 FPU 指令是 16 位
        • FPU_MODRM: 表示指令包含 ModRM 字节
      • 目的地形状
        • DEST_REG: 表示目标是寄存器
        • DEST_RM: 表示目标是 RM,例如“dword ptr [xxxx]”
        • DEST_IMM: 目标是立即数(仅与 enter 指令一起使用)
        • DEST_BITS32: 目标是 32 位
        • DEST_BITS16: 目标是 16 位
        • DEST_BITS8: 目标是 8 位
        • FPU_DEST_ST: 表示目标在 FPU 独有指令中是“ST0”
        • FPU_DEST_STi: 表示目标是“STx”,例如“ST1”
        • FPU_DEST_RM: 表示目的地是 RM
      • Source Shape: 类似于目标……请阅读上面目标标志中的描述
        • SRC_REG
        • SRC_RM
        • SRC_IMM
        • SRC_BITS32
        • SRC_BITS16
        • SRC_BITS8
        • FPU_SRC_ST
        • FPU_SRC_STi
  • ndest: 这包含与目标类型相关的值
    • 如果它是一个寄存器……那么它将包含此寄存器的索引
    • 如果它是一个立即数……那么它将具有立即数值
    • 如果是 RM...那么它将为空
  • nsrc: 这包含与类型相关的源值……请参阅上面的 ndest

这只是反汇编器。我们讨论了调试器的所有项目。我们讨论了进程分析器、调试器、PE 解析器和反汇编器。现在我们应该把所有东西放在一起。

8. 融会贯通

为了编写一个既好又简单的调试器,我们决定创建一个交互式控制台应用程序(类似于 Metasploit 中的 msfconsole),它接受诸如 run 或 bp(用于设置断点)等命令。
要创建交互式控制台应用程序,我们将使用 cConsoleApp 类来创建我们的控制台应用程序。我们将从它继承一个类并开始修改其命令。
class cDebuggerApp : public cConsoleApp
{
public:
    cDebuggerApp(cString AppName);
    ~cDebuggerApp();
    virtual void SetCustomSettings();
    virtual int Run();
    virtual int Exit();
};
And the Code:
cDebuggerApp::cDebuggerApp(cString AppName) : cConsoleApp(AppName)
{
    
}
cDebuggerApp::~cDebuggerApp()
{
    ((cApp*)this)->~cApp();
}

void cDebuggerApp::SetCustomSettings()
{
    //Modify the intro of the application
Intro = "\
    ***********************************\n\
    **       Win32 Debugger          **\n\
    ***********************************\n";

}
int cDebuggerApp::Run()
{
    //write your code here for run
StartConsole();
    return 0;
}
int cDebuggerApp::Exit()
{
    //write your code here for exit
    return 0;
}
如您在前面的代码中看到的,我们实现了 3 个函数(虚函数),它们是
  1. SetCustomSettings:此函数用于修改应用程序的设置……例如修改应用程序的介绍、包含日志文件、包含应用程序的注册表项或包含应用程序的数据库以保存数据……如您所见,它用于编写介绍。
  2. Run: 此函数用于运行应用程序。您应该调用 StartConsole 来启动交互式控制台。
  3. Exit: 当用户在控制台输入“quit”命令时,此函数被调用。
cConsoleApp 为您实现了两个命令:“quit”和“help”。Quit 退出应用程序,help 显示带有描述的命令列表。要添加新命令,您应该调用此函数
AddCommand(char* Name,char* Description,char* Format,DWORD nArgs,PCmdFunc CommandFunc)
命令 Func 是当用户输入此命令时将被调用的函数……它应该具有以下格式
void CmdFunc(cConsoleApp* App,int argc,char* argv[])
它类似于添加了 App 类的 main 函数。argv 是此函数的参数列表,argc 是参数数量(总是等于您在 add commands 中输入的 nArgs,可以忽略,因为它已保留)。
要使用 AddCommand……您可以像这样使用它
AddCommand("dump","Dump a place in memory in hex","dump [address] [size]",2,&DumpFunc);
DumpFunc 像这样
void DumpFunc(cConsoleApp* App,int argc,char* argv[])
{
    ((cDebuggerApp*)App)->Dump(argc,argv);
};
因为它调用 cDebuggerApp 类(继承自 cConsoleApp 类)中的 Dump 函数。
我们为应用程序添加了这些命令
AddCommand("step","one Step through code","step",0,&StepFunc);
AddCommand("run","Run the application until the first breakpoint","run",0,&RunFunc);
AddCommand("regs","Show Registers","regs",0,&RegsFunc);
AddCommand("bp","Set an Int3 Breakpoint","bp [address]",1,&BpFunc);
AddCommand("hardbp","Set a Hardware Breakpoint","hardbp [address] [size (1,2,4)] [type .. 0 = access .. 1 = write .. 2 = execute]",3,&HardbpFunc);
AddCommand("membp","Set Memory Breakpoint","membp [address] [size] [type .. 0 = access .. 1 = write]",3,&MembpFunc);
AddCommand("dump","Dump a place in memory in hex","dump [address] [size]",2,&DumpFunc);
AddCommand("disasm","Disassemble a place in memory","disasm [address] [size]",2,&DisasmFunc);
AddCommand("string","Print string at a specific address","string [address] [max size]",2,&StringFunc);
AddCommand("removebp","Remove an Int3 Breakpoint","removebp [address]",1,&RemovebpFunc);
AddCommand("removehardbp","Remove a Hardware Breakpoint","removehardbp [address]",1,&RemovehardbpFunc);
AddCommand("removemembp","Remove Memory Breakpoint","removemembp [address]",1,&RemovemembpFunc);
运行函数
int cDebuggerApp::Run()
{

    Debugger = new cDebugger(Request.GetValue("default"));
    Asm = new CPokasAsm();
    if (Debugger->IsDebugging)
    {
        Debugger->Run();
        Prefix = Debugger->DebuggeeProcess->processName;
        if (Debugger->IsDebugging)StartConsole();
    }
    else
    {
        cout << Intro << "\n\n";
        cout << "Error: File not Found";
    }
    return 0;
}
如您所见,我们让应用程序在用户输入有效文件名时启动控制台,否则返回错误并关闭应用程序。
我们不会描述所有命令,而是那些难以实现的命令。
void cDebuggerApp::Disassemble(int argc,char* argv[])
{
    DWORD Address = 0;
    DWORD Size = 0;
    sscanf(argv[0], "%x", &Address);
    sscanf(argv[1], "%x", &Size);
    DWORD Buffer = Debugger->DebuggeeProcess->Read(Address,Size+16);
    DWORD InsLength = 0;
    
    for (DWORD InsBuff = Buffer;InsBuff < Buffer+ Size ;InsBuff+=InsLength)
    {
        cout << (int*)Address << ": " << Asm->Disassemble((char*)InsBuff,InsLength) << "\n";
        Address+=InsLength;
    }
}    
此函数一开始将参数从字符串(用户输入)转换为十六进制值。然后,它读取被调试进程中您需要反汇编的内存。如您所见,我们添加了 16 字节以确保即使其中一条指令超出缓冲区限制,所有指令也能正确反汇编。
然后,我们开始循环反汇编过程,并按每条指令的长度递增地址,直到达到限定大小。
主函数将调用一些函数来启动应用程序并运行它
int _tmain(int argc, char* argv[])
{
    cDebuggerApp* Debugger = new cDebuggerApp("Win32Debugger");
    Debugger->SetCustomSettings();
    Debugger->Initialize(argc,argv);
    Debugger->Run();
    return 0;
}

9. 结论

在本文中,我们描述了如何使用 SRDF 编写调试器……以及 SRDF 的易用性。我们还描述了如何分析 PE 文件以及指令反汇编的工作原理。


© . All rights reserved.