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

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (28投票s)

2012 年 11 月 29 日

GPL3

11分钟阅读

viewsIcon

72194

调试器 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 用于网络或协议相关问题,以及最后的手段,如 OllyDbgIDA proWinDbg 等调试器。

问题在于我喜欢我舒适的 .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,然后打开可执行模块窗口。

image

代码是用 VC++ 2012 运行时编译的,所以请下载并安装它,否则加载器将找不到MSVCR110D(包含 printf 的 C 运行时库的调试库)。

你可以看到,在运行任何代码之前,已经有几个.dll被加载到我的进程地址空间中。加载器是如何知道要加载哪个dll的?嗯……这只是写在任何 PE 文件(dllexe文件)的导入表中的。你可以使用 CFF explorer 查看它。

image

每个导入的 dll 都有其他依赖项,这些依赖项也会被加载。OllyDbg 仅在加载器完成后才阻塞。

所以,在可执行模块窗口中,你可以看到SimpleCrackMe加载在0x3D0000,入口点在0x3E110E

所以你可能认为0x3E110Emain的地址……但错了!如果禁用所有 MS 编译器的设置和功能,它可能是,但总有一些引导代码。如果它是混合汇编(C++/CLI),则有一些 .NET 相关的东西,如果它是与 C 运行时库链接的,那么也有一些引导代码……这是入口点……直接指向引导程序mainCRTStartup

image

你也可以使用CFF Explorer获取可执行文件的入口点,入口点位于 PE 文件中。

image

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模块,它将显示所有通过pdbPE 的导出目录解析的符号。

搜索main符号,点击它,你应该能在内存转储中看到代码。

image

如果你看到的是十六进制编辑器而不是指令,请右键单击内存转储,然后点击反汇编,以更改其视图。

有趣的是:在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 设置一个断点。

image

然后,用另一个内存编辑器检查指令地址0x012E13D0。例如,用出色的 API Monitor

image

所以调试器显示的值是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

image

你可以通过本地计算机策略授予此特权(管理员默认启用)。

image

如果你没有这些权限,被调试器应该授予调试器进程的 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方法最终调用ContinueDebugEventNextEvent方法最终调用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 !!!!
}

它在内部如何工作需要一些解释,说明dllexe在 RAM 中与在磁盘上的存储方式。

dll 中的一个字节有两个位置:文件偏移量RVA文件偏移量是它在磁盘文件中的位置,RVA是它在模块加载地址相对于 RAM 中的位置。

为什么是这样?
两个主要原因

  • 文件不必占用空间来存储全局变量。这些值是在运行时确定的。
  • 文件需要紧凑。
  • 另一方面,处理器需要将 DLL 的加载到页边界 (4 KB),以确保安全性和性能。处理器可以阻止在某些页面中执行代码,这样缓冲区或堆栈溢出就不会轻易被利用。

举个例子
你可以使用 CFF explorer 看到一个可执行文件(dll 或 exe)有多个段。
段的虚拟地址是RVA。原始地址是文件偏移量
Image [29]

.text 段通常是汇编代码所在的位置。
现在想象一个名为IVssAdmin::RegisterProvider的方法位于 RAM 中该段的地址E3BC
Image [30]
那么,根据 .text 段的 Raw Size 和 Raw Address(文件偏移量)的信息,IVssAdmin::RegisterProvider 将位于磁盘上的D7BC

Image [31]

所以这是将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 主页!

© . All rights reserved.