NHook - 一个用于 x86 的 .NET 调试器 API






4.92/5 (28投票s)
调试器 API,轻松探索和修改正在运行的程序
using(var dbg = new ProcessDebugger()) { dbg.Start(SimpleCrackMe); RVA mainRVA = dbg.SymbolManager.FromName("SimpleCrackMe.exe", "main").RVA; //Get main address Breakpoint breakPoint = dbg.Breakpoints.At("SimpleCrackMe.exe", mainRVA); //Set breakpoint dbg.BreakingThread.Continue.UntilBreakpoint(breakPoint); //Run to it var disasm = dbg.BreakingThread.Continue.UntilInstruction("JNZ"); //Continue execution until next JNZ dbg.BreakingThread.WriteInstruction("NOP"); //Nop the JNZ dbg.Patches.ModifyImage(dbg.Debuggee.MainModule, "SimpleCrackMe-Patched.exe"); //Save changes back to a file Assert.Equal(1, dbg.Continue.ToEnd()); //Debugged process resolved ! var result = StartAndGetReturnCode("SimpleCrackMe-Patched.exe"); Assert.Equal(1, result); //SimpleCrakMe-Patched also resolved !!!! }
为什么你需要一个调试器 API?
我不是那种能把新设备拆开研究明白的人。我不是硬件方面的专家。任何一个给我一把螺丝刀的人事后都会后悔。
但是对于软件,那是另一回事。当一个软件能做很酷的事情时,我想知道它们是怎么做到的……我也使用完全相同的工具集来调试 bug,因为软件几乎从来不像预期的那样工作……Google 和 MSDN 有时不足以解决你的问题。
所以我使用很多不同的工具来查看内部发生的情况,这些程序就像你在 Windows 内部的眼睛,而且你肯定每天都在使用它们……例如,强大的 Procmon 用于调试文件系统和注册表访问,出色的 API Monitor 用于调试有问题的 API 调用——这个应该得到更多关注,它是一个真正很棒的工具——著名的 Wireshark 用于网络或协议相关问题,以及最后的手段,如 OllyDbg、IDA pro 和 WinDbg 等调试器。
问题在于我喜欢我舒适的 .NET 世界,所以我想到:“嘿,如果所有这些很棒的工具都能在一个漂亮友好的 .NET API 中提供呢?”所以这个 API 就是我对这个问题的回答。目前并非所有功能都已实现,但这只是最初的版本。现在它只支持x86 程序。
在这篇文章的最后,你将能够
- 以编程方式设置汇编断点
- 查找远程进程中已导入和已加载模块及函数的地址。
- 由此,设置对已知 WIN API 函数的断点。
- 应用补丁到远程进程(修改 x86 指令或数据)
- 将补丁推回文件。
所有这些都很好地用舒适的 .NET API 实现。
对于高级开发者,你将理解
- 调试器是如何工作的(内部原理)
- 什么是原生断点(内部原理)
- 什么是 PE 文件、模块、PDB。
NHook 使用 OllyDbg 汇编器/反汇编器 来处理汇编部分。
NHook 和本文由 Dusan Trtica 和我共同创作。我们一起合作这个项目。Dusan 是一个非常有才华的 C++ 开发者,项目进展顺利多亏了他!(我在 C++ 方面太慢了!)
那么,我们开始吧!
如何破解一个简单的 Crack Me 程序
在编写任何代码之前,NHook 的目标是让破解这个简单程序变得容易。既然目标已经设定,编写代码只是表达达到这个目标路径的过程。
#include <stdio.h>
int main(int argc, char** argv)
{
if(argc == 3)
{
printf("success");
return 1;
}
printf("miss");
return 0;
}
使用 NHook,我将绕过if条件,这样即使我没有提供 2 个参数来调用程序,它也会打印成功。
这个函数汇编代码看起来是什么样的?
要查看汇编代码,让我们使用 OllyDbg 运行SimpleCrackMe.exe,然后打开可执行模块窗口。
代码是用 VC++ 2012 运行时编译的,所以请下载并安装它,否则加载器将找不到MSVCR110D(包含 printf 的 C 运行时库的调试库)。
你可以看到,在运行任何代码之前,已经有几个.dll被加载到我的进程地址空间中。加载器是如何知道要加载哪个dll的?嗯……这只是写在任何 PE 文件(dll或exe文件)的导入表中的。你可以使用 CFF explorer 查看它。
每个导入的 dll 都有其他依赖项,这些依赖项也会被加载。OllyDbg 仅在加载器完成后才阻塞。
所以,在可执行模块窗口中,你可以看到SimpleCrackMe加载在0x3D0000,入口点在0x3E110E。
所以你可能认为0x3E110E是main的地址……但错了!如果禁用所有 MS 编译器的设置和功能,它可能是,但总有一些引导代码。如果它是混合汇编(C++/CLI),则有一些 .NET 相关的东西,如果它是与 C 运行时库链接的,那么也有一些引导代码……这是入口点……直接指向引导程序mainCRTStartup。
你也可以使用CFF Explorer获取可执行文件的入口点,入口点位于 PE 文件中。
0x1110E 是相对于模块SimpleCrackMe基地址的地址。它被称为RVA(相对虚拟地址)。
这就是为什么 Ollydbg 显示的有效VA(虚拟地址)是0x3D0000 + 0x1110E = 0x3E110E。
那么,回到问题:你如何找到main的地址? 回答:使用pdb。
PDB只不过是一个包含符号(名称和类型)及其RVA以及可能的源文件和行号的数据库。
在 .NET 中,PDB不存储RVA,只存储MetadataToken-源文件:行号。JIT编译器最终负责选择如何将 MSIL 转换为(x86、64 等……),这就是为什么 C# 或 VB .NET 编译器无法提前预测RVA而使用MetadataToken。
在可执行视图中右键单击SimpleCrackMe.exe模块,它将显示所有通过pdb或PE 的导出目录解析的符号。
搜索main符号,点击它,你应该能在内存转储中看到代码。
如果你看到的是十六进制编辑器而不是指令,请右键单击内存转储,然后点击反汇编,以更改其视图。
有趣的是:在0x3E13EE有一个测试,紧接着在0x3E13F2有一个条件跳转 (JNZ)……这就是我们需要绕过的if。
我们可以通过将JNZ打补丁成两个 NOP指令来绕过它。
不用担心……NHook链接了 OllyDbg 的汇编器/反汇编器库,所以你不必知道这些指令的操作码。
现在我已经讲清楚了基础知识,你可以理解使用NHook所需知道的一切,接下来的代码应该不言自明。
using(var dbg = new ProcessDebugger()) { dbg.Start(SimpleCrackMe); RVA mainRVA = dbg.SymbolManager.FromName("SimpleCrackMe.exe", "main").RVA; //Get main address Breakpoint breakPoint = dbg.Breakpoints.At("SimpleCrackMe.exe", mainRVA); //Set breakpoint dbg.BreakingThread.Continue.UntilBreakpoint(breakPoint); //Run to it var disasm = dbg.BreakingThread.Continue.UntilInstruction("JNZ"); //Continue execution until next JNZ dbg.BreakingThread.WriteInstruction("NOP"); //Nop the JNZ dbg.Patches.ModifyImage(dbg.Debuggee.MainModule, "SimpleCrackMe-Patched.exe"); //Save changes back to a file Assert.Equal(1, dbg.Continue.ToEnd()); //Debugged process resolved ! var result = StartAndGetReturnCode("SimpleCrackMe-Patched.exe"); Assert.Equal(1, result); //SimpleCrakMe-Patched also resolved !!!! }
深入挖掘
如果你只想使用NHook,你可以在这里停止。我不会在本节介绍新功能,如果你想了解内部工作原理……你可以继续。
我要回答的第一个问题是:什么是断点?
断点本质上是x86 指令 (INT 3),它会触发一个由调试器处理的中断。你可以自己验证,尝试用 Ollydbg 设置一个断点。
然后,用另一个内存编辑器检查指令地址0x012E13D0。例如,用出色的 API Monitor。
所以调试器显示的值是0x55 (PUSH EBP),但内存编辑器显示的是0xCC (INT 3)。结论:调试器通过用0xCC (INT 3)覆盖给定指令来设置断点。
那么你的下一个问题可能是:如何向另一个进程的内存写入数据?
你可以使用 WriteProcessMemory 来实现。这是一个简单的 C++/CLI 包装器,processHandle参数可以很容易地通过 .NET 中的 Process.Handle 方法找到。
public:static void WriteMemory(IntPtr processHandle, IntPtr baseAddress, array<Byte>^ input) { mauto_handle<BYTE> bytes(Util::ToCArray(input)); SIZE_T readen; ASSERT_TRUE(WriteProcessMemory(processHandle.ToPointer(),baseAddress.ToPointer(),bytes.get(),input->Length,&readen)); }
WriteProcessMemory和所有调试方法都将不起作用,除非调试器的线程安全令牌具有SeDebugPrivilege。此外,在使用UAC时,此特权会被过滤。
这是procexp的截图,显示了一个进程(csrss.exe)以System账户运行。System 账户的令牌具有SeDebugPrivilege。
你可以通过本地计算机策略授予此特权(管理员默认启用)。
如果你没有这些权限,被调试器应该授予调试器进程的 ACLPROCESS_VM_OPERATION PROCESS_VM_READ PROCESS_VM_WRITE权限……但我们不会走这条路。
现在你已经理解了什么是断点,如何将调试器附加到进程?
第一个解决方案是让调试器启动进程。这是一个 C++/CLI 包装器。
static ProcessInformation^ StartDebugProcess(String^ appPath) { marshal_context context; STARTUPINFO startupInfo = {0}; startupInfo.cb = sizeof(STARTUPINFO); PROCESS_INFORMATION processInformation = {0}; auto directory = System::IO::Path::GetDirectoryName(appPath); ASSERT_TRUE(CreateProcess(context.marshal_as<LPCTSTR>(appPath), NULL, NULL, NULL, false, CREATE_DEFAULT_ERROR_MODE | CREATE_NEW_CONSOLE | DEBUG_ONLY_THIS_PROCESS | NORMAL_PRIORITY_CLASS, NULL, context.marshal_as<LPCTSTR>(directory), &startupInfo, &processInformation)); return gcnew ProcessInformation(&processInformation); }
有趣的是创建标志DEBUG_ONLY_THIS_PROCESS。与DEBUG_PROCESS不同,它不会调试子进程。
第二个解决方案是使用 DebugActiveProcess 附加到正在运行的进程。
public: static ProcessInformation^ DebugActiveProcess(int pid) { ASSERT_TRUE(::DebugActiveProcess(pid)); auto process = System::Diagnostics::Process::GetProcessById(pid); PROCESS_INFORMATION info; info.dwProcessId = process->Id; info.dwThreadId = process->Threads[0]->Id; info.hProcess = OpenProcess(PROCESS_ALL_ACCESS,false,process->Id); info.hThread = OpenThread(THREAD_ALL_ACCESS,false, process->Threads[0]->Id); return gcnew ProcessInformation(&info); }
一旦你的调试器附加,只有 2 个函数将控制如何接收被调试者的事件以及何时继续执行。
第一个是 WaitForDebugEvent。它将阻塞调试器线程,直到下一个调试事件。一旦收到调试事件,你就调用 ContinueDebugEvent 来继续被调试者的执行。
我的 C++/CLI 包装器将 Debug_Event 转换为 CLR 类型。当前有趣的调试事件是:Dll 加载、异常抛出、CreateThread 和 ExitProcess。
static DebugEvent^ WaitForEvent(ProcessInformation^ processInformation, TimeSpan timeout) { DEBUG_EVENT dbgEvent; BOOL result; if(timeout == TimeSpan::MaxValue) result = WaitForDebugEvent(&dbgEvent, INFINITE); else result = WaitForDebugEvent(&dbgEvent, (DWORD)timeout.TotalMilliseconds); if(!result) return nullptr; if(dbgEvent.dwDebugEventCode == (DWORD)DebugEventType::LoadDllEvent) return gcnew DebugEventEx<LoadDllDetail^>(&dbgEvent, gcnew LoadDllDetail(processInformation, &dbgEvent.u.LoadDll)); if(dbgEvent.dwDebugEventCode == (DWORD)DebugEventType::ExceptionEvent) return gcnew DebugEventEx<ExceptionDetail^>(&dbgEvent, gcnew ExceptionDetail(processInformation, &dbgEvent.u.Exception)); if(dbgEvent.dwDebugEventCode == (DWORD)DebugEventType::CreateThreadEvent) return gcnew DebugEventEx<CreateThreadDetail^>(&dbgEvent, gcnew CreateThreadDetail(processInformation, &dbgEvent.u.CreateThread)); if(dbgEvent.dwDebugEventCode == (DWORD)DebugEventType::ExitProcessEvent) return gcnew DebugEventEx<ExitProcessDetail^>(&dbgEvent, gcnew ExitProcessDetail(processInformation, &dbgEvent.u.ExitProcess)); return gcnew DebugEvent(&dbgEvent); }
ContinueDebugEvent只是要求进程和线程继续执行,以及调试器是否处理了异常。
static void Continue(DebugEvent^ debugEvent, bool handleException) { ContinueDebugEvent(debugEvent->ProcessId, debugEvent->ThreadId, handleException ? DBG_CONTINUE : DBG_EXCEPTION_NOT_HANDLED); }
那么,如何触发断点?
很简单:遍历所有DebugEvent,当收到一个“异常”事件并且原因是“断点”时停止,这是 C# 代码,它就是这么做的。
public DebugEventEx<ExceptionDetail> UntilNextBreakpoint() { return (DebugEventEx<ExceptionDetail>)Until(ev => { if(ev.EventType != DebugEventType.ExceptionEvent) return false; return ((DebugEventEx<ExceptionDetail>)ev).Details.Exception.Reason == ExceptionReason.ExceptionBreakpoint; }); }
Until方法循环直到谓词返回 true。(Run方法最终调用ContinueDebugEvent,NextEvent方法最终调用WaitDebugEvent方法)
public DebugEvent Until(Func<DebugEvent, bool> filter) { DebugEvent lastEvent = null; bool first = true; if(_Commandable.CurrentEvent != null) Run(); while(lastEvent == null || !filter(lastEvent)) { if(!first) Run(); first = false; lastEvent = _Commandable.Wait.NextEvent(); } return lastEvent; }
如何从 PDB 转储内存地址
没有 PDB,开发者的生活将非常艰难。没有 PDB,你将无法知道当前线程在哪里崩溃在main方法中。你只会看到一堆堆栈上的字节。
因此,在调试会话期间,你将无法知道局部变量的值或类型。生活将非常艰难。如此艰难,以至于调试专家会告诉你PDB 与你的源代码一样重要。
正是因为 PDB,我才能在这个示例中找到main方法的 RVA。
using(var dbg = new ProcessDebugger()) { dbg.Start(SimpleCrackMe); RVA mainRVA = dbg.SymbolManager.FromName("SimpleCrackMe.exe", "main").RVA; //Get main address Breakpoint breakPoint = dbg.Breakpoints.At("SimpleCrackMe.exe", mainRVA); //Set breakpoint dbg.BreakingThread.Continue.UntilBreakpoint(breakPoint); //Run to it var disasm = dbg.BreakingThread.Continue.UntilInstruction("JNZ"); //Continue execution until next JNZ dbg.BreakingThread.WriteInstruction("NOP"); //Nop the JNZ dbg.Patches.ModifyImage(dbg.Debuggee.MainModule, "SimpleCrackMe-Patched.exe"); //Save changes back to a file Assert.Equal(1, dbg.Continue.ToEnd()); //Debugged process resolved ! var result = StartAndGetReturnCode("SimpleCrackMe-Patched.exe"); Assert.Equal(1, result); //SimpleCrakMe-Patched also resolved !!!! }
听起来很酷?那么我们来看看如何解析 PDB。
Microsoft 在 Visual Studio 中提供了一个名为 DIA(Debug Interface Access)的 COM 组件,它正好能做到这一点。所以我只需要为这个 COM 组件生成 COM 互操作 .NET 程序集。
转到C:\ProgF\Microsoft Visual Studio 11.0\DIA SDK\idl。
从idl生成一个tlb文件。
midl /I "C:\ProgF\Microsoft Visual Studio 11.0\DIA SDK\include" dia2.idl /tlb dia2.tlb
从tlb生成dll
tlbimp dia2.tlb
在NHook中引用生成的Dia2Lib.dll。
其余的只是在调试器的WaitNextEvent方法中为进程中加载的每个模块创建一个DiaSource对象。
if(dbgEvent.EventType == DebugEventType.LoadDllEvent) { if(AreSameImage(dbgEvent.As<LoadDllDetail>().ImageName, "kernel32.dll")) { Debuggee.SetProcess(); SymbolManager.LoadSymbolsFromExe(Debuggee.MainModule.FileName); } SymbolManager.LoadSymbolsFromExe(dbgEvent.As<LoadDllDetail>().ImageName); }
加载符号很容易
public bool LoadSymbolsFromExe(string exePath) { var pdb = Path.ChangeExtension(exePath, "pdb"); if(File.Exists(pdb)) { var source = LoadSymbols(pdb); _Sources.Add(Path.GetFileName(exePath), source); return true; } return false; } private DiaSource LoadSymbols(string pdbPath) { if(!File.Exists(pdbPath)) throw new FileNotFoundException(pdbPath); var diaSource = COMHelper.RegisterIfNotExistAndCreate(() => new DiaSource(), "msdia110.dll"); diaSource.loadDataFromPdb(pdbPath); return diaSource; }
RegisterIfNotExistAndCreate只是一个方法,它会在本地计算机上自动部署 COM 组件,这样用户就不会遇到奇怪的异常。
SymbolManager.FromName只需要获取正确的DIASource,提取正确的符号,然后返回一个漂亮的普通 .NET 对象。
public SymbolInfo FromName(string moduleName, string name) { return FromName(moduleName, name, null); } public SymbolInfo FromName(string moduleName, string name, SymTagEnum? type) { _Debugger.EnsureProcessLoaded(); DiaSource diaSource = FindSource(moduleName); IDiaSession session; IDiaEnumTables tables; diaSource.openSession(out session); session.getEnumTables(out tables); return tables.ToEnumerable() .OfType<IDiaEnumSymbols>() .SelectMany(s => s.ToEnumerable()) .Where(s => s.name == name && (type == null || (uint)type.Value == s.symTag)) .Select(s => new SymbolInfo(s)) .FirstOrDefault(); }
打补丁
如果你修改了一个程序的行为,并且想将所做的更改持久化到二进制文件中,该怎么办?
你也可以轻松做到!每次调试器向被调试者的内存写入时,更改都会被Debugger.Patches跟踪,每个Patch都是一个更改,你可以选择删除它们,或者将它们应用到二进制文件中。
using(var dbg = new ProcessDebugger()) { dbg.Start(SimpleCrackMe); RVA mainRVA = dbg.SymbolManager.FromName("SimpleCrackMe.exe", "main").RVA; //Get main address Breakpoint breakPoint = dbg.Breakpoints.At("SimpleCrackMe.exe", mainRVA); //Set breakpoint dbg.BreakingThread.Continue.UntilBreakpoint(breakPoint); //Run to it var disasm = dbg.BreakingThread.Continue.UntilInstruction("JNZ"); //Continue execution until next JNZ dbg.BreakingThread.WriteInstruction("NOP"); //Nop the JNZ dbg.Patches.ModifyImage(dbg.Debuggee.MainModule, "SimpleCrackMe-Patched.exe"); //Save changes back to a file Assert.Equal(1, dbg.Continue.ToEnd()); //Debugged process resolved ! var result = StartAndGetReturnCode("SimpleCrackMe-Patched.exe"); Assert.Equal(1, result); //SimpleCrakMe-Patched also resolved !!!! }
它在内部如何工作需要一些解释,说明dll或exe在 RAM 中与在磁盘上的存储方式。
dll 中的一个字节有两个位置:文件偏移量和RVA。文件偏移量是它在磁盘文件中的位置,RVA是它在模块加载地址相对于 RAM 中的位置。
为什么是这样?
两个主要原因
- 文件不必占用空间来存储全局变量。这些值是在运行时确定的。
- 文件需要紧凑。
- 另一方面,处理器需要将 DLL 的段加载到页边界 (4 KB),以确保安全性和性能。处理器可以阻止在某些页面中执行代码,这样缓冲区或堆栈溢出就不会轻易被利用。
举个例子
你可以使用 CFF explorer 看到一个可执行文件(dll 或 exe)有多个段。
段的虚拟地址是RVA。原始地址是文件偏移量。
.text 段通常是汇编代码所在的位置。
现在想象一个名为IVssAdmin::RegisterProvider的方法位于 RAM 中该段的地址E3BC。
那么,根据 .text 段的 Raw Size 和 Raw Address(文件偏移量)的信息,IVssAdmin::RegisterProvider 将位于磁盘上的D7BC。
所以这是将RVA转换为文件偏移量的数学计算。
E3BC (RVA) - 1000 (.text 段的 RVA) = D3BC (相对于 .text 段的地址)
D3BC + 400 (.text 段的文件偏移量) = D7BC (文件偏移量)
或者在代码中
public FileOffset ToImageOffset(RVA rva) { var section = GetSectionAt(rva); if(section == null) throw new InvalidOperationException("This RVA belongs to no section"); var addressInSection = rva.Address - section.VirtualAddress; var offset = section.PointerToRawData + addressInSection; if(offset >= section.PointerToRawData + section.SizeOfRawData) throw new InvalidOperationException("This RVA is virtual"); return new FileOffset(this, offset); }
这就是Patch如何将跟踪的RVA更改转换为二进制文件的更改。
----新增:现在 NHook 可以从 dll 的导出目录中找到 RVA。
结论
希望你喜欢它,我认为 .NET 开发者了解底层发生的事情并非坏事。这是一个我将在需要以任何理由操作进程内存或行为时使用的 API 的开始。
还有很多重要的事情要做,比如
- x64 支持
- .NET 支持
查看 NHook 主页!