Steam API 完整性校验绕过工具的技术分析与防御

逆向工程 算法 编程语言
35 编辑于
kinotern

摘要

Steam 客户端在进程启动阶段读取 steam_api64.dll 的尺寸、时间戳与属性,据此判断该文件是否被替换。绕过这套检查需要让校验方读到原始文件的特征,同时让游戏加载替换后的版本。Steam-API-Check-Bypass 的做法是在 ntdll 层接管进程内全部文件与模块操作,为指定文件名构建一套与磁盘实际状态相反的视图。本文沿代码路径还原其实现,分析规则存储与路径归一化、文件操作重定向、目录枚举链表过滤、模块摘链四个环节,归纳该方案成立所依赖的假设,并给出应用层、系统层与检测侧的对策。

关键词: API Hook;文件虚拟化;完整性校验绕过;PEB 摘链;Detours


1 问题背景

Steam 的完整性校验建立在一个前提上:调用 Windows API 读到的文件信息等于文件在磁盘上的真实状态。校验方用 CreateFileW 打开 steam_api64.dll,读尺寸或算哈希,与预期值比对;也可以用 GetFileAttributesW 取属性,或枚举目录确认文件在位。

破解版的 steam_api64.dll 是模拟器实现(如 Goldberg),尺寸与哈希都与官方版本不符,直接替换会被上述任一条检查拦下。

攻击方的目标是让校验方通过 API 读到错误的答案。ntdll 是用户态文件操作的公共出口,CreateFileW、GetFileAttributesW、FindFirstFileW 最终都要落到 NtCreateFile、NtQueryAttributesFile、NtQueryDirectoryFile 上。接管这一层,就接管了进程对文件系统的全部认知。kernel32 的函数只是封装,且 W/A/Ex 变体众多,直接在 ntdll 落钩能一次覆盖所有调用路径,包括那些绕过 kernel32 直接调 ntdll 的程序。


2 系统结构

2.1 分层

SteamAPICheckBypass.dll      引导层:注入方式、自我隐藏、配置发现、默认规则
   └── ntfsdupe(静态库)     核心层
        ├── Configs         规则存储与查询
        ├── Hooks           挂载、分发、目录过滤
        ├── NtApis          路径转换、PEB 锁、句柄复制
        └── Helpers         字符串与路径工具

引导层由 SteamAPICheckBypass/ 实现,核心层来自 nt_file_dupe/。作者说明核心层逆向自 CODEX 的 Steam 模拟器,并参考了 otavepto/nt-fs-dupe 的实现。

2.2 注入

进程必须先加载这个 DLL,钩子才有机会安装。代码提供两条路径。

第一条是修改主程序的导入表,用 CFF Explorer 增加一条 DLL 导入记录。

第二条是代理 DLL 劫持。把工具改名为 version.dll、winmm.dll 或 winhttp.dll 放在 exe 旁边。Windows 的 DLL 搜索顺序里,应用程序目录排在 System32 之前,所以系统会先加载这个冒牌货。为了不破坏原有功能,它从 System32 加载真身并把导出地址填入转发表(exports.cpp:51),自己的导出则是汇编跳板:

asm
GetFileVersionInfoA proc
    jmp QWORD ptr OriginalFuncs_version[0 * 8]

17 个 version.dll 导出、180 个 winmm.dll 导出、91 个 winhttp.dll 导出都按这个方式转发。

代码在这里有一处错误。exports.cpp:33 判断文件名是 winhttp.dll 后返回的是 DLL_WINMM,应该返回 DLL_WINHTTP。结果是 winhttp 模式下程序去加载 winmm.dll,再按 winmm 的导出名表填充,表项对不上,转发全部失效。

DLL 内还注册了 TLS 回调(dllmain.cpp:113),放在 .CRT$XLB 段。该回调在 DllMain 之前执行,用于尽早加载真身。钩子本身仍然在 DllMain 里安装,TLS 阶段只有转发表就绪。


3 核心机制

3.1 规则模型与路径归一化

规则来自 JSON 配置文件,支持五种模式:file_redirect、file_hide、module_prevent_load、module_redirect、module_hide_handle。配置在 Configs.cpp:275 解析,每一项转成 FileCfgEntry 或 ModuleCfgEntry。

一条 file_redirect 会同时写入两条记录(Configs.cpp:122 与 :139):

"…\STEAM_API64.DLL"      → { mode = original, target = "…\STEAM_API64.DLL.BAK" }
"…\STEAM_API64.DLL.BAK"  → { mode = target,   original = "…\STEAM_API64.DLL" }

查表一次就能判断当前访问的是被重定向的原文件,还是那个影子目标。存储用 unordered_map<std::wstring_view, FileCfgEntry>,键是指向 global_entries_storage 的视图,避免重复分配字符串。

查询链固定在 Hooks.cpp:33:

UNICODE_STRING(可能是 \Device\HarddiskVolume3\Games\…)
   ↓ NtPathToDosPath        转成 C:\Games\…
   ↓ GetFullDosPath         补全相对路径与长路径
   ↓ helpers::upper()       全大写,并把 '/' 统一成 '\'
   ↓ file_entries.find()

upper() 定义在 Helpers.hpp:27,大小写归一和分隔符统一在同一趟完成。这条链路是所有规则的入口,也决定能拦到什么。任何一处没归一化,对应写法就能绕过规则。

Configs.cpp:109 还要求原文件与目标文件的目录前缀一致,所以重定向只能在同一目录内进行。

3.2 文件操作的重定向与隐藏

NtCreateFile 的钩子(NtCreateFile_hook.cpp:38)按模式分派:

模式处理
original改写 ObjectAttributes->ObjectName 指向目标文件,调用原函数,随后恢复
target / hide返回 STATUS_OBJECT_NAME_NOT_FOUND

改写路径的代码在临时缓冲区上完成(NtCreateFile_hook.cpp:56):

cpp
const auto buffer_backup = ObjectAttributes->ObjectName->Buffer;
ObjectAttributes->ObjectName->Buffer = object_name_new.get();
const auto result = NtCreateFile_original(...);
ObjectAttributes->ObjectName->Buffer = buffer_backup;

新路径缓冲区由 _malloca 分配在栈上(Helpers.hpp:12),调用返回前恢复调用者的 UNICODE_STRING。这样不会破坏调用者的堆,也不会留下可被检测的内存痕迹。

STATUS_OBJECT_NAME_NOT_FOUND 是内核在文件确实不存在时返回的状态码,调用方无法从返回值上区分真假。

同样的语义在 NtQueryAttributesFile、NtQueryFullAttributesFile、NtOpenFile、NtDeleteFile 上重复实现,覆盖 GetFileAttributesW、DeleteFileW 等上层 API。

3.3 目录枚举的链表过滤

目录枚举必须单独处理,否则 FindFirstFileW 会把 .bak 文件列出来。

单节点查询(ReturnSingleEntry 为真,对应"这个文件在不在目录里")走 NtQueryDirectoryFile_hook.cpp:146:复制句柄,把 FileName 参数设成目标文件名再查一次,用目标文件的尺寸和时间戳顶替原结果返回。stat("steam_api64.dll") 因此得到的是 .bak 的属性。

多节点查询走 handle_file_multi_query(Hooks.cpp:142)。内核返回的是一条 FILE_*_DIR_INFORMATION 链表,代码逐节点重建完整路径后查表:

  • 命中 hide 或 original:把该节点从链表摘除。后续节点前移 memcpy,IoStatusBlock->Information 减去该节点字节数。如果它是唯一节点,整个查询返回"目录为空"。

  • 命中 target:把节点里的文件名原地改回原名。不做这一步,列表里会直接出现 steam_api64.dll.bak。

最终进程看到的是 steam_api64.dll 存在、.bak 不存在,与磁盘真实状态正好相反。

代码处理了 9 种 FILE_INFORMATION_CLASS。每种结构用 offsetof 手法算出 NextEntryOffset、FileName、FileNameLength 三个字段的偏移(NtQueryDirectoryFile_hook.cpp:42),共用一份过滤逻辑。

3.4 模块级隐藏

LdrLoadDll 的钩子(LdrLoadDll_hook.cpp:20)按模块模式分派。prevent_load 与 target 返回 STATUS_DLL_NOT_FOUND,与原 API 在 DLL 不存在时的返回值一致。original 改写 DllName 后调用原函数。

module_hide_handle 不拦截加载,而是把模块从 PEB 中摘除。peb_helper.cpp:65 的 UnlinkLdrEntry 同时处理五条链表:

InMemoryOrderLinks
InLoadOrderLinks
InInitializationOrderLinks
HashLinks
NodeModuleLink

只摘一条会留下不一致。GetModuleHandle 走 HashLinks,EnumProcessModules 走 InMemoryOrder,调试器走另外几条。五条一起摘,模块才在进程内存视图里消失。

3.5 豁免与计数控制

重定向模块时存在自伤风险。loader 加载 .bak 的过程中会内部调用一次 NtCreateFile,如果不加处理,这次调用会命中 target 规则,得到"文件不存在",加载失败。

LdrLoadDll_hook.cpp:65 用线程局部豁免解决:

cpp
ntfsdupe::cfgs::add_bypass(wsv);
result = LdrLoadDll_original(...);
ntfsdupe::cfgs::remove_bypass(wsv);

豁免表按线程 ID 索引并带引用计数(Configs.cpp:389),嵌套加载不会互相覆盖,多线程并发也互不影响。查询侧在 Configs.cpp:373 检查豁免状态,命中则返回空,等同该文件没有任何规则。

hook_times_mode 提供另一类控制。校验逻辑常见的形式是先读文件验证,再加载。规则可以只拦截第 N 次访问(Configs.cpp:439),让前几次校验读到真文件,后续访问才落到被替换的版本上。


4 假设与薄弱点

这套方案能成立,依赖三条假设。逐条检查可以定位它的边界。

假设一:文件系统的用户态入口是唯一的,且都经过 ntdll。 直接执行 syscall 指令、自行解析系统服务号的代码不经过 ntdll 函数体,Detours 改的是函数开头的字节,对这类调用没有作用。

假设二:调用方只看钩子覆盖的字段。 FileId 就是漏掉的字段。卷内的 FileId 在文件改名后保持不变。NtQueryDirectoryFile 的 FileIdBothDirectoryInformation 分支(NtQueryDirectoryFile_hook.cpp:78)只改了文件名,没有伪造 FileId。用目录枚举拿到的 FileId 与打开句柄后查到的 FileId 对比,重定向立刻暴露:两个名字指向同一个 FileId。

假设三:钩子在检查之前装好。 钩子在 DllMain 中安装,TLS 回调阶段尚未生效,两者之间存在窗口。

此外还有几处工程约束。原文件与目标文件必须在同一目录(Configs.cpp:109)。目录过滤按 FILE_INFORMATION_CLASS 逐个适配,未覆盖的类别会漏。钩子依赖 ntdll 的导出名,GetProcAddress 失败时 optional 标记决定是整体失败还是跳过。每个钩子都有 try/catch 兜底并在异常时直通原函数,因为崩溃等于暴露。


5 防御

5.1 应用层

用 FileId 交叉验证。 成本最低。校验方先用目录枚举取 steam_api64.dll 的 FileId,再通过句柄查询同一个文件的 FileId,两者应当一致。做法上不需要额外权限:

cpp
// 枚举目录,从 FILE_ID_BOTH_DIR_INFORMATION 取 FileId
// 打开句柄,FILE_ID_INFO 取 VolumeSerialNumber + FileId
// 比较两者,不一致即判定文件视图被篡改

绕过 ntdll 直接发系统调用。 自行解析目标函数的系统服务号,用 syscall 指令调用。Detours 修改的是 ntdll 函数体开头的字节,系统调用不经由那里,钩子完全失效。任何只在用户态 ntdll 层做文件校验的方案都止步于此。

校验内存镜像而非路径。 steam_api64.dll 自校验时不要用 GetFileAttributesW 或 CreateFileW,这两条路径已被覆盖。改为映射自身镜像后校验 PE 头与节区内容,或用 NtQuerySection 取节信息。

三方交叉比对。 目录枚举结果、单文件属性、句柄查询三者对同一文件给出的大小、时间戳、FileId 应当吻合。任一处不一致即告警。

进程内钩子自检。 从磁盘读入 ntdll 副本,与内存映像逐字节比较,检查是否存在 0xE9 相对跳转或 0xFF 0x25 绝对跳转补丁。检查 PEB 链表节点数是否等于 EnumProcessModules 的返回数,摘链会造出差异。扫描自身目录下是否存在 *.dll.bak、*.org、*_o.dll 这类由默认规则生成的组合。

数字签名校验。 官方 steam_api64.dll 带有 Valve 签名,可用 WinVerifyTrust 验证。这条手段单独使用不够,WinVerifyTrust 同样运行在用户态,需要与内核侧验证配合。

5.2 系统层

关闭 DLL 搜索顺序劫持。 现在进程创建时调用:

cpp
SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32 |
                         LOAD_LIBRARY_SEARCH_APPLICATION_DIR);

或在可执行文件清单中声明各 DLL 的来源与签名要求。这一步直接切断 2.2 节描述的注入路径。

WDAC 配合 HVCI。 只允许签名映像加载,把未签名 DLL 放进游戏目录的做法彻底堵死。

内核回调监听模块加载。 PsSetLoadImageNotifyRoutine 在镜像真正映射进进程时触发,与 PEB 链表无关。模块即使被摘链,加载事件仍然产生。这是对抗 module_hide_handle 的主要手段。

minifilter 驱动与 ETW。 minifilter 在 IRP_MJ_CREATE 层拿到真实路径、真实尺寸与哈希,用户态的伪造在这一层全部透明。ETW 的 Microsoft-Windows-Kernel-File provider 记录真实文件访问,可以与进程自身报告的行为对照。

5.3 检测指标

特征说明
游戏目录出现 steam_api64.dll.bak、.org、_o.dll默认兜底规则生成的三件套
进程加载了游戏目录下的 version.dll、winmm.dll、winhttp.dll正常应从 System32 加载
ntdll 代码段被修改Detours 补丁特征
同一 FileId 对应两个不同文件名重定向的直接证据
目录枚举 FileId 与句柄查询 FileId 不一致同上
用户可写目录下加载了未签名模块通用信号

Sysmon 的 EventID 7(ImageLoad)由内核回调产生,不受 PEB 摘链影响,适合作为主检测点。Sigcheck 可用于批量筛查未签名模块,Process Monitor 可用于确认 DLL 的实际加载路径。


6 结论

Steam-API-Check-Bypass 的全部能力建立在一个假设上:用户态 ntdll 是文件系统的唯一入口,且调用方只看它返回的字段。打破这个假设有三条路径。向下绕过用户态,直接发系统调用;向上到内核,用 minifilter、ETW 或加载回调观察真实状态;横向用钩子未覆盖的字段做交叉验证,FileId 是最直接的一个。

这三条路径里,FileId 比对与 Sysmon ImageLoad 事件的实现成本最低。该工具在这两处都没有设防:目录过滤只改文件名不改 FileId,模块隐藏只摘 PEB 链表不去影响内核的镜像加载通知。


参考文献

  1. Microsoft. NtQueryInformationFile function (ntifs.h) [EB/OL]. Microsoft Learn. https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/nf-ntifs-ntqueryinformationfile
  2. Microsoft. Detours [CP/OL]. GitHub. https://github.com/microsoft/Detours
  3. otavepto. nt-fs-dupe [CP/OL]. GitHub. https://github.com/otavepto/nt-fs-dupe
  4. Steam API Check Bypass [CP/OL]. 项目 README 与本仓库 nt_file_dupe/README.md;早期讨论见 cs.rin.ru 论坛帖 t=136448
  5. Microsoft. PE Format [EB/OL]. Microsoft Learn. https://learn.microsoft.com/en-us/windows/win32/debug/pe-format

本文版权遵循 CC BY-NC 协议 和 本站版权政策

(。>︿<。) 已经一滴回复都不剩了哦~