跳转至

Windows 客户端加固

文件签名证明不了运行中的进程

发布包已经做了 Authenticode 签名。玩家启动游戏后,用外部进程打开它,写入一段 DLL,再调用游戏里的函数,磁盘上的 EXE 和 DLL 仍然是签名后的文件。

这就是 Windows 加固最容易混淆的地方:

  • 文件签名说明发布物由谁签发。
  • 进程完整性说明当前运行的内存和模块有没有被改。
  • 网络结果说明服务器是否接受了客户端提交的数据。

三者不能互相替代。Windows 也没有给普通游戏进程一个可靠的“我现在绝对没有被调试”的 API。1

常见入口

方式 主要动作 观察点
远程线程 OpenProcess、VirtualAllocEx、WriteProcessMemory、CreateRemoteThread 线程起始地址、私有可执行内存、模块记录
DLL 注入 LoadLibrary 或手动映射 模块路径、签名、加载基址
IAT / GOT Hook 改导入表,把函数转发到自己的实现 导入函数地址是否落在原模块
Inline Hook 改函数开头指令,插入跳转 代码页、函数序言、异常处理链
调试器 附加、断点、单步、修改寄存器 IsDebuggerPresent、远程调试、PEB 标志
驱动 从内核读写进程、隐藏模块或修改回调 驱动签名、加载时间、已知漏洞驱动

前两种是“把代码放进进程”,后几种是“改变进程执行到哪里”。实际工具通常组合使用,例如先注入 DLL,再在 DLL 里做 Hook。

文件身份

安装包、主程序、更新器和关键 DLL 都应使用 Authenticode 签名。3 更新器替换文件前要校验:

  • 签名者是否是预期的发布证书。
  • 证书链是否有效。
  • 是否带可信时间戳。
  • 文件和清单中的 SHA-256 是否一致。
  • 版本和渠道是否匹配。

签名本身不覆盖运行中的补丁。玩家可以在启动后修改内存,文件还是合法的。所以文件校验适合放在安装、更新和启动阶段,不适合每一帧重复执行。

不要把私钥、证书口令或代码签名密码放进普通构建机和工作日志。签名机应与日常开发网络隔离,至少要有独立的访问控制和审计记录。

模块检查

可以用 Process.GetCurrentProcess().Modules 或 EnumProcessModules 枚举当前模块:

foreach (ProcessModule module
         in Process.GetCurrentProcess().Modules)
{
    var path = module.FileName;
    var baseAddress = module.BaseAddress;
    var size = module.ModuleMemorySize;

    // 记录路径、签名者、版本、加载基址
}

未知模块不等于作弊。输入法、录屏、直播、显卡覆盖层、杀毒软件和辅助功能工具都可能加载到游戏进程。反过来说,手动映射的模块也可以不出现在正常模块链表里。

需要组合判断:

  • 模块是否在临时目录、下载目录或其他用户可写目录。
  • 模块是否有有效签名,签名者是否是已知厂商。
  • 模块是否只在某个作弊工具出现后加载。
  • 线程起始地址是否落在模块范围之外。
  • 是否存在没有对应文件映射的可执行内存。

模块扫描适合在启动、登录、场景切换和进入比赛时采样。活跃进程的模块会变化,扫描结果也要带时间点和加载地址,不能只记录一个模块名。

可执行内存和线程

Windows 的正常模块通常由 DLL/EXE 文件映射,代码页有对应的文件来源。手动映射、shellcode 和某些保护壳会创建私有可执行内存。

检查时要用 VirtualQuery 看内存区域的:

  • BaseAddress 和 RegionSize。
  • State:MEM_COMMIT、MEM_RESERVE、MEM_FREE。
  • Type:MEM_IMAGE、MEM_MAPPED、MEM_PRIVATE。
  • Protect:是否包含 PAGE_EXECUTE、PAGE_EXECUTE_READWRITE 等。

MEM_PRIVATE + 可执行 是信号,不是结论。Mono、脚本运行时、JIT 插件、保护壳和某些崩溃收集工具都可能创建可执行内存。先在自己的发布构建上跑一遍,区分正常区域和可疑区域。

线程起始地址如果落在未知模块或私有可执行页,值得记录。但 Windows 的线程枚举和起始地址获取并不总是可靠,线程也可能在扫描之后才创建。不要把一次采样当成完整证明。

全量扫描的代价不低。它会访问大量内存页,可能触发页错误、增加工作集和造成卡顿。按启动、场景切换和比赛开始采样,并限制单次扫描的区域数量和处理时间。

调试器检测

最基础的检查:

using System.Runtime.InteropServices;

[DllImport("kernel32.dll")]
private static extern bool IsDebuggerPresent();

[DllImport("kernel32.dll")]
private static extern bool CheckRemoteDebuggerPresent(
    IntPtr processHandle,
    out bool isDebuggerPresent);

private static bool HasDebugger()
{
    CheckRemoteDebuggerPresent(
        Process.GetCurrentProcess().Handle,
        out var remote);

    return IsDebuggerPresent() || remote;
}

这些 API 很容易被绕过。内核调试、隐藏端口、先附加再 Hook 检测函数,都会让结果变成 false。开发包、性能分析工具和部分崩溃收集器也会让结果变成 true。

因此,调试器检测适合记录和风险分级:

  • 发布构建检测到调试器时记录附加方式和时间。
  • 不要直接崩溃或强制退出,否则误报无法排查。
  • 结合模块、可执行内存和服务器行为一起看。
  • 开发构建允许调试,但不要把这个豁免带到发布构建。

进程缓解策略

Windows 提供了一组进程缓解策略:

  • DEP 和 ASLR。
  • Control Flow Guard,限制间接调用的合法目标。
  • Arbitrary Code Guard,限制动态生成可执行代码。
  • Code Integrity Guard,限制未签名二进制加载。
  • 子进程创建时的缓解属性继承。

部分策略可以在进程内通过 SetProcessMitigationPolicy 设置,部分只能在创建进程时通过 STARTUPINFOEX 和 UpdateProcThreadAttribute 指定。具体可用范围取决于 Windows 版本和策略类型。2

这些选项不能无脑全开。Unity 的脚本后端、插件、热更、保护壳和崩溃收集器可能依赖 JIT 或未签名模块;CIG 和 ACG 一旦和实际加载链冲突,表现通常是插件的某条路径失效,而不是启动时立刻报错。

上线前至少分别测试:

策略 主要风险
CFG 某些间接调用或生成代码被拦截
ACG JIT、脚本运行时或插件无法创建可执行页
CIG 未签名或签名者不在允许列表的 DLL 被拒绝
子进程继承 更新器、崩溃收集器或外部工具启动失败

策略开启后要记录失败原因和模块名,不能只留下一个“进程创建失败”。

Hook 检查

IAT Hook

IAT Hook 修改导入地址表,让某个函数调用跳到别处。检查方法是把当前函数地址和预期模块导出地址比较,发现目标不在原模块范围就记录。

难点是延迟加载、API 转发、代理 DLL 和 Windows 版本差异。合法插件也可能代理某些系统调用,所以不能把所有非标准地址都当成 Hook。

Inline Hook

Inline Hook 改写函数开头几条指令。可以按构建版本记录关键函数序言的哈希或字节范围,在启动和关键操作前抽查。

需要处理:

  • ASLR 后的模块基址。
  • 热更新程序集和原生插件的版本。
  • 代码页保护和读取权限。
  • 保护壳、重定位和补丁造成的正常差异。
  • 采样频率和扫描成本。

只检查一个函数无法覆盖全部 Hook。IAT Hook 可能不改函数体,运行时方法表 Hook 可能连指令都不碰。

调试结果

发现 Hook 后,记录:

  • 模块路径、签名者、版本。
  • 函数名和模块基址。
  • 采样地址、原始字节和当前字节。
  • 首次发现时间、会话 ID 和构建版本。

不要在客户端回传完整内存内容。记录足够的范围和哈希,敏感数据要脱敏。

更新器

更新器是高风险路径,因为它要替换主程序和 DLL。至少做到:

  • 更新器自身也要签名。
  • 下载内容用 HTTPS,但不要只依赖 HTTPS。
  • 核对清单签名、文件大小和 SHA-256。
  • 替换前关闭正在运行的游戏进程。
  • 使用独立目录或临时文件,失败时保留旧版本。
  • 更新完成后重新校验版本和签名。

Windows 的文件占用、权限和杀毒软件扫描会让替换失败。更新器要能区分“文件被占用”“签名不匹配”“磁盘空间不足”和“用户取消”,不要统一提示“更新失败”。

热更清单和文件切换的具体做法见热更新包与资源验签和热更新版本、下载与回退。

内核驱动

内核驱动能拿到比用户态更多的信息,但也会带来新的问题:

  • 蓝屏和兼容性故障。
  • 与杀毒软件、虚拟机和系统更新冲突。
  • 驱动签名、商店审核和发布流程更复杂。
  • 驱动本身可能成为攻击面,也可能被已知漏洞驱动绕过。

如果项目没有稳定的驱动团队、签名流程和长期兼容性测试,优先使用成熟的反作弊方案,或者把资源放在服务器校验和风控上。不要为了“更底层”仓促上自研驱动。

发布检查

  • Release 包不带调试符号,PDB 上传到符号服务器。
  • 主程序、更新器和关键 DLL 都签名并带时间戳。
  • 服务器校验伤害、金币、射速、移动、掉落和领奖请求。
  • 模块和可执行内存检测按阶段采样,不放在每帧。
  • 误报时能关闭检测或人工恢复账号。
  • 日志包含构建、平台、会话、采样时间和信号来源。
  • 更新器能处理文件占用、签名失败和回滚。

PC 端加固的目标不是让修改器完全消失。更现实的目标是让普通修改失效,让高级修改留下可追踪的证据,让服务器能够拒绝异常结果。