ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

D3D11 Hook实战:vtable hook与x86/x64兼容的Present拦截框架

D3D11 Hook实战:vtable hook与x86/x64兼容的Present拦截框架 简介面向D3D11 Hook与游戏渲染逆向开发者这套C源码工程提供了一套可编译的D3D11 Hook实现同时支持X86与X64平台适合需要掌握API拦截、DLL注入以及渲染管线挂钩的读者。压缩包共84个文件rar包大小约4.35MB以cpp/h源码、vcxproj工程文件、inc头文件为主还包含obj/lib、pdb调试信息、tlog编译日志等构建辅助文件便于直接打开工程对照学习。目前已有2674人学习下载同类需求者可参考其中完整架构。资源内包含inject注入器、Dll1注入模块、hook主控模块以及BeaEngine反汇编引擎覆盖从目标进程注入、D3D11函数挂钩到指令解析的完整链路各模块目录与编译配置划分清晰二次开发时可按需替换或扩展。该工程对D3D11 Hook思路做了模块化拆分inject负责远程线程注入Dll1承载Hook逻辑hook模块统一管理函数地址与调用链BeaEngine辅助处理指令长度与反汇编解析结构比常见单文件Demo更接近实际项目适合具备一定C和Windows编程基础的开发者深入研究。 D3D11 HOOK 在处理渲染调试、叠加层以及性能分析的时候几乎是绕不开的一项技能。特别是当你的工具需要跑在 x86 和 x64 两种进程里还得保持稳定不崩溃这就不是随便找个 sample 改改就能糊弄过去的了。最近我把这套 C 源码重新整理了一遍支持 X86_X64正好把关键设计和踩坑记录整理成文。这篇内容适合两类读者一类是做图形分析工具、录屏软件、标注插件的开发需要理解 D3D11 的 Present 环节如何在外部扩展另一类是刚接触 HOOK 机制想弄明白 vtable hook 和 inline hook 的区别、x86/x64 下指针处理差异的初学者。如果你只想快速用起来后面也给了可以直接抄作业的核心框架。1. 项目概述与整体设计思路1.1 为什么选中 D3D11 HOOKD3D11 HOOK 最常用的入口是IDXGISwapChain::Present它负责把后备缓冲区渲染结果提交到屏幕。任何叠加层、帧率统计、画面采集本质上都是在 Present 前后插入自己的逻辑。这个方案选 Present 而不选 Draw 或 Dispatch 的理由很直接Draw 调用太频繁每帧可能几百上千次你不可能每次都在里面跑 UI 逻辑Present 每帧通常只调用一次时机稳定而且此时后备缓冲已经完成了所有渲染工作你拿到的图像就是最终画面。这套源码要解决的核心问题有三个第一兼容 x86 和 x64 编译环境第二注入后不掉帧不闪退第三代码可以复用方便你扩展出自己的覆盖层或采集模块。从实际使用效果看按这套框架做出来的 DLL 在稳定性和兼容性上都经得起长时间运行测试。1.2 方案选型vtable hook 与 inline hook 的取舍谈到 HOOK绕不开两种主流实现方式一种是改虚函数表vtable hook另一种是改函数入口inline hook常见库有 MinHook、Detours。两套方案各有优势也各有痛点。vtable hook 的原理很直接D3D11 的接口都是纯虚类IDXGISwapChain的虚函数表里保存着一组函数指针你只需要把Present那一项改成自己的函数地址就行。优点是不需要修改可执行代码只需要改内存里的一个指针操作简单也不容易被完整性校验发现缺点是这类 HOOK 对接口版本变化敏感一旦函数表布局变化就得重新调整。inline hook 则需要修改目标函数开头几个字节跳转到你自己的代码。MinHook 这类库已经处理了重定位和指令修复稳定性很高但实现复杂度高而且反调试工具对这类 HOOK 极其敏感。考虑到这套源码的目标是跨 x86/x64 复用并且希望使用者能快速集成我最终选择了 vtable hook 作为主方案同时预留了 inline hook 的接口。主要原因在于 D3D11 的接口虚函数表在 x86 和 x64 下布局规则基本一致用指针替换的方式处理时不需要担心指令集差异带来的兼容问题。1.3 x86/x64 兼容性的核心挑战x86 和 x64 最大的差异在于指针宽度这直接影响 HOOK 函数原型的定义。32 位下函数指针用std::uint32_t也能存64 位下就必须用std::uintptr_t。很多网上流传的源码在 64 位下崩溃多半就是这里埋的雷。另一个常见坑是调用约定。x86 默认__thiscallx64 下只有一个调用约定所以你不能简单地把同一份代码原样编译到两种架构。我在这套代码里通过预处理器宏分别定义了函数指针类型typedef HRESULT (STDMETHODCALLTYPE* PresentFn)( IDXGISwapChain* pSwapChain, UINT SyncInterval, UINT Flags );STDMETHODCALLTYPE这个宏在 x86 下展开为__stdcall在 x64 下为空。用这种方式定义函数指针之后剩下的逻辑在两种架构下就能保持高度一致。这种细节看起来不起眼但在实际项目中就是能不能编译通过、能不能稳定运行的分水岭。2. 核心细节解析与实操要点2.1 虚函数表定位与索引校验要 hook Present先得拿到IDXGISwapChain接口。最常见的方法是从D3D11CreateDeviceAndSwapChain的返回值中获取 SwapChain 指针。在你自己控制创建流程的场景下这很简单。但如果你要 hook 一个已经运行起来的进程就得靠另一种思路通过遍历进程里已有的 DXGI 设备来获取。一个比较实用的做法是注入 DLL 后在入口处先创建一个临时的 SwapChain 用于读取虚函数表然后立刻释放。因为IDXGISwapChain的虚函数表地址在同一个进程内是共享的哪怕你创建的实例和目标的类型完全一致虚函数表里的函数指针却是一样的。具体操作流程是这样创建临时ID3D11Device和IDXGISwapChain参数尽量简单窗口句柄可以用当前进程已有的窗口也可以自己 CreateWindow 一个隐藏窗口。从临时 SwapChain 的虚函数表里读出 Present 对应的索引位置。释放临时对象只保留读到的函数地址。读取索引位置这一步可以直接从IDXGISwapChain头文件定义的接口顺序推算。Present 在IDXGISwapChainVtbl里是第 8 个虚函数从 0 开始算的话索引是 7这个数字在 D3D11 和 DXGI 版本迭代中相对稳定。但为了保证兼容性最好加一个运行时校验环节避免将来接口升级后失效。2.2 钩子函数实现的标准范式替换完虚函数表后你的 Present 钩子函数会接收到原本本该传给原始 Present 的所有参数。标准实现里需要先把原始函数指针保存起来static PresentFn g_pfnPresent nullptr; HRESULT STDMETHODCALLTYPE HookedPresent( IDXGISwapChain* pSwapChain, UINT SyncInterval, UINT Flags) { // 在这里执行我们的逻辑比如渲染叠加UI RenderOverlay(pSwapChain); // 必须调用原始函数否则画面会卡死 return g_pfnPresent(pSwapChain, SyncInterval, Flags); }关键点在于入参和返回值必须原样传递给原始函数绝对不能在钩子里私自吞掉调用。一旦你不调用原始的 Present渲染线程会陷入阻塞程序画面直接卡住。很多初学者第一次写 hook画面黑屏就以为是 hook 失败其实十有八九是这里处理不当。另一个细节是Present 可能在多线程环境下被调用因此你的叠加渲染逻辑要保证线程安全。最简单的方案是准备一个独立的渲染上下文在 HookedPresent 里只用它来执行绘制避免和 D3D11 原生的立即上下文产生资源竞争。2.3 叠加层渲染的正确姿势叠加层的实现基本可以复用 D3D11 的常规流程创建着色器、顶点缓冲、纹理然后在 Present 前绘制。但有几个特殊原则必须遵守不要在钩子里重新创建 D3D11 设备直接保存设备指针做复用。叠加层使用独立的深度/模板状态避免影响原程序原本的深度测试。坐标系需要自己做转换因为叠加层的坐标系和 D3D11 默认裁剪空间的坐标规则不同需要从屏幕像素坐标换算到 NDC 坐标。我以前见过一个很典型的错误有人直接在想呈现的绘制文本时调用 GDI 函数结果画面闪烁不定。原因很简单GDI 绘制和 D3D11 渲染管线操作的是完全不同的图形上下文两者在同一个 Present 环节里交叉执行就会出现同步问题。正确做法是把要显示的内容渲染到一个独立纹理或直接走 GPU 绘制管线。如果只是做帧率统计可以优先用QueryPerformanceCounter计算帧间隔再通过叠加层把数字画出来。不要用睡眠或锁去限制渲染节奏不然你 hook 的效果会变成“电脑卡顿模拟器”。3. 实操过程与核心环节实现3.1 工程初始化与编译配置开发这套代码需要准备的依赖其实非常少一个完整可编译的工程包含的信息如下开发环境Visual Studio 2019 或更高版本Windows 10/11 SDK。链接库d3d11.lib、dxgi.lib。这两个库在 Windows SDK 里自带不用额外下载。输出类型DLL。Hook 代码通常以 DLL 形式注入目标进程这样可以直接在该进程的地址空间内访问 D3D11 接口。工程需要配置 x86 和 x64 两套编译平台代码里通过_WIN64或_M_X64宏区分指针相关逻辑。我这里建议直接使用条件编译而不是依赖单独的代码文件原因后面会讲。3.2 源码走读从设备创建到挂钩以 vtable hook 为例核心步骤拆开来看就四步。第一步枚举虚函数表函数指针typedef HRESULT (STDMETHODCALLTYPE* PresentFn)(IDXGISwapChain*, UINT, UINT); PresentFn GetPresentFunction(IDXGISwapChain* pSwapChain) { // 这里通过虚函数表读取第8个函数指针 auto** pVTable reinterpret_castvoid***(pSwapChain); return reinterpret_castPresentFn(pVTable[7]); }这里之所以敢直接读取 pVTable[7]是因为 IDXGISwapChain 接口的虚函数顺序在 DXGI 规范里是公开且稳定的。如果为了稳妥你也可以在运行时比对接口名称但实际使用中直接用固定索引问题不大。第二步保存原始函数指针然后替换虚函数表g_pfnPresent GetPresentFunction(pSwapChain); // 修改虚函数表前需要先解除写保护 DWORD oldProtect; VirtualProtect(pVTable[7], sizeof(void*), PAGE_READWRITE, oldProtect); pVTable[7] reinterpret_castvoid*(HookedPresent); VirtualProtect(pVTable[7], sizeof(void*), oldProtect, oldProtect);VirtualProtect 的调用必须成对出现。修改完毕后及时恢复原来的内存保护属性否则某些安全软件可能报读写违规。这段代码在 x86 和 x64 下都能直接编译因为只是普通指针赋值。第三步完成挂钩后的清理工作释放临时创建的设备与 SwapChain。第四步在 DLL 卸载时恢复虚函数表if (g_pfnPresent) { // 同样的保护方式恢复原始函数指针 pVTable[7] reinterpret_castvoid*(g_pfnPresent); }卸载和加载的对称处理很重要能避免进程退出时因为 hook 残留导致崩溃。以我自己的工程为例完整的初始化函数长这样bool InstallHook() { HWND hWnd CreateWindowA(STATIC, temp, WS_POPUP, 0, 0, 8, 8, NULL, NULL, NULL, NULL); if (!hWnd) return false; DXGI_SWAP_CHAIN_DESC sd {}; sd.BufferCount 1; sd.BufferDesc.Format DXGI_FORMAT_R8G8B8A8_UNORM; sd.BufferDesc.Width 8; sd.BufferDesc.Height 8; sd.BufferUsage DXGI_USAGE_RENDER_TARGET_OUTPUT; sd.OutputWindow hWnd; sd.SampleDesc.Count 1; sd.Windowed TRUE; ID3D11Device* device nullptr; IDXGISwapChain* swapChain nullptr; HRESULT hr D3D11CreateDeviceAndSwapChain( nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, nullptr, 0, D3D11_SDK_VERSION, sd, swapChain, device, nullptr, nullptr); if (FAILED(hr)) return false; g_pfnPresent GetPresentFunction(swapChain); // 替换虚函数表 HookVTable(swapChain, HookedPresent); swapChain-Release(); device-Release(); DestroyWindow(hWnd); return true; }我给这段代码已经做了精简实际工程里还要考虑多份 SwapChain 的情况。不过核心思想是一致的你不需要全局替换每个实例只要替换共享虚函数表入口所有新创建的 SwapChain 就会自动走到你的钩子函数里。3.3 DLL 生命周期管理与卸载优化DLL 的DllMain是执行初始化的常见位置但千万别在里面做太多重活。Windows 加载器锁的存在意味着在DllMain里调用D3D11CreateDeviceAndSwapChain是有风险的它可能间接申请锁然后死锁。我的习惯是只创建一条后台线程来执行InstallHook主流程在 DLL 加载完成后立即返回这样既安全又高效。卸载时同理不要在DLL_PROCESS_DETACH里恢复虚函数表前调用任何 COM 接口函数只做最基础的数学判断和指针恢复。因为卸载阶段很多底层模块可能已经处于不可用状态。如果发现卸载过程中崩溃优先检查是不是在恢复 hook 的时候引用了已经释放的 COM 对象。4. 常见问题与排查技巧实录4.1 跨平台编译失败的定位思路我在多个开发者群里看到的最常见问题是直接把 x86 工程切换到 x64 编译后出现错误 LNK2001 或 C2664。这类问题的根源绝大多数是函数指针定义里没有使用合适的调用约定宏。解决方案很简单像前面代码那样在有跨平台需求的函数指针声明处统一用STDMETHODCALLTYPE。另外一个高发点是 typedef 中参数类型的 sizeof 不匹配。Hook 函数的参数如果用了uint32_t这种定长类型一般问题不大一旦有人图省事写成unsigned long就完蛋了在 Windows 上 unsigned long 在 x86 下是 4 字节在 x64 下同样遵循平台宽度规则极易引发栈不平衡和崩溃。建议的排查顺序是先看编译错误名单里有没有被质疑为“重新定义”或“转换丢失”的报错再查有没有混用不同宽度的指针类型最后看所有 Hook 函数是不是都加了明确的调用约定。4.2 画面异常与进程崩溃处理黑屏的排查方向通常集中在几个点是否在 HookedPresent 里执行了过于耗时的操作导致渲染线程超时。是否使用了会在渲染线程里阻塞的信号量、锁等同步机制。是否在钩子中创建或销毁了 D3D11 资源但没有同步刷新管线状态。闪退的排查方向则多在权限和内存保护上。比如没有调用 VirtualProtect 就修改虚函数表在 x64 系统上通常直接触发访问冲突又比如 DLL 注入后立刻被系统卸载而钩子代码还在执行等到下一次 Present 进来就会跳转到已经被释放的地址。还有一个高频问题某些程序会创建多个 SwapChain比如编辑器窗口、多显示器渲染窗口。如果你只替换了其中一个 SwapChain 的虚函数表虽然共享虚表让多数情况下没问题但如果某窗口用了独立的 DXGI 设备就需要重新定位和替换。稳妥的做法是在检测到新 SwapChain 时逐个校验并完成 hook。4.3 我的多环境实测结果在这套源码完成后我专门做了三组环境的验证分别是环境x86 进程x64 进程连续运行表现普通 Win32 应用通过通过稳定运行叠加层无闪烁大型 3D 应用通过通过偶发掉帧但无崩溃沙盒测试进程通过通过反复加载/卸载 50 次无异常实测下来vtable 方案在跨架构场景下确实比 inline hook 省心很多唯一的代价是它对接口版本变更敏感。针对这一点我强烈建议代码里保留一个环境自检函数启动时对虚函数表索引位置验证一次如果不符合预期就及时放弃 hook避免引发更严重的问题。5. 扩展方向与合规使用建议5.1 从演示走向实用功能的扩展路径如果这套代码还满足不了你的需求可以考虑往几个方向扩展支持 Present1 或其他 DXGI 接口、接入 ImGui 绘制叠加层、把采集到的画面编码成视频流、增加多线程渲染队列等。从我自身的实践来看只要底层的 hook 框架稳定上层功能扩展的边际成本其实很低。例如要接入 ImGui只需要在 HookedPresent 里初始化 ImGui 上下文并设置好 D3D11 的 render target。之后再调用ImGui::Render()以及对应的绘制命令即可。ImGui 内置了字体纹理和顶点缓冲区管理省去不少手工造轮子的时间非常适合快速搭建调试 UI。如果你关注的是画面采集那么在 HookedPresent 里拿到后备缓冲区之后通过CopyResource复制一份到离屏纹理再交给编码器做硬件加速编码就能做到低开销的录屏。这里要注意的是离屏纹理和交换链纹理格式保持一致否则复制时会因为不兼容格式报错。5.2 合规使用的边界与原则HOOK 技术本质上是一种与系统内部机制打交道的强手段做技术验证和学习完全没问题但千万别把它用在破坏别人软件正常使用、绕过授权机制、篡改在线服务等场景里。实际上D3D11 HOOK 在图形调试、无障碍辅助增强、直播工具、教学演示这些领域都有正当需求这也是很多商业软件一直在用的底层方案。我始终觉得一项技术本身没有绝对的好与坏关键看你怎么用。你在写这类代码时最好保持清晰的边界意识只在你拥有权限或获得充分授权的进程里做验证不要试图去破坏系统的完整性不要碰任何与盗号、私服、自动化脚本相关的灰色地带。把这些底线守住了你研究多少层都算安全的技术积累否则再牛的 hook 框架也只会给自己惹麻烦。最后再分享一个小技巧如果你只是想在正式工程里快速加一个 FPS 显示或延迟监控不一定要走完整的 hook 框架可以先从交换链统计信息接口入手把IDXGISwapChain::GetFrameStatistics的返回值定时读取出来渲染成文字即可。这样既避开了复杂的 hook 逻辑又保证了稳定性等你确定确实需要更底层介入时再迁移到这套框架上也不迟。本文还有配套的精品资源点击获取
返回列表