通过劫持EndScene实现菜单绘制

在DirectX 9环境中,菜单绘制是图形应用程序中一个常见且重要的功能。相比于使用D3DDrawLib外部绘图库绘制图形的不稳定和高资源占用率来说,内部绘制方法提供了更为流程化和稳定的解决方案。本文将介绍如何通过挂钩IDirect3DDevice9接口中的EndScene函数,来实现一个内部D3D菜单的绘制案例。

EndScene函数在DirectX 9中扮演着重要角色,它主要用于实现最终的图像渲染及展现功能,这些操作包括清空缓冲区中的图像、设置视口及其他渲染状态、执行顶点和像素着色器,最终在后台缓冲区生成完整的渲染图像并将其呈现到屏幕上,从而完成一次绘制操作。

若要实现在指定进程内部增加菜单的功能,则需要对EndScene函数进行Hook挂钩,EndScene是IDirect3DDevice9接口中的第43个函数,读者可自行打开d3d9.h头文件信息,并将光标移动到473行,此处就是我们需要挂钩的函数。

20240811133058

首先我们需要编写一段可以任意地址Hook的通用代码,此类代码的实现市面上也有很多可供参考的案例,当然若不想自己实现Hook函数的编写,也可以使用MinHookDetoursEasyHookFrida等第三方类库,此处我们就使用一段简单的代码来代替如下代码所示。

挂钩与摘钩

首先定义一个JmpCode结构体,以32位为例,这个 JmpCode 结构体用于创建一个相对跳转(JMP)指令。它包含一个 jmp 字节(值为0xE9)和一个 address 字段,该字段存储计算得到的跳转偏移量。构造函数接受源地址和目标地址,并通过调用 setAddress 方法计算并设置跳转偏移量,从而实现函数挂钩或代码重定向的功能。

它包括两个版本,分别适用于32位和64位系统:

32位系统版本:

  • 使用一个字节的 jmp 指令(值为 0xE9)和一个32位的地址偏移量。
  • 构造函数通过计算目标地址与源地址之间的偏移量来设置跳转地址。

64位系统版本:

  • 使用 FF 25 00 00 00 00 的跳转指令前缀和一个64位的绝对地址。
  • 构造函数直接设置目标地址。

接着定义了两个函数,其中hook用于挂钩,而unhook则用于摘除钩子,以挂钩函数为例,在其内部首先通过调用JmpCode结构体生成一个相对跳转的汇编指令集,接着通过使用VirtualProtect将这段内存设置为读写属性,并将机器码拷贝到其中替换,当替换后再次调用VirtualProtect恢复内存属性,取消Hook的原理类似。

  • hook函数:将指定的原始函数替换为自定义函数,并保存原始函数的代码,以便后续取消挂钩。
  • unhook函数:恢复原始函数的代码,取消自定义函数的挂钩。

总结起来的代码如下所示,其中包含了所需调用的头文件信息;

#include <Windows.h>
#include <d3dx9.h>
#include <tchar.h>
#include <iostream>

#pragma comment(lib,"d3d9.lib")
#pragma comment(lib,"d3dx9.lib")

#pragma pack(push)
#pragma pack(1)
#ifndef _WIN64
struct JmpCode
{
private:
const BYTE jmp;
DWORD address;

public:
JmpCode(DWORD srcAddr, DWORD dstAddr) : jmp(0xE9)
{
setAddress(srcAddr, dstAddr);
}

void setAddress(DWORD srcAddr, DWORD dstAddr)
{
address = dstAddr - srcAddr - sizeof(JmpCode);
}
};
#else
struct JmpCode
{
private:
BYTE jmp[6];
uintptr_t address;

public:
JmpCode(uintptr_t srcAddr, uintptr_t dstAddr)
{
static const BYTE JMP[] = { 0xFF, 0x25, 0x00, 0x00, 0x00, 0x00 };
memcpy(jmp, JMP, sizeof(jmp));
setAddress(srcAddr, dstAddr);
}

void setAddress(uintptr_t srcAddr, uintptr_t dstAddr)
{
address = dstAddr;
}
};
#endif
#pragma pack(pop)

// ---------------------------------------------------------------------------------
// Hook挂钩函数
// ---------------------------------------------------------------------------------

// 开始Hook
int hook(void* originalFunction, void* hookFunction, BYTE* oldCode)
{
JmpCode code((uintptr_t)originalFunction, (uintptr_t)hookFunction);
DWORD oldProtect, oldProtect2;

// 设置内存保护方式为可读写
if (VirtualProtect(originalFunction, sizeof(code), PAGE_EXECUTE_READWRITE, &oldProtect))
{
memcpy(oldCode, originalFunction, sizeof(code));
memcpy(originalFunction, &code, sizeof(code));

// 恢复内存保护方式
if (VirtualProtect(originalFunction, sizeof(code), oldProtect, &oldProtect2))
{
return 1;
}
}
return 0;
}

// 取消Hook
int unhook(void* originalFunction, BYTE* oldCode)
{
DWORD oldProtect, oldProtect2;

// 设置保护方式为可读写
if (VirtualProtect(originalFunction, sizeof(JmpCode), PAGE_EXECUTE_READWRITE, &oldProtect))
{
memcpy(originalFunction, oldCode, sizeof(JmpCode));

// 恢复内存保护方式
if (VirtualProtect(originalFunction, sizeof(JmpCode), oldProtect, &oldProtect2))
{
return 1;
}
}
return 0;
}

全局变量配置

接着我们继续来定义所需变量,如下代码所示,代码中定义了一些全局变量和结构,用于实现自绘菜单和Direct3D的函数劫持。具体包括:

  • Direct3D相关变量:如endSceneAddr(EndScene函数地址)、g_font(字体对象)、d3dLine(线条对象)等。
  • 键盘输入相关变量:定义了获取按键状态的函数指针pfnGetAsyncKeyState
  • 菜单相关变量:包括窗口宽高(WindowWidthWindowHeight)、当前选中的菜单项(CurrentlySelected)、菜单显示状态(IsItDisplayed)、菜单显示位置(MenuOnTheRight)和各功能项的状态(如功能项A功能项G)。

这些变量和结构为后续的菜单显示、键盘输入处理以及Direct3D渲染劫持提供了基础。

// ---------------------------------------------------
// 键盘指针与D3D配置变量
// ---------------------------------------------------

void* endSceneAddr = NULL;
BYTE endSceneOldCode[sizeof(JmpCode)];

// 定义获取按键码结构
typedef short(WINAPI *fnGetAsyncKeyStateAddress)(_In_ int Key);

// 获取按键码指针
fnGetAsyncKeyStateAddress pfnGetAsyncKeyState = NULL;

ID3DXFont* g_font = NULL;
ID3DXLine* d3dLine = NULL;

// ---------------------------------------------------
// 菜单全局变量
// ---------------------------------------------------

// 窗体的宽度及高度
int WindowWidth = 1024;
int WindowHeight = 768;

// 当前选中
static int CurrentlySelected = 1;

// 控制是否显示菜单
bool IsItDisplayed = true;

// 菜单居左还是居右显示
bool MenuOnTheRight = false;

// 功能项选中状态
bool 功能项A = false;
bool 功能项B = false;
bool 功能项C = false;
bool 功能项D = false;
bool 功能项E = false;
bool 功能项F = false;
bool 功能项G = false;

键盘回调事件

键盘回调部分,我们通过在入口处使用GetProcAddress动态获取到GetAsyncKeyState函数状态,该函数可用于监控键盘热键,得到地址后放入到pfnGetAsyncKeyState全局变量中存储,通过CreateThread创建一个CheckKey线程,该线程中用于监控热键是否为,VK_UP、VK_DOWN、VK_LEFT、VK_RIGHT、以及VK_HOME热键,并根据不同的热键做出对选中菜单的调整,其代码如下所示;

// ---------------------------------------------------------------------------------
// 按键回调事件
// ---------------------------------------------------------------------------------

// 按键监视器
DWORD WINAPI CheckKey(LPVOID dllMainThread)
{
while (1)
{
// 上光标
if (pfnGetAsyncKeyState(VK_UP))
{
// CurrentlySelected是7 | 因为我们有7个功能
CurrentlySelected = CurrentlySelected - 1;
if (CurrentlySelected == 0)
{
CurrentlySelected = 7;
}
}

// 下光标
if (pfnGetAsyncKeyState(VK_DOWN))
{
CurrentlySelected = CurrentlySelected + 1;
if (CurrentlySelected == 8)
{
CurrentlySelected = 1;
}
}
// 左光标
if (pfnGetAsyncKeyState(VK_LEFT))
{
if (CurrentlySelected == 1)
{
功能项A = false;
}
else if (CurrentlySelected == 2)
{
功能项B = false;
}
else if (CurrentlySelected == 3)
{
功能项C = false;
}
else if (CurrentlySelected == 4)
{
功能项D = false;
}
else if (CurrentlySelected == 5)
{
功能项E = false;
}
else if (CurrentlySelected == 6)
{
功能项F = false;
}
else if (CurrentlySelected == 7)
{
功能项G = false;
}
}
// 右光标
if (pfnGetAsyncKeyState(VK_RIGHT))
{
if (CurrentlySelected == 1)
{
功能项A = true;
}
else if (CurrentlySelected == 2)
{
功能项B = true;
}
else if (CurrentlySelected == 3)
{
功能项C = true;
}
else if (CurrentlySelected == 4)
{
功能项D = true;
}
else if (CurrentlySelected == 5)
{
功能项E = true;
}
else if (CurrentlySelected == 6)
{
功能项F = true;
}
else if (CurrentlySelected == 7)
{
功能项G = true;
}
}
// 菜单键
if (pfnGetAsyncKeyState(VK_HOME))
{
if (IsItDisplayed == true)
{
IsItDisplayed = false;
}
else if (IsItDisplayed == false)
{
IsItDisplayed = true;
}
}

Sleep(300);
}
return 1;
}

动态绘图函数

绘制部分函数如下所示,其中包括绘制线条、方框、空心矩形、单行文本和双重文本。

  • DrawLine:函数用于绘制指定宽度和颜色的线条;
  • DrawBox:函数绘制一个指定位置、宽度、高度和颜色的方框;
  • DrawBorderRectangle:函数绘制一个空心矩形,通过绘制四条边线来实现;
  • DrawString:函数在指定位置绘制单行文本,使用指定的字体大小和颜色;
  • DrawStringAndString:函数则在指定位置绘制双重文本,包括提示文字和命令选择,分别使用不同的颜色。

这些函数利用了Direct3D的绘图功能,通过调用Direct3D的API实现各种图形和文本的绘制,其代码如下所示;

// ---------------------------------------------------------------------------------
// 绘图函数
// ---------------------------------------------------------------------------------

// 绘制线条
void DrawLine(float x, float y, float x2, float y2, float width, D3DCOLOR color)
{
D3DXVECTOR2 dLine[2];

d3dLine->SetWidth(width);

dLine[0].x = x;
dLine[0].y = y;

dLine[1].x = x2;
dLine[1].y = y2;

d3dLine->Draw(dLine, 2, color);
}

// 绘制方框
void DrawBox(float x, float y, float width, float height, float w, D3DCOLOR color)
{
D3DXVECTOR2 points[5];
points[0] = D3DXVECTOR2(x, y);
points[1] = D3DXVECTOR2(x + width, y);
points[2] = D3DXVECTOR2(x + width, y + height);
points[3] = D3DXVECTOR2(x, y + height);
points[4] = D3DXVECTOR2(x, y);
d3dLine->SetWidth(w);
d3dLine->Draw(points, 5, color);
}

// 绘制空心矩形
void DrawBorderRectangle(float x, float y, float w, float h, float width, D3DCOLOR color)
{
DrawLine(x, y - 1, x + w, y - 1, width, color);
DrawLine(x - 1, y, x - 1, y + h, width, color);
DrawLine(x + w, y, x + w, y + h, width, color);
DrawLine(x, y + h - 1, x + w, y + h - 1, width, color);
}

// 绘制文字
void DrawString(const char* fmt, int fontsize, int x, int y, D3DCOLOR color)
{
RECT FontPos;
FontPos.left = x;
FontPos.top = y;

CHAR buf[124] = { '\0' };
sprintf(buf, "%s", fmt);
g_font->DrawTextA(0, buf, -1, &FontPos, DT_NOCLIP, color);
}

// 绘制双重文字
void DrawStringAndString(const char* str, const char* fmt, int offset, int x, int y, D3DCOLOR colorA, D3DCOLOR colorB)
{
RECT FontPos, fmtPos;
FontPos.left = x;
FontPos.top = y;

CHAR format[128] = { '\0' };
CHAR string[128] = { '\0' };

// 文字提示
sprintf(format, "%s", str);
g_font->DrawTextA(0, format, -1, &FontPos, DT_NOCLIP, colorA);

// 命令选择
fmtPos.left = x + offset;
fmtPos.top = y;

sprintf(string, "%s", fmt);
g_font->DrawTextA(0, string, -1, &fmtPos, DT_NOCLIP, colorB);
}

当有了上述函数的封装,那么绘制一个菜单将变得非常容易,如下DrawArea函数负责绘制一个自定义的菜单界面,包括菜单的边框、表头、功能项及其状态、选择条和底部的显示/隐藏按钮。通过判断IsItDisplayed变量是否为真来决定是否显示菜单,并根据预设的菜单配置(如宽度、高度、颜色等)动态计算和绘制各个元素的位置和样式。菜单功能项的名称和状态存储在数组中,并通过遍历这些数组来绘制各个功能项及其当前状态。

// ---------------------------------------------------------------------------------
// 菜单绘制区域
// ---------------------------------------------------------------------------------

VOID DrawArea()
{
if (IsItDisplayed)
{
// 菜单配置
const int MenuWidth = 120; // 菜单宽度
const int ItemHeight = 25; // 每个功能项的高度
const int ExtraPadding = 10; // 顶部额外间距
const int BottomPadding = 0; // 底部额外间距(用于显示/隐藏按钮)
const int HeaderHeight = 20; // 表头高度

// 配置着色表
const D3DCOLOR BorderColor = D3DCOLOR_XRGB(255, 69, 0);
const D3DCOLOR HighlightColor = D3DCOLOR_XRGB(148, 0, 211);
const D3DCOLOR EnabledColor = D3DCOLOR_XRGB(0, 255, 0);
const D3DCOLOR DisabledColor = D3DCOLOR_XRGB(255, 250, 250);
const D3DCOLOR HeaderColor = D3DCOLOR_XRGB(255, 69, 0);

// 菜单位置(居左或者居右)
int MenuLeft = MenuOnTheRight ? WindowWidth - MenuWidth - WindowWidth / 30 : WindowWidth / 30;

// 菜单顶部位置
int MenuTop = WindowHeight / 3;

// 菜单名称
const char* itemNames[] = {
"功能项A",
"功能项B",
"功能项C",
"功能项D",
"功能项E",
"功能项F",
"功能项G"
};

// 菜单开关状态
bool* itemStates[] = {
&功能项A,
&功能项B,
&功能项C,
&功能项D,
&功能项E,
&功能项F,
&功能项G
};

// 计算功能项数量
const int itemCount = sizeof(itemNames) / sizeof(itemNames[0]); // 动态计算功能项数量
int TotalHeight = ExtraPadding + (itemCount * ItemHeight) + BottomPadding + HeaderHeight; // 计算总高度

// 绘制外边框
DrawBorderRectangle(
MenuLeft,
MenuTop,
MenuWidth,
TotalHeight,
1,
BorderColor
);

// 绘制表头矩形
DrawBorderRectangle(
MenuLeft,
MenuTop,
MenuWidth,
HeaderHeight,
1,
HeaderColor
);

// 计算表头文字的宽度
const char* headerText = "简单自绘菜单";

// 假设获取文本宽度
int headerTextWidth = 0;

// 这里我们假设每个字符宽度为7个像素
headerTextWidth = strlen(headerText) * 7;

// 居中绘制表头文字
int headerTextX = MenuLeft + 7 + (MenuWidth - headerTextWidth) / 2; // 计算居中的X坐标
DrawString(headerText, 12, headerTextX, MenuTop + 2, D3DCOLOR_XRGB(0, 0, 139)); // 蓝色文字

// 绘制选择条
DrawBorderRectangle(
MenuLeft + 5,
MenuTop + 9 + HeaderHeight + (CurrentlySelected - 1) * ItemHeight,
MenuWidth - 10,
17,
1,
HighlightColor
);

// 绘制功能项
for (int i = 0; i < itemCount; ++i)
{
DrawStringAndString(
itemNames[i],
(*itemStates[i]) ? "[开启]" : "[关闭]",
65,
MenuLeft + 10,
MenuTop + ExtraPadding + HeaderHeight + i * ItemHeight,
D3DCOLOR_XRGB(255, 52, 179),
(*itemStates[i]) ? EnabledColor : DisabledColor
);
}

// 绘制显示/隐藏按钮,位于矩形外部底部
DrawString("HOME 显示/隐藏", 13, MenuLeft + 12, MenuTop + TotalHeight + 5, D3DCOLOR_RGBA(255, 0, 0, 255));
}
}

自定义EndScene方法

EndScene 方法是IDirect3DDevice9接口中的重要成员,它是每帧渲染循环中的一个关键步骤,通常与BeginScenePresent一起使用。该方法用于通知Direct3D设备当前的绘制操作已经结束,并准备将这些绘制操作提交到渲染管线进行处理。

由于EndScene是一个在每帧渲染时都会调用的方法,因此它成为了许多自定义UI绘制的理想挂钩点。通过挂钩EndScene,开发者可以在游戏或应用程序的每一帧渲染完毕之前插入自定义的绘制代码,并以此来实现图形功能的扩展,如下代码中我们在每次渲染之前插入DrawArea来实现自定义菜单的绘制。

// 自定义转向函数
HRESULT STDMETHODCALLTYPE MyEndScene(IDirect3DDevice9* thiz)
{
// 如果是第一次则初始化绘图库
if (g_font == NULL)
{
// 初始化字体
D3DXCreateFontA(thiz, 12, 0, FW_HEAVY, 1, 0, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, ANTIALIASED_QUALITY, DEFAULT_PITCH | FF_DONTCARE, "宋体", &g_font);

// 线条初始化线条
D3DXCreateLine(thiz, &d3dLine);

unhook(endSceneAddr, endSceneOldCode);
HRESULT hr = thiz->EndScene();
hook(endSceneAddr, MyEndScene, endSceneOldCode);
return hr;
}

// 不是第一次则直接绘图
else
{
// 绘制菜单
DrawArea();

// 恢复钩子
unhook(endSceneAddr, endSceneOldCode);

// 执行原函数
HRESULT hr = thiz->EndScene();

// 挂钩
hook(endSceneAddr, MyEndScene, endSceneOldCode);
return hr;
}
}

初始化与开始绘制

通过调用initHookThread初始化一个挂钩线程,用于劫持Direct3DEndScene方法。具体操作包括注册一个窗口类并创建窗口,然后初始化Direct3D设备,并通过GetVtableFunAddr来获取设备的虚函数表地址,并以此来定位EndScene函数,对其进行劫持。成功劫持后,释放Direct3D对象并销毁窗口。

// 获取虚函数地址
uintptr_t GetVtableFunAddr(void* pObj, int index)
{
uintptr_t* pAddr = NULL;
pAddr = reinterpret_cast<uintptr_t*>(pObj);

// 获取虚函数表指针
pAddr = (uintptr_t*)*pAddr;
return pAddr[index];
}

// 初始化Hook线程
DWORD WINAPI initHookThread(LPVOID dllMainThread)
{
WaitForSingleObject(dllMainThread, INFINITE);
CloseHandle(dllMainThread);

WNDCLASSEX wc = {};
wc.cbSize = sizeof(wc);
wc.style = CS_OWNDC;
wc.hInstance = GetModuleHandle(NULL);
wc.lpfnWndProc = DefWindowProc;
wc.lpszClassName = _T("LySharkWindow");

// 注册窗口类
if (RegisterClassEx(&wc) == 0)
{
return 0;
}

// 创建窗口
HWND hwnd = CreateWindowEx(0, wc.lpszClassName, _T(""), WS_OVERLAPPEDWINDOW, 0, 0, 640, 480, NULL, NULL, wc.hInstance, NULL);
if (hwnd == NULL)
{
return 0;
}

// 初始化D3D
IDirect3D9* d3d9 = Direct3DCreate9(D3D_SDK_VERSION);
if (d3d9 == NULL)
{
DestroyWindow(hwnd);
return 0;
}

D3DPRESENT_PARAMETERS pp = {};
pp.Windowed = TRUE;
pp.SwapEffect = D3DSWAPEFFECT_COPY;

// 创建设备
IDirect3DDevice9* device;
if (FAILED(d3d9->CreateDevice(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hwnd, D3DCREATE_SOFTWARE_VERTEXPROCESSING, &pp, &device)))
{
d3d9->Release();
DestroyWindow(hwnd);
return 0;
}

// 开始劫持 EndScene
// EndScene是IDirect3DDevice9第43个函数
// endSceneAddr = (*(void***)device)[42];
endSceneAddr = (VOID *)GetVtableFunAddr(device, 42);

hook(endSceneAddr, MyEndScene, endSceneOldCode);

// 释放
d3d9->Release();
device->Release();
DestroyWindow(hwnd);
return 0;
}

在主函数中,我们通过GetProcAddress获取到GetAsyncKeyState键盘监控函数地址,并创建CheckKey用于监控热键,通过创建initHookThread初始化挂钩函数,则此时挂钩已实现。

// ---------------------------------------------------------------------------------
// DLL程序入口
// ---------------------------------------------------------------------------------
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
{
HANDLE curThread;

// 获取当前线程ID
if (!DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), GetCurrentProcess(), &curThread, SYNCHRONIZE, FALSE, 0))
{
return FALSE;
}

// 监视热键
pfnGetAsyncKeyState = (fnGetAsyncKeyStateAddress)GetProcAddress(GetModuleHandleA("user32.dll"), "GetAsyncKeyState");

// 创建一个子线程专用于监控热键
CreateThread(NULL, 0, CheckKey, curThread, 0, NULL);

// Dll中不能使用COM组件,需要另外开启线程
CloseHandle(CreateThread(NULL, 0, initHookThread, curThread, 0, NULL));
break;
}
case DLL_PROCESS_DETACH:
if (endSceneAddr != NULL)
{
unhook(endSceneAddr, endSceneOldCode);
}
break;
}
return TRUE;
}

读者可自行组合上述代码片段,并将其编译为DLL动态链接库文件,通过使用LyInjector注入器或使用其他通用注入工具将模块注入到窗体中,如下所示;

C:\> LyInjector InjectDLL --proc lyshark.exe --dll hook.dll
[*] 模块 [ hook.dll ] 已被注入到 [ 11228 ] 进程

当模块被注入后,原EndScene函数将被替换,此时每当进程调用EndScene绘制函数时都会经过MyEndScene自定义函数。

20240811163318

在自定义函数中我们增加DrawArea函数,当执行结束后在让其恢复到EndScene原函数上继续执行,如下所示;

20240811163238

挂钩后的绘制效果如下图所示,菜单将被显示出来;

20240811163526

警告:本篇文章中所涉及的内容仅用于技术交流与研究之用,仅允许被用于正规用途或学习目的,请读者自觉遵守相关法规,禁止滥用。