重构大版本更新
一口气全面统一博客样式,移除部分冗余导航页面,更专注内容展示。
以 FinalSuspect 的三重检测被一个 dnSpy + ret 指令组合一次性解除为例,说明托管层检测为何必然失效,并给出由弱到强的五层加固方案:混淆加密、IL 完整性自校验、原生 DLL 检测、分散时机加蜜罐、服务端验证。

以 FinalSuspect 为例,它的三重检测被一个 dnSpy + ret 指令的组合就全部解除了。根本原因只有一个:所有检测逻辑都在托管代码层(IL)里,和攻击者的修改工具处于同一个信任层。本文从运行机制角度,讲清楚为什么现有方案会失败,以及如何一层一层加强。
FinalSuspect 的检测失败链路如下:
检测逻辑在 IL 层 ↓dnSpy 可以直接反编译看到 ↓直接把方法体改成 ret ↓检测逻辑永远不执行根本问题是检测代码和被检测的环境在同一个进程、同一个信任层里。任何在托管代码里写的检测,理论上都可以被同样在托管层运行的代码绕过。下面从弱到强介绍五个加固层级。
FinalSuspect 已经用了 XOR 加密字符串,但逻辑本身没有混淆,反编译后整体结构一目了然。更进一步的做法是用 ConfuserEx 或 Obfuscar 在编译后对 DLL 做整体混淆。
<project> <rule pattern="true">
<protection id="ctrl flow" />
<protection id="constants" />
<protection id="ref proxy" />
<protection id="anti tamper" />
<protection id="anti debug" /> </rule></project>**局限:**混淆只是提高反编译成本,不能从根本上阻止修改。有经验的人依然可以动态调试绕过。
让 DLL 在运行时校验自身的 IL hash。如果有人修改了某个方法的指令,hash 就对不上,直接退出。
internal static class IntegrityChecker{ // 编译时预先计算好关键方法的 hash,硬编码进来 // 注意:这些 hash 本身也要混淆存储,不能明文 private static readonly Dictionary<string, byte[]> _expectedHashes = new() { // MethodToken → 预期的 IL bytes 的 SHA256 { "DllChecker.OnInitialization", new byte[] { 0x3A, 0xF1, ... } }, { "Git.Prefix", new byte[] { 0x7C, 0x2B, ... } }, };
internal static void Verify() { var asm = Assembly.GetExecutingAssembly();
foreach (var type in asm.GetTypes()) { foreach (var method in type.GetMethods( BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)) { string key = $"{type.Name}.{method.Name}"; if (!_expectedHashes.TryGetValue(key, out byte[] expected)) continue;
// 读取运行时实际的 IL bytes var body = method.GetMethodBody(); if (body == null) { Quit(); return; }
byte[] actual = SHA256.HashData(body.GetILAsByteArray());
if (!actual.SequenceEqual(expected)) { // IL 被篡改,静默退出(不报错,防止攻击者定位触发点) Quit(); return; } } } }
private static void Quit() { // 故意延迟几秒再退出,让攻击者难以定位触发点 Thread.Sleep(new Random().Next(2000, 8000)); Application.Quit(1); }}关键点:
Verify() 要在多个时机调用(启动时、进入游戏时、每隔一段时间),不能只在一处这是最关键的跨越。托管代码(C#)可以被 dnSpy 修改;原生代码(C++ DLL)需要 x64dbg + IDA,门槛高出一个数量级。
做法是写一个原生 DLL(C++),负责执行真正的检测逻辑,C# 只负责调用:
// NativeGuard.cpp(编译成 NativeGuard.dll)#include <windows.h>#include <tlhelp32.h>
// 扫描插件目录,发现白名单外的 DLL 返回 -1extern "C" __declspec(dllexport)int __stdcall ScanPluginDirectory(const wchar_t* pluginDir, const wchar_t** whitelist, int whitelistCount){ WIN32_FIND_DATAW findData; wchar_t pattern[MAX_PATH]; swprintf_s(pattern, L"%s\\*.dll", pluginDir);
HANDLE hFind = FindFirstFileW(pattern, &findData); if (hFind == INVALID_HANDLE_VALUE) return 0;
int count = 0; do { bool allowed = false; for (int i = 0; i < whitelistCount; i++) { if (wcscmp(findData.cFileName, whitelist[i]) == 0) { allowed = true; break; } } if (!allowed) { FindClose(hFind); return -1; // 发现非法 DLL } count++; } while (FindNextFileW(hFind, &findData));
FindClose(hFind); return count;}
// 检测已知调试器进程extern "C" __declspec(dllexport)bool __stdcall IsDebuggerRunning(){ // 方法 1:Windows API if (IsDebuggerPresent()) return true;
// 方法 2:检查调试器进程名 const wchar_t* debuggers[] = { L"dnSpy.exe", L"x64dbg.exe", L"cheatengine-x86_64.exe", L"ida64.exe", L"OllyDbg.exe", L"ProcessHacker.exe" };
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snap == INVALID_HANDLE_VALUE) return false;
PROCESSENTRY32W pe = { sizeof(pe) }; if (Process32FirstW(snap, &pe)) { do { for (auto& dbg : debuggers) { if (_wcsicmp(pe.szExeFile, dbg) == 0) { CloseHandle(snap); return true; } } } while (Process32NextW(snap, &pe)); } CloseHandle(snap); return false;}在 C# 中调用:
internal static class NativeGuard{ [DllImport("NativeGuard.dll", CharSet = CharSet.Unicode)] private static extern int ScanPluginDirectory( string pluginDir, string[] whitelist, int whitelistCount);
[DllImport("NativeGuard.dll")] private static extern bool IsDebuggerRunning();
internal static void Check() { // 调试器检测 if (IsDebuggerRunning()) { SilentQuit(); return; }
// 插件目录扫描 string pluginDir = Path.GetDirectoryName( Assembly.GetExecutingAssembly().Location)!;
string[] whitelist = { "FinalSuspect.dll", "PolarNight.dll" };
int result = ScanPluginDirectory(pluginDir, whitelist, whitelist.Length); if (result == -1) { SilentQuit(); } }
private static void SilentQuit() { Thread.Sleep(new Random().Next(3000, 10000)); Application.Quit(1); }}**为什么有效:**攻击者要绕过这个,必须先找到原生 DLL(名字可以随便起,混在游戏 DLL 里),再用 x64dbg 动态调试原生层,最后找到具体函数并 patch。这个门槛比改 IL 高出一个数量级。
不要把检测集中在一个地方,分散到各个角落,让攻击者找不全:
// 在多个不相关的 Patch 里随机触发检测[HarmonyPatch(typeof(HudManager), nameof(HudManager.Update))]internal static class HudUpdatePatch{ private static int _frameCount = 0;
public static void Postfix() { _frameCount++; // 每隔随机帧数触发一次检测,不规律、难预测 if (_frameCount % (UnityEngine.Random.Range(800, 1200)) == 0) { IntegrityChecker.Verify(); NativeGuard.Check(); } }}
[HarmonyPatch(typeof(AmongUsClient), nameof(AmongUsClient.OnGameJoined))]internal static class JoinGamePatch{ public static void Prefix() { // 进游戏时也检测一次 NativeGuard.Check(); }}
[HarmonyPatch(typeof(MeetingHud), nameof(MeetingHud.Start))]internal static class MeetingStartPatch{ public static void Prefix() { // 每次开会议时也检测 IntegrityChecker.Verify(); }}配合蜜罐使用——故意留一个看起来像检测方法的”假靶子”,让攻击者优先清空它,真正的检测在别处:
// 这个方法什么都不做,就是个蜜罐// 攻击者反编译后会优先清空"看起来最危险"的方法internal static class FakeChecker{ public static void CheckPlugins() { } // 空的,故意的 public static void ScanDirectory() { } // 空的,故意的}如果你的模组有联网功能,可以把合规性验证放到服务端。检测逻辑不在客户端,本地改再多也没用:
客户端启动 ↓发送签名请求到服务器(请求里包含插件目录的 hash 列表) ↓服务器验证 hash 是否合规 ↓返回一次性 Token ↓客户端没有 Token → 无法使用联网功能internal static class ServerValidator{ private const string ValidateEndpoint = "https://your-server.com/api/validate";
internal static async Task<bool> ValidateAsync() { string pluginDir = Path.GetDirectoryName( Assembly.GetExecutingAssembly().Location)!;
// 计算插件目录所有 DLL 的 hash 列表 var hashes = Directory .EnumerateFiles(pluginDir, "*.dll") .Select(f => new { name = Path.GetFileName(f), hash = Convert.ToHexString( SHA256.HashData(File.ReadAllBytes(f))) });
// 发送给服务端验证 using var client = new HttpClient(); var response = await client.PostAsJsonAsync(ValidateEndpoint, hashes);
if (!response.IsSuccessStatusCode) return false;
// 服务端返回一次性 Token,后续联网功能需要带上 string token = await response.Content.ReadAsStringAsync(); SessionToken.Set(token); return true; }}**代价:**需要维护一个服务器,且离线玩家无法使用联网功能。适合竞技或排行榜类场景。
| 层级 | 方案 | 防护对象 | 被绕过难度 | 实现成本 |
|---|---|---|---|---|
| 1 | 混淆 + 字符串加密 | 静态分析 | 低(动态调试可绕) | 低 |
| 2 | IL 完整性自校验 | IL 篡改 | 中(改 hash 可绕) | 中 |
| 3 | 原生 DLL 检测 | 托管层修改 | 高(需 x64dbg + IDA) | 中 |
| 4 | 分散时机 + 蜜罐 | 定点清除 | 高(找不全触发点) | 低 |
| 5 | 服务端验证 | 所有本地修改 | 极高(逻辑不在本地) | 高 |
按需求选型:
没有 100% 防破解的方案,目标是让破解成本高于收益。
FinalSuspect 失败的原因不是技术不好,而是所有鸡蛋放在一个篮子里——找到检测代码在哪儿,一次性清空就结束了。真正有效的防护是多层、分散、跨信任层的组合。攻击者面对的不是”找到一个开关关掉”,而是”拆解整个防御体系的每一层”,破解成本自然就上去了。