首先,我们先看下一些法律基础,以确保大家是在学习插件,而不是外挂。
一个游戏插件的发布,前提是要得到游戏开发或者发行商的许可(或者有利游戏方不会封禁,比如直播软件),其中包括了功能会不会破环平衡性的讨论和达成共识。
当然,今天这个快速扫盲版的方法不会提及绕过anti-cheat,而是假设了已告知anti-cheat我们不会干坏事,在允许的范围下做事情。
那么,我们开始今天的旅程。
一、选择开源软件学习在游戏上显示内容
刚才我们学习了一下法律小基础,里面提到了一个tricky的地方是可以有利游戏方不会封禁,这个是因为软件安装在用户的电脑上,用户有一定权力去确保自己的系统运行在自己的方式上。比如我想直播一个游戏,除了游戏画面,我想放一些文字比如战绩比如广告,然后推送流(直播软件无法一个一个和游戏方谈判的,但是直播有利于更多玩家进入所以游戏方默许;再一个例子是杀毒软件,因为kernel driver的控制权问题,游戏制作发行方也不会太深究)。这里当然方法很多。比如可以使用录屏然后后期处理加上各种特效显示文字,这样直播人看不到这些特效;所以还有一种方法是注入一些代码到游戏程序上,然后画出想显示的内容,这样在直播的时候,播主可以看到内容,观众也可以看到,就比较方便了。
其实有很多方法显示,我们就快速过一下常用的两种方式。
第一种就是非侵入式的,但是对游戏的设置有要求。很简单,就是画个窗口显示内容,然后保证这个窗口top-most在所有窗口最前面显示就好。所以这个方法的好处就是,不会修改游戏的任何地方就能显示内容,坏处是游戏必须处于非全屏独占模式(无边框模式或者窗口模式),不然top-most是不生效的。github上也有一大把repo,比如 https://github.com/lolp1/Overlay.NET 当然overlay.net稍微和我们说的有点差别,它是创建一个窗口,然后把窗口塞到目标上去。
关键代码比如DirectX下,CreateWindow 再 DwmExtendFrameIntoClientArea 就能实现这部分功能了
......
private bool CreateWindow() {
Handle = Native.CreateWindowEx(
WindowConstants.WindowExStyleDx,
WindowConstants.DesktopClass,
"",
WindowConstants.WindowStyleDx,
X,
Y,
Width,
Height,
IntPtr.Zero,
IntPtr.Zero,
IntPtr.Zero,
IntPtr.Zero);
if (Handle == IntPtr.Zero) {
return false;
}
Native.SetLayeredWindowAttributes(Handle, 0, 255, WindowConstants.LwaAlpha);
......
private void ExtendFrameIntoClient() {
_margin.cxLeftWidth = X;
_margin.cxRightWidth = Width;
_margin.cyBottomHeight = Height;
_margin.cyTopHeight = Y;
Native.DwmExtendFrameIntoClientArea(Handle, ref _margin);
}
第二种就是通过注入DLL的方式,hook到游戏的画面刷新函数,因为像windows游戏底层最终要么是DirectX要么是GL,很少会有开发商比出新裁自己开发底层库,所以函数就那么几个系列。所以,这里我们可以学习有名的直播软件OBS的代码 https://github.com/obsproject/obs-studio
首先简单介绍下DLL注入,这个其实就是柔和版的shellcode,注入一个自己的DLL到一个进程里,这样可以触发DLL的加载,从而在进程内部运行一次初始化DLL的代码,这样就可以在知道函数偏移以后,替换成自己的函数,达到hook的目的。如何知道函数偏移这个我们后面第二部分再说。不过,这里还要提一嘴,这个DLL的注入一般都是被anti-cheat监控的,所以并不是100%成功,当然一般没必要封死,毕竟还有诸如直播软件要用。
我们直接来看看obs干了啥吧,在这个程序里就是先 load_deubg_privilege,这个是提权操作,确保最大成功率;当然,这个在driver面前就是个0,这个我们也后面再说。接着就执行了 inject_helper。逆向嘛,也就是一堆压缩扭曲的源代码,麻烦点但是也是和代码一样追,我们看源代码多舒服(懒腰)…然后继续追着inject_helper 里面有两种方法 inject_library_obf 和 inject_library_safe_obf
int main(void)
{
wchar_t dll_path[MAX_PATH];
LPWSTR pCommandLineW;
int argc;
LPWSTR *argv;
int ret = INJECT_ERROR_INVALID_PARAMS;
SetErrorMode(SEM_FAILCRITICALERRORS);
load_debug_privilege();
pCommandLineW = GetCommandLineW();
argv = CommandLineToArgvW(pCommandLineW, &argc);
if (argv) {
if (argc == 4) {
if (GetModuleFileNameW(NULL, dll_path, MAX_PATH))
ret = inject_helper(argv, argv[1]);
}
LocalFree(argv);
}
return ret;
}
这里我就不贴一长串代码了,这两函数就写在一个文件里:https://github.com/obsproject/obs-studio/blob/master/plugins/win-capture/inject-library.c
显然,为何 inject_library_safe_obf 叫safe,那是因为完全没有直接WriteProcessMemory硬核操作,safe的方式就是我使用windows api给进程的主线程注册一个事件callback,这样线程比如是待窗口的,那么除了它会自动加载你的DLL到进程里,还可以截获诸如鼠标键盘操作的事件;这个方法注册完回调,PostThreadMessage 触发一下,确保DLL在进程中加载;当然你看到了RETRY,那是因为这个是走windows的消息队列,注册表里默认10000的长度,万一丢包了可以重试增加成功率;这个方法也得看bit的,一般32位注入32位程序,64位注入64位的,所以这个程序就会编译成俩。另一个方法,就是硬核写入进程内存,十分粗鲁,大家自己看吧 create_remote_thread 是启动DLL的重点。另外提一嘴,想学习更多注入方法,可以看看 https://github.com/vinjn/injector 方法还蛮多的。
之后我们可以参考如何实现 hook 从而在游戏画面中展示我们想要画出的内容。举起一个栗子:https://github.com/obsproject/obs-studio/blob/master/plugins/win-capture/graphics-hook/d3d9-capture.cpp 我们就不管它是如何初始化和如何得到DirectX的画面要去推流的,我们看看OBS如何hook画面。 里面有个 manually_get_d3d9_addrs 函数,这个其实是如何得到DirectX的渲染关键函数,它拿到了vtable,然后使用特定index定位函数。之后就是替换这个函数,换成自己的,然后就可以在游戏内容绘制以后画你的内容,后面就是DirectX的知识了,好了,可以去学习imgui画界面了…
bool hook_d3d9(void)
{
HMODULE d3d9_module = get_system_module("d3d9.dll");
uint32_t d3d9_size;
void *present_addr = nullptr;
void *present_ex_addr = nullptr;
void *present_swap_addr = nullptr;
if (!d3d9_module) {
return false;
}
d3d9_size = module_size(d3d9_module);
if (global_hook_info->offsets.d3d9.present < d3d9_size &&
global_hook_info->offsets.d3d9.present_ex < d3d9_size &&
global_hook_info->offsets.d3d9.present_swap < d3d9_size) {
present_addr = get_offset_addr(
d3d9_module, global_hook_info->offsets.d3d9.present);
present_ex_addr = get_offset_addr(
d3d9_module, global_hook_info->offsets.d3d9.present_ex);
present_swap_addr = get_offset_addr(
d3d9_module,
global_hook_info->offsets.d3d9.present_swap);
} else {
if (!dummy_window) {
return false;
}
if (!manually_get_d3d9_addrs(d3d9_module, &present_addr,
&present_ex_addr,
&present_swap_addr)) {
hlog("Failed to get D3D9 values");
return true;
}
}
if (!present_addr && !present_ex_addr && !present_swap_addr) {
hlog("Invalid D3D9 values");
return true;
}
DetourTransactionBegin();
if (present_swap_addr) {
RealPresentSwap = (present_swap_t)present_swap_addr;
DetourAttach((PVOID *)&RealPresentSwap, hook_present_swap);
}
if (present_ex_addr) {
RealPresentEx = (present_ex_t)present_ex_addr;
DetourAttach((PVOID *)&RealPresentEx, hook_present_ex);
}
if (present_addr) {
RealPresent = (present_t)present_addr;
DetourAttach((PVOID *)&RealPresent, hook_present);
}
const LONG error = DetourTransactionCommit();
const bool success = error == NO_ERROR;
if (success) {
if (RealPresentSwap)
hlog("Hooked IDirect3DSwapChain9::Present");
if (RealPresentEx)
hlog("Hooked IDirect3DDevice9Ex::PresentEx");
if (RealPresent)
hlog("Hooked IDirect3DDevice9::Present");
hlog("Hooked D3D9");
} else {
RealPresentSwap = nullptr;
RealPresentEx = nullptr;
RealPresent = nullptr;
hlog("Failed to attach Detours hook: %ld", error);
}
return success;
}
好,我们稍微展开下那个vtable,这个其实是个编译器知识,
struct {
int var;
void (*fn)();
} *a;
简化点,就是编译一般是顺序放数据结构的,如果是32位的,在对齐的情况我们把a看成一个void*数组,a[0]对应var,a[1]对应fn的函数指针。
二、如何找到游戏中的数据
这个问题其实我们可以从单机游戏开始,尤其是没有压缩的保存文件。和上面稍微展开的vtable类似,当内存中的数据要存储到硬盘上的时候,一般就是直接serialize,所以可以很好的去分析保存文件学习内存中的样子。这里有一个分析仙剑本地保存文件的帖子 https://blog.csdn.net/prog_6103/article/details/6604276 这个只要多看看,自然就熟了;原来的金山游侠 fps2000旧时代的内存搜索或者cheat engine新时代的替代搜索内存就相当于这样在文件里找数据。
为了找到数据的位置,我们需要知道进程中任意内存中的内容,这个就是逆向的重点了。为了让它足够简单,我们会合理运用前一部分的知识。
单机游戏不会那么追求保护,所以可以先从单机游戏学习起来;一般刚才谈到的金山 fps ce直接搜索诸如血量之类的数值,就能找到那个位置。或者可以直接把相关游戏的exe dll拖到IDA里,找到写数据的位置。一个简单的例子,扫雷程序很简单,我们可以想想如果你写个程序会如何生成雷,应该是会用到随机数之类的函数,比如rand,所以扫雷exe拖到IDA里,找到call rand,顺藤摸瓜,就可以找到雷会存储到内存的哪个位置了。一般全局变量的地址都是固定的,这个就很好处理,写起插件来,只要能注入然后读取那个固定地址就好了。如果对于局部变量,一般它会存在heap堆里,遍历起来慢慢搜索;也有存在stack栈里的,比如一个函数while死循环就可以有一些变量在stack里,这个时候得先拿到进程PEB或者windows api可以得到模块的基础地址,exe其实也是一个模块,也有基址,然后找一些有特征的点,再计算这个点和数据的偏移,甚至hook一些必要的函数,让函数调用的时候通知出来也是一种方法。
网络游戏的保护很重要,它确保了游戏正常公平运营。所以才有了跌宕起伏的游戏和外挂激战。一般游戏发布的时候exe dll都是要加壳的,anti-cheat都是会要kernel driver保护的。在和游戏开发和运营商谈拢后,比如插件可以被允许读取游戏数据,然后分析再给玩家出报告,让玩家打得更好,这个就是增加游戏的retention,有成就感就能一直打这个游戏。那么这个时候需要突破一些限制。
首先静态分析是比较复杂的,因为加壳了,所以脱壳dump很重要,这个看雪的教程还是刚刚的 https://www.kanxue.com/chm.htm?id=2277&pid=node1000293
但是大家会发现诸如apxx 原x这样的重量级联网游戏,会有anti-cheat去patch内核层面的函数来达到保护,大家会发现attach debugger会失败,因为anti-cheat屏蔽了权限。这个权限大家可以参考 https://learn.microsoft.com/zh-CN/windows/win32/procthread/process-security-and-access-rights
想要突破这个process的权限,有很多方法;我们回到开源上,比如 https://github.com/notscimmy/libelevate 通过隐藏的数据结构重新拿到权限,当然anti-cheat会一直扫描,所以这就是一种对抗了。内核调试到ring0是肯定解决问题的,还有一种就是在可能的情况下注入DLL然后dump内存。这个注入DLL的方法我们在前一部分已经谈过了。
快速扫盲就暂时到这里吧。其实游戏插件的话题还有很多,比如说插件做出来了,如何保护自己让自己不被滥用?万一别人写了个DLL注入到你的程序里dump游戏内容,这个可能会遭到游戏开发和发行商的封杀的。安全问题一层又一层。科技向善吧。
本文拙劣,欢迎批评指正。谢谢。
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 2023-2-5 23:26
被freshreer编辑
,原因: