
玩烈焰升腾模组,结果发现这个模组需要解锁全部 DLC 才有完整体验,一通搜索之后发现了免费解锁 DLC 的方法,这个方法需要 DLC 文件和一个 `steam_api64.dll` 文件。对这个他人提供的 DLL 文件我不是很放心,顺藤摸瓜找到了原始的解锁 steam 游戏 DLC 的项目: [GitHub - acidicoala/SmokeAPI: Legit DLC Unlocker for Steamworks](https://github.com/acidicoala/SmokeAPI)
粗略看了下源码,该项目伪造了一个 `steam_api64.dll` 文件,将其放入游戏目录,让游戏可执行文件加载这个伪造的 DLL,欺骗游戏进程使其认为用户拥有 DLC。
具体来说,游戏通过 steam sdk 提供的 API 与 steam 通信,DLC 的验证也是如此,链接在此:[Steamworks API 索引](https://partner.steamgames.com/doc/api/ISteamApps)
可以看到,该 SDK 通过接口对象提供 API,虽然也提供了所谓的 flat 版本的函数,即不是虚函数的接口,但根据游戏可执行文件的导入表,被使用的 API 还是通过 `ISteamApps` 这种接口提供的。
根据项目以及 steam 的文档,SDK 提供的 `BIsDlcInstalled` 用于验证用户是否拥有 DLC,那破解方案就很明显了:我们可以创建同名 DLL,该 DLL 导出所有原始 SDK DLL 导出的函数名字,当游戏进程调用 Steam API 时,实际调用的是我们伪造的 DLL 中的函数。大部分函数都只需要原样转发至原始 DLL,只有少部分函数需要执行自定义逻辑。
有个问题是,那种通过虚函数提供的接口不会体现在模块的导出表中,无法让游戏进程调用我们实现的同名函数来执行自定义逻辑。
但是接口对象是通过普通函数返回的,例:`SteamApps()`。根据 SDK 中头文件的相关实现,最终是通过某几个 `CreateInternalInstance` 类似的函数返回接口对象指针。
我们可以重新实现 `Create` 系列函数,获取原始接口对象,修改其虚函数表,如:让虚函数表的第7项(从0开始),即 `BIsDlcInstalled` 所在的条目,指向我们的自定义函数,这样就可以让游戏在调用虚函数接口时执行我们的自定义逻辑了。
如果被导出的函数需要一个个写实现转发未免太麻烦了,幸运的是,有偷懒的办法。
根据微软的文档:[EXPORTS | Microsoft Learn](https://learn.microsoft.com/en-us/cpp/build/reference/exports?view=msvc-170)
EXPORTS
func2=other_module.func1
上述模块定义文件的写法可以让 DLL 导出其他模块的名字,而且可以重新指定名字,非常方便。只需要用 dumpbin 工具或者 python 脚本将原始 DLL 的导出名字列表改写成 DEF 文件的格式就行。
对于需要添加自定义逻辑的函数,如 `Create` 系列函数,由我们实现它,别忘了要在 DEF 文件中将对应条目改为导出自身模块的名字。
关于 MSVC,msvc 会在链接时会报错,说未解析的外部符号之类的。根据 StackOverflow 上[一个问题](https://stackoverflow.com/q/75518773/18420321)下的评论:
The linker appears to double-check that target_module.dll actually exports func. Try changing it to, say, `func = user32.SetWindowPos` and now it is happy. If it can't find the .dll or the export then it complains. The exact rules it uses to locate the .dll are not documented anywhere, I couldn't find a pattern.
我认为这是缺少导入库导致的。steam 官网下载的 SDK 附带的导入库可能是因为版本问题,仍有链接问题。我不太想再去确认游戏目录下对应 DLL 的版本了。幸而 clang 没有 msvc 那么严格,我在 CMake 中指定 clang 工具链即可正常编译链接,查看生成产物的导出表也没问题。
关于修改虚函数表,由于在运行时虚函数表位于内存的只读数据段,直接修改是不行的。在Windows上,需要调用 `VirtualProtect` 函数将其所在页面修改为可写。
关于调用约定,由于游戏只有64位,这里只讲64位情况。Windows 平台64位应用程序默认使用 fastcall 调用约定,最左侧四个整数参数通过四个寄存器传递,RCX、RDX、R8、R9。正好 this 指针也通过 RCX 传递,于是虚函数表项所指向的函数原型在参数列表开头加一个 `void * rcx` 即可。
[Create a Proxy DLL with Artifact Kit](https://hstechdocs.helpsystems.com/manuals/cobaltstrike/current/userguide/content/topics/blog_proxy-dll.htm)
[Adaptive DLL Hijacking - NetSPI](https://www.netspi.com/blog/technical-blog/adversary-simulation/adaptive-dll-hijacking/)
[DLL Hijacking](https://lucabarile.github.io/Blog/dll_hijacking_and_proxying/index.html)
[EXPORTS | Microsoft Learn](https://learn.microsoft.com/en-us/cpp/build/reference/exports?view=msvc-170)
[使用 DEF 文件从 DLL 导出 | Microsoft Learn](https://learn.microsoft.com/zh-cn/cpp/build/exporting-from-a-dll-using-def-files?view=msvc-170)
[VirtualProtect 函数 (memoryapi.h) - Win32 apps | Microsoft Learn](https://learn.microsoft.com/zh-cn/windows/win32/api/memoryapi/nf-memoryapi-virtualprotect)
[x64 Calling Convention | Microsoft Learn](https://learn.microsoft.com/en-us/cpp/build/x64-calling-convention?view=msvc-170)