游戏逆向初探
游戏逆向初探
参考视频:线上培训 -先知社区
Unity3D
Unity3D 是一个由 Unity Technologies 开发的 跨平台游戏引擎,主要用于制作 2D 和 3D 游戏、交互式模拟、虚拟现实(VR)、增强现实(AR)以及其他实时三维内容。
Unity3D 是一个 游戏引擎(Game Engine),简单来说,它是一个“做游戏的软件框架”。
使用 C# 编写逻辑控制(如人物移动、AI、UI响应等)。
Unity3D 中的 Mono 和 IL2CPP 是两种不同的脚本后端,它们负责将 C# 代码编译并运行在目标平台上。它们的主要区别在于编译方式、性能、兼容性、安全性等方面:
平台支持与兼容性:
- Mono: 支持广泛的平台,但在一些特定平台或架构上受限:
- iOS:不再被允许。Apple 禁止在 iOS 上使用 JIT 编译(安全原因)。因此 iOS 发布必须使用 IL2CPP。
- WebGL: 由于浏览器沙箱限制,无法进行 JIT 编译,因此 WebGL 必须使用 IL2CPP。
- 某些主机/嵌入式平台: 可能不支持 Mono 或其 JIT。
- IL2CPP:平台支持更广泛且是未来的方向。它是支持 iOS、WebGL、Universal Windows Platform (UWP) 和一些主机平台的唯一选择。几乎支持所有 Unity 目标平台。(iOS 和 WebGL: 强制使用 IL2CPP。)
安全性:
- Mono: 托管字节码 (.dll) 相对容易被反编译(使用工具如 ILSpy, dnSpy),代码逻辑和资源路径容易暴露。
- **IL2CPP:**安全性大大提高。将 C# 代码转换为 C++ 代码,再编译成机器码,使得反编译回原始 C# 逻辑变得极其困难(虽然反汇编原生代码是可能的,但理解成本极高)。是保护游戏逻辑和知识产权的更好选择。
如果 Unity 项目没有使用 IL2CPP(而是使用 Mono 作为脚本后端),那么 主要的游戏逻辑代码 都在 Assembly-CSharp.dll
中
例如TSCTF-J 2024 | Matriy’s blog的iPlayPingpang
对于一般的文件结构,都会有一个后缀为_Data的文件夹,并且里面有一个名为Managed的文件夹,而那个文件夹里的Assembly-CSharp.dll文件正是我们需要的东西,里面包含了作者的代码
1 | /Managed/ |
再介绍IL2CPP前,先介绍下IL
IL 的全称是 Intermediate Language(中间语言)它是 C# 编译后的中间形式,不是机器码,也不是源代码。
如一个C#代码:
1 | using System; |
用 C# 编译器(csc.exe
)编译后,会生成一个 Hello.exe
或 .dll
。但这个文件内部并不是 CPU 能直接运行的机器码,而是 IL 指令,例如:
1 | IL_0000: ldstr "Hello, World!" |
这就是 IL:一种介于高级语言(C#)和底层机器码之间的中间层语言。
让程序与 CPU 架构无关(跨平台)可以被 CLR(Common Language Runtime) 或 Mono Runtime 即时编译(JIT)成机器码提供了更强的反编译可能性(这也是为什么 .NET
程序容易被反编译)
类似一个面向对象的汇编语言,并且它是完全基于堆栈的,它运行在虚拟机上(.Net Framework, Mono VM)的语言。
具体过程是:C#或者VB这样遵循CLI规范的高级语言,被先被各自的编译器编译成中间语言:IL(CIL),等到需要真正执行的时候,这些IL会被加载到运行时库,也就是VM中,由VM动态的编译成汇编代码(JIT)然后在执行。
本质上说,到了IL这一层级,它是由哪门高级语言创建的也不是那么重要了,你可以用C#,VB,Boo,Unity Script甚至C++,只要有相应的编译器能够将其编译成IL都行
Mono 虚拟机 是一个用来执行 C#(IL)代码的跨平台运行时,类似于 Java 的 JVM。一般电脑不会自带,但 Unity 游戏会自动打包它
1 | C# 源码 |
JIT(即时编译)在运行时转换 IL → 机器码,有额外开销
IL2CPP 是 Unity 自己设计的一个编译后端,全称是 **Intermediate Language To C++**。它的作用就是把 IL(中间语言)转换成 C++ 代码,然后再编译成原生机器码。
简言之,就是把IL中间语言转换成CPP文件。大家如果看明白了上面动态语言的 CLI, IL以及VM,再看到IL2CPP一定心中充满了疑惑。现在的大趋势都是把语言加上动态特性,哪怕是c++这样的静态语言,也出现了适合IL的c++编译 器,为啥Unity要反其道而行之,把IL再弄回静态的CPP呢?
Mono VM在各个平台移植,维护非常耗时,有时甚至不可能完成
Mono的跨平台是通过Mono VM实现的,有几个平台,就要实现几个VM,像Unity这样支持多平台的引擎,Mono官方的VM肯定是不能满足需求的。所以针对不同的新平 台,Unity的项目组就要把VM给移植一遍,同时解决VM里面发现的bug。这非常耗时耗力。这些能移植的平台还好说,还有比如WebGL这样基于浏览 器的平台。要让WebGL支持Mono的VM几乎是不可能的。
Mono版本授权受限
很多C#的新特性无法使用。这是因为Mono 授权受限,导致Unity无法升级Mono。如果换做是IL2CPP,IL2CPP VM这套完全自己开发的组件,就解决了这个问题。
提高运行效率:根据官方的实验数据,换成IL2CPP以后,程序的运行效率有了1.5-2.0倍的提升。
既然 IL 是跨平台的,那为什么 Unity 还要舍弃它、转成 C++?这样不是反而变得更‘依赖平台’了吗?
Unity 引入 IL2CPP 的目的,其实不是“跨平台”,而是解决 性能 和 安全性 问题。
IL → C++:是“提前编译”(AOT),不是“放弃跨平台”
IL2CPP 并不是放弃跨平台,而是把“编译成机器码”的工作提前做好
Unity 仍然“跨平台”,但跨平台的责任从运行时转移到了编译阶段。
原来:
IL2CPP
PS:其实就是IL2CPP牺牲了Mono跨平台的优势来保证安全,快速
C#的产出必须为IL,这个是绕不开的(C# 是个很复杂的语言(泛型、委托、LINQ、async、反射、属性、接口、多继承模型、GC……),如果C#要到C++差不多是重新设计一个语言,不如利用IL来做一下适配)
对比 Mono 模式(早期 Unity)
在 Mono 模式 下:
- 游戏包里包含
Assembly-CSharp.dll
(IL 字节码)。 - 游戏运行时自带 Mono 虚拟机。
- Mono 会在运行时 即时编译(JIT) IL → 机器码。
也就是在你运行的时候Mono会翻译成机器码执行
而IL2CPP不一样,它是开发者直接转化除了CPP然后编译好给你,针对不同平台如ubuntu,ios都给一个,相当于牺牲了部分跨平台的优势
旧 Unity(Mono) = “运行时编译 IL”;
新 Unity(IL2CPP) = “提前编译 IL”。
Mono 模式:
游戏包里包含:
1 | /Managed/ |
IL2CPP 模式:
游戏包里包含:
1 | /libil2cpp.so ← 已经编译好的C++机器码 |
global-metadata.dat元数据表(类、方法、字符串等)
如SUSCTF2025的一道题为例:xxx
一般dll类型的unity游戏逆向,唯一核心就是逆向/修改某个 dll 文件就可以了。而一般IL2CPP的Unity3D游戏的逆向,大多只需要根据global-metadata.dat和libil2cpp.so来进行就可以了。目标异常明确,这也是 Unity3D 和 其它安卓逆向不同的地方。
IL2CPP的一些工具
恢复globalmetadata.dat里的方法,可以导入到libil2cpp.soPerfare/Il2CppDumper: Unity il2cpp reverse engineer
恢复一个dump.cs:需要有root机Perfare/Zygisk-Il2CppDumper: Using Zygisk to dump il2cpp data at runtime
global-metadata主要内容
Il2CppGlobalMetadataHeader:文件头部,包含数据偏移等信息。
Il2CppClassDefinition:所有类的信息。
Il2CppMethodDefinition:所有方法的信息(方法索引、类索引、返回值等)。
Il2CppFieldDefinition:所有字段信息(名称、偏移等)Il2CppStringLiteral:字符串字面量表。
关键数据:
methodPointers[]数组
metadataUsages[]数组
global-metadata中metadataUsagePairs存储了metadataUsages[]的索引和类型信息。
1 | ┌────────────────────────┐ |
1 | struct MetadataHeader { |
Unity一个比较显著的特点就是一切都由Manager实例进行管理。实际利用的时候,往往是找到对应类的get instance函数,直接获取该类实例的地址,然后调用其对应的成员函数,来获取实体列表等
在 Unity 引擎底层(尤其是 C++ 引擎代码中),所有子系统(场景、物理、渲染、音频、输入、资源等)都由各自的 Manager 类(管理器) 统一管理。
比如:
Manager 名称 | 管理的内容 |
---|---|
GameObjectManager |
所有场景中的游戏对象(GameObject) |
ComponentManager |
所有组件(Component) |
SceneManager |
场景加载与切换 |
PhysicsManager |
物理系统(刚体、碰撞体) |
AudioManager |
声音与音效 |
InputManager |
键鼠/手柄输入 |
ResourceManager |
资源加载与卸载 |
PlayerLoopManager |
主循环(每帧更新逻辑) |
这些类通常都被设计成 单例(Singleton),
也就是说:
在游戏运行过程中,全局只存在一个实例。
在单例模式中,一个类通常提供一个静态的访问方法,比如:
1 | class GameObjectManager { |
当你在逆向分析 Unity 游戏时(尤其是 IL2CPP 或 Native 段),很多有用的数据都藏在这些 Manager 实例 里。
比如:
- 所有当前场景的玩家对象(GameObject)
- 想枚举所有正在播放的音效
- 读取当前摄像机的参数
这些数据都不是独立散落的,而是存储在某个 Manager 结构中。所以逆向时的流程通常是:
- 找到
XXXManager::GetInstance()
的函数地址(单例获取器) - 调用它,拿到全局 Manager 的内存地址
- 从该对象的成员变量中,访问各种列表、容器、实体对象
举个例子(伪代码)
1 | // 1. 获取 GameObjectManager 的实例 |
在逆向时:
- 你在 IDA 里定位
GameObjectManager::GetInstance
; - Hook 或调用它;
- 从返回的地址(即单例实例)偏移出列表字段;
- 遍历得到所有对象指针。
U3D的防御一般是global-metadata.dat的加密,函数名混淆等
UE引擎
UE简介
UE 采用对象(UObject)管理系统,所有的UE反射系统管理的类都是UOject的继承,并由 UE的全局对象数组(GObjects)和名称表(GNames)进行管理。UE的反射系统存储了类、对象、变量、函数等信息,主要与GObjects和 GNames这两个全局数据结构有关。
这让引擎能:
- 在编辑器中自动显示属性;
- 支持序列化;
- 与蓝图交互;
- 实现运行时元信息访问。
UE 的底层是 纯 C++ 实现 的。游戏逻辑可以通过两种方式编写:
层 | 技术 | 特点 |
---|---|---|
高层 | Blueprint 蓝图 | 可视化逻辑、易上手 |
底层 | C++ | 性能高、可自定义引擎逻辑 |
比较项 | Unity3D | Unreal Engine 4 |
---|---|---|
开发语言 | C#(托管语言,基于 Mono/IL2CPP) | C++(原生代码) + Blueprint |
源码开放 | ❌ 部分封闭(引擎不开源) | ✅ 全部开源,可修改底层 |
脚本层机制 | Mono 虚拟机 / IL2CPP | 反射系统(UObject) |
渲染能力 | 中等偏上,适合中小型项目 | 顶级水准(AAA 级实时光照、PBR、Lumen) |
性能优化 | 依赖 IL2CPP 与引擎优化 | 原生 C++,性能极高 |
学习曲线 | 简单(C# + 可视化 Editor) | 较难(C++ + 架构复杂) |
典型用途 | 独立游戏、移动游戏、教育、AR/VR | 3A 游戏、影视、仿真、虚拟制作 |
代表作品 | 《原神》《饥荒》《空洞骑士》 | 《堡垒之夜》《PUBG》《黑神话:悟空》《古墓丽影》 |
资源生态 | Unity Asset Store | Unreal Marketplace |
平台适配 | 非常广(移动端、WebGL) | 偏向高性能设备(PC、主机) |
Uworld
管理关卡(Levels):1个 Uworld 包含多个ULevel,包括持久化关卡(PersistentLevel)和子关卡(Sub-Levels)。
管理所有的 Actor:AActor 是 游戏中的所有动态和静态对象 的基础:玩家、NPC、静态物体等等。拿到AActors列表也就拿到了当前游戏内的所有实体。其中AActor子类APawn是可由玩家或 AI控制的对象,通常就是需要拿到的玩家。
如下图,对于外挂而言,可以从UWord可以拿到所需要的几乎一切数据。
1 | UWorld |
引擎大致会经历以下过程:
1 | UWorld* World = NewObject<UWorld>(); |
蓝图(Blueprint)是 Unreal Engine 的可视化脚本系统(Visual Scripting System)。它允许你不用写 C++ 代码,就能用 拖拽节点 的方式来编写逻辑、控制游戏行为、操作对象。
可以把它理解为: 用图形化方式表示代码逻辑的脚本语言。
UFunction
UFunction
是 Unreal 引擎中用来描述 C++ 函数元信息(metadata)的类。
它不是一个“函数本身”, 而是一个用于记录函数信息的对象——例如函数名、参数类型、返回值、访问标志、蓝图可见性、所属类等等。
它继承自 UE 的反射基类 UField
→ UStruct
→ UFunction
。
1 | class UFunction : public UStruct { |
UE 的反射系统由三大支柱组成:
1 | UClass → 描述类 |
因此,UFunction
的作用可以概括为三点:
功能 | 说明 |
---|---|
反射调用 | 允许在运行时通过函数名调用函数,而不依赖编译时符号。 |
蓝图绑定 | 让蓝图能够调用或实现 C++ 函数。 |
网络与事件支持 | 用于标记函数为 RPC(客户端 / 服务器调用)。 |
假设写了一个 C++ 类:
1 | UCLASS() |
在编译时,UE 的反射系统(通过 UnrealHeaderTool)会自动为这个类生成元信息:
- 创建一个
UFunction
对象描述JumpHigher
; - 记录函数名、参数类型、返回值类型;
- 注册到
AMyCharacter
的UClass
里。
于是:
蓝图就能在节点里看到 “JumpHigher”;编辑器能知道它有一个 float Power
参数; 引擎运行时可以用字符串名调用它(反射)。
编译阶段
- UnrealHeaderTool 解析
.h
文件中UFUNCTION()
宏; - 为函数生成注册代码;
- 在运行时创建对应的
UFunction
对象并挂在所属UClass
上。
运行时阶段
- 引擎通过
UObject::FindFunction(FName("JumpHigher"))
找到对应的UFunction
; - 然后调用
UObject::ProcessEvent(UFunction* Function, void* Params)
执行; - 反射系统自动处理参数传递、返回值、事件广播等。
UFunction 的底层结构(内部成员)
在引擎源码中(UObject/Class.h
),UFunction
继承自 UStruct
,它保存了函数的完整签名信息:
1 | struct UFunction : public UStruct |
特别重要的是这一行:
1 | FNativeFuncPtr Func; |
它指向真正的 C++ 函数实现。反射系统在 ProcessEvent
时会用这个指针去调用底层实现。
在 C++ 层可以直接使用 UFunction
来调用函数:
1 | UObject* Obj = GetSomeUObject(); |
这就实现了“用字符串名调用函数”的效果(类似于 C# 的反射调用)
ProcessEvent
在源码中(UObject/ScriptCore.cpp
):
1 | virtual void ProcessEvent(UFunction* Function, void* Parameters); |
给定一个函数描述(UFunction
)和参数数据, ProcessEvent
就会执行这个函数,无论它是 C++ 实现、蓝图实现,还是网络 RPC。
ProcessEvent
= “执行一个函数(UFunction)+ 自动处理参数传递 + 调度蓝图或原生逻辑”。
1 | void UObject::ProcessEvent(UFunction* Function, void* Parameters) |
假设写了一个函数:
1 | UFUNCTION(BlueprintCallable) |
当蓝图中调用它时,流程如下:
1 | 蓝图节点点击 → 蓝图虚拟机 → ProcessEvent() |
换句话说:
蓝图、反射、RPC,全都用同一个执行路径:
UObject::ProcessEvent
。
也可以在 C++ 中手动使用 ProcessEvent
调用任意函数:
1 | UObject* Obj = GetSomeActor(); |
这就相当于运行时通过反射调用了 JumpHigher()
。
类似 C# 的:
1 | obj.GetType().GetMethod("JumpHigher").Invoke(obj, new object[]{500}); |
在 IL2CPP 或 UE Native 分析时:
ProcessEvent
是最常被 Hook 的函数之一;- 它几乎会被调用成千上万次;
- Hook 它可以打印出所有正在执行的蓝图/C++ 函数名称与参数;
- 在作弊、调试、自动化脚本中非常常用。
例如(伪代码):
1 | void (*original_ProcessEvent)(UObject*, UFunction*, void*); |
这能让你实时看到游戏中执行的每个函数调用,非常有价值。
为什么要通过ProcessEvent的方式去调用比如Obj->ProcessEvent(Func, &Params);,不能直接new个对象然后对象.方法调用吗,这么做的意义在哪?
假设我们在纯 C++ 世界里:
1 | AMyCharacter* Obj = new AMyCharacter(); |
这当然没问题,也最直接。编译器知道 AMyCharacter
类里有 JumpHigher()
方法,它能直接生成汇编调用代码,性能最高。
那为什么 UE 要绕一圈用 ProcessEvent()
呢?
因为 Unreal Engine 是一个“反射式运行时系统”,而不是普通的静态 C++ 程序。
UE 的目标不仅是跑一段逻辑,而是要:
- 让编辑器(Blueprint、Sequencer、AI、UI 等)动态识别和调用函数;
- 让引擎支持脚本层调用 C++ 层逻辑;
- 让游戏支持事件广播、网络RPC、热重载;
- 让蓝图 / 网络 / 编辑器 在不知道类型的情况下也能调用函数。
而普通的 Obj->JumpHigher()
是编译期绑定(compile-time binding),在运行时是无法通过字符串或元信息去找到它的。
模式 | 调用方式 | 特点 |
---|---|---|
静态绑定(C++ 调用) | Obj->JumpHigher(100) |
编译时确定函数地址;快但死板 |
动态绑定(反射调用) | Obj->ProcessEvent(Func, &Params) |
运行时查找函数并调用;灵活但慢 |
UE 设计的是一个可反射、可扩展的运行时系统,它必须能在不知道类型的情况下执行任意函数。
想象你做一个蓝图节点:
1 | [Call Function by Name] |
用户在蓝图里输入字符串:
1 | "JumpHigher" |
然后节点要去调用这个函数。
此时你只能这么写:
1 | UFunction* Func = Obj->FindFunction(TEXT("JumpHigher")); |
因为编译时你根本不知道会调用哪个函数,只能通过运行时的 字符串 → UFunction → ProcessEvent 动态调用。
virtual void ProcessEvent(UFunction* Function, void* Parameters);
有两个参数:
参数名 | 类型 | 含义 |
---|---|---|
Function | UFunction* |
描述要调用的函数的元信息对象 |
Parameters | void* |
指向参数数据结构的指针(通常是一个栈结构或用户定义的 struct) |
1 | UObject::ProcessEvent(UFunction* Func, void* Params) |
- ProcessEvent:统一入口
UObject::ProcessEvent(UFunction* Function, void* Params)
是统一调度口:做网络 RPC 判断、蓝图/原生分流、调试钩子等。- 判断是“原生函数”就走 Native 路径,否则走蓝图 VM。
- UFunction::Invoke / CallFunction
- 不同版本命名略有差异,本质是根据
UFunction
的元信息调用其绑定的原生函数指针。 - 这里会把
void* Params
/FFrame Stack
这样的参数区准备好,随后调用函数指针。
(*Func)(Obj, Stack, RESULT_PARAM)
Func
是UFunction
里保存的 原生函数指针(类型一般是FNativeFuncPtr
)。调用签名常见为:
1
using FNativeFuncPtr = void(*)(UObject* Context, FFrame& Stack, RESULT_DECL);
Obj/Context
:调用对象Stack
:参数解析用的执行栈(从里面按元信息取出各参数)RESULT_PARAM
/RESULT_DECL
:返回值写回位置
- *.gen.cpp 的 Thunk(桩函数)
- 这个函数指针 不是直接指向你写的 C++ 方法,而是指向 UHT 生成的“Thunk”(桩函数),文件名形如
YourClass.gen.cpp
。 - Thunk 的职责:
- 用宏从
FFrame
里解码参数(P_GET_PROPERTY
/P_GET_OBJECT
等) P_FINISH;
检查参数读取完毕- 调用真正实现:
- 普通
UFUNCTION
:P_THIS->YourFunc(DecodedParams...)
BlueprintNativeEvent
:调用YourFunc_Implementation(...)
- 普通
- 如果有返回值,写入
RESULT_PARAM
- 用宏从
典型生成代码(示意):
1 | // 定义由 UHT 生成的原生桩 |
- “真正的 C++ 函数”
就是你在类里写的那个实现:
1
2UFUNCTION(BlueprintCallable)
void JumpHigher(float Power) { /* 你的逻辑 */ }若是
BlueprintNativeEvent
:1
2
3
4UFUNCTION(BlueprintNativeEvent)
void DoX(int A);
void DoX_Implementation(int A) { /* 真正实现 */ }
// Thunk 会调用 _Implementation
- 为什么需要 Thunk?
- 编组/解组(marshalling):把反射/蓝图世界里的“无类型参数块(FFrame)”转换成强类型的 C++ 形参。
- 统一通道:同一套机制兼容蓝图、RPC、编辑器调用、反射调用。
- 元信息驱动:根据
UFunction
的Property
链表解参数/写返回值。 - 隔离生成代码:参数处理、错误检查、默认值、标志处理都放在自动生成的桩里,你的业务函数保持干净。
LineTraceSingleLineOfSightToStaticFindObject(光线类的效果,判断是否遮挡)
K2 DrawText,K2 DrawLine(引擎绘制)
防御
UE开发中,对引擎做改动是比较困难的,目前的游戏厂商常用的有以下的方式:
指针加密
在运行时不直接存放真实指针,而是存放加密后的值;每次使用前先解密,用完再丢弃或重新加密。
假设原本:
1 | UWorld* GWorld; |
被修改为:
1 | uintptr_t GWorld_Encrypted; |
在引擎访问全局对象时,都通过一个 解密函数:
1 | auto World = GetGWorld(); |
修改引擎关键结构体
官方 UE4(简化):
1 | struct FUObjectItem { |
被厂商修改为:
1 | struct FUObjectItem_Protected { |
或直接改偏移:
1 | // 原始偏移 0x10 改为 0x18 |
调换变量的顺序也行
所有现成的 SDK / Dumper 工具失效;
混淆函数名
UE 的反射系统和蓝图系统依赖字符串(如 UFunction
、UClass
名)。
在 global-metadata.dat
或 GNames
中可以看到很多原始函数名,例如:
1 | Function /Script/Game.Character.Jump |
这些字符串在反编译或 SDK Dump 时非常显眼。混淆函数名的思路是:
在打包或编译阶段对所有函数、类、属性名进行哈希或替换,保证引擎运行时可识别,但肉眼无法理解。
编译前:
1 | UFUNCTION(BlueprintCallable) |
混淆处理后(自动脚本):
1 | UFUNCTION(BlueprintCallable) |
或者动态加密:
1 | RegisterFunction(DecryptString("0xA93F1221B")); |
最终在反射表中看到的函数名就变成:
1 | Function /Script/Game.a5F4E1C8 |
更多可以看
[原创]UE4.27SDK-Dump-软件逆向-看雪论坛-安全社区|非营利性质技术交流社区
记录一次虚幻4(UE4)手游逆向 - 吾爱破解 - 52pojie.cn
逆向UE4最简单的一集 - 吾爱破解 - 52pojie.cn
这些博客
外挂原理
外挂主要有两种方式,跨进程和注入式dll
跨进程(External):作弊程序驻外部进程,通过读取/写入目标进程的内存、模拟输入、或监听渲染帧来实现功能;不把代码直接放入游戏进程。
做法
- 外部进程使用系统API(例如 Windows 的
ReadProcessMemory
/WriteProcessMemory
、GetWindowRect
、SendInput
、DirectX hook 外挂用的显存读取或重放)访问或影响目标。 - 通过画面采样、内存读取或图像识别(OCR/模板匹配)来获得游戏状态。
- 模拟鼠标/键盘输入或通过网络包修改(如果可能)来实现动作。
优点
- 不直接修改目标进程代码,风险较低(崩溃/暴露面较小)。
- 实现门槛低(不用解决注入/进程内同步复杂性)。
- 容易用普通权限运行(不需要内核权限)。
缺点
- 能力受限:不能直接调用内部函数或拦截渲染管线(功能受限,信息采集可能不完整)。
- 性能和精确度可能低(例如帧间延迟、画面识别误判)。
- 某些反作弊对跨进程访问检测严格(如检测异常的 OpenProcess/ReadProcessMemory 调用、异常句柄等)。
检测点(反作弊)
- 监控外部进程对游戏的 OpenProcess/ReadProcessMemory/WriteProcessMemory/WriteFile/SetWindowsHookEx 等调用或异常句柄/命名对象。
- 检测鼠标/键盘输入的可疑模式(硬件生成 vs 软件注入差异)。
- 检测画面采样/帧缓冲读取或重复的屏幕抓取行为。
注入式 DLL(Injected):把一段 DLL 或代码注入到游戏进程地址空间中,代码直接运行在游戏上下文里,能直接调用游戏内部函数 / 操作内存 / 截获渲染与输入。
做法(高层)
- 将自定义 DLL 注入到游戏进程(通过 CreateRemoteThread+LoadLibrary、SetWindowsHookEx、APC、manual mapping 等方式),在进程内加载后直接 hook 函数、patch 内存、截获 DirectX/OpenGL/Vulkan 调用或直接修改游戏状态(内存/对象指针)。
- 也可能用 inline hook、vtable hook、IAT hook、inline patch、函数 trampolines 等技术直接替换/拦截函数。
优点
- 功能强大:可以直接操作游戏对象、调用内核函数、拦截渲染/网络栈,精确度高、延迟低。
- 可以实现隐蔽的“内部”逻辑(例如修改游戏状态而不触发外部可见的访问)。
缺点
- 风险更高:进程崩溃、符号暴露、注入行为容易被检测到(例如扫描已加载模块、异常页属性、线程注入痕迹)。
- 反作弊防护(内核防护、签名白名单、完整性校验)会重点拦截注入行为。
- 注入技术和绕过反作弊往往触及安全/法律灰区。
检测点(反作弊)
- 检查进程模块列表(不应出现未知模块),校验模块签名/完整性(代码签名或哈希)。
- 监控进程创建线程/远程加载库等操作的来源。
- 使用内核级驱动(或防护内核模式签名)来检测和阻断注入(例如禁止未签名驱动、阻断 CreateRemoteThread 目标进程等)。
其他常见变体(补充)
- 内核级外挂(Kernel-mode / Driver cheats):通过安装驱动绕过用户态检查(可读写任意进程内存、隐藏进程、hook 内核函数)。能力极强但需要签名/权限,且更容易被检测/追责。
- 网络层作弊(Packet manipulation / MITM):拦截/修改网络数据包(可能在客户端和服务器间伪造状态)。
- 模拟器/客户端修改:在模拟器或改造的客户端上直接修改逻辑(例如改协议、修改资源文件)。
- 外部硬件或辅助设备:硬件宏、专用操控器、FPGA 等在硬件层面模拟或加速作弊动作。
待补充…
Ring3常见反作弊手段
“Ring3” 是操作系统底层架构中的一个非常重要的概念,尤其是在安全、逆向、驱动开发中经常出现。要理解它,先要从 CPU 的 特权级(Privilege Level) 机制讲起。
名称 | 特权级别 | 说明 |
---|---|---|
Ring0 | 最高权限 | 操作系统内核(Kernel Mode) |
Ring1 | 较高权限 | 旧架构中用于驱动(现代系统很少用) |
Ring2 | 较低中间层 | 保留或未使用 |
Ring3 | 最低权限 | 用户程序运行环境(User Mode) |
大多数现代操作系统(如 Windows、Linux)只实际使用:
- Ring0:内核态
- Ring3:用户态
1 | ┌───────────────┐ |
VT 全称是 **Intel VT-x (Virtualization Technology)**,AMD 的对应技术叫 AMD-V。
它是 硬件级虚拟化支持,允许一个物理 CPU 通过硬件机制运行多个“虚拟机” (Virtual Machine)。
简单说:VT 就是让虚拟机(如 VMware、VirtualBox、Hyper-V)能高效运行的“硬件加速层”。
许多分析人员、CTF 环境、恶意代码研究者,都在虚拟机里运行样本(方便快照和隔离)。
于是,恶意程序会想办法检测自己是不是运行在虚拟机环境中。这类技术叫做 **反虚拟化 (Anti-VM)**,也经常和反调试配合使用。
当 VT开启时,CPU 允许进入一种叫 VMX root / non-root mode 的模式:
- Root mode:运行 VMM(虚拟机管理器,例如 VMware / Hyper-V)
- Non-root mode:运行虚拟机里的客体系统(guest OS)
当虚拟机里执行某些敏感指令(如 CPUID、SIDT、SGDT、IN、OUT 等)时,CPU 会把控制权交还给 VMM —— 这就是所谓的 VM exit。
在硬件虚拟化(Intel VT-x 或 AMD-V)体系中,CPU 和 内存 被划分为三个逻辑层:
层级 | 名称 | 运行内容 | 特征 |
---|---|---|---|
Host(宿主机) | 宿主操作系统 / Hypervisor | 真实运行在物理硬件上 | 拥有最高控制权 |
Guest(客户机) | 被虚拟化出来的“虚拟电脑” | 运行自己的 OS(例如虚拟机中的 Windows 或 Linux) | 受 Hypervisor 控制 |
Hardware(物理层) | CPU、内存、I/O 设备 | 由 VT 机制分时提供给各 Guest | 提供虚拟化支持指令(VMX / SVM) |
状态 | 描述 |
---|---|
VM-entry | CPU 从 Hypervisor 进入 Guest 开始执行 → Guest 代码运行在 VMX non-root mode。 |
VM-exit | 当 Guest 执行敏感指令(如 IO、CPUID、HLT、VMCALL 等)或发生异常时,CPU 自动退出 Guest 模式,把控制权交回 Hypervisor (Host)。 |
Hypervisor 处理事件 | Hypervisor 检查发生了什么(例如 Guest 访问了被禁止的寄存器),修改状态或模拟结果。 |
再次 VM-entry | Hypervisor 处理完毕后再让 CPU 返回 Guest 继续执行。 |
术语 | 含义 | 所在层级 |
---|---|---|
Host | 物理机 + 宿主操作系统 | 真实硬件 |
Hypervisor (VMM) | 虚拟机监控器,调度 Guest 运行 | VMX root mode |
Guest | 被虚拟化出来的操作系统或程序 | VMX non-root mode |
如
角色 | 谁在做 |
---|---|
Host(宿主) | 你的物理机系统,比如 Windows 11 或 Linux + VMware Workstation |
Hypervisor(虚拟机管理器) | VMware 内核驱动或 Hyper-V 核心 |
Guest(客户机) | VMware 中的 Windows 10;它的 kernel 在 CPU 的 VMX non-root mode 运行 |
单步异常在 VT 下导致 RIP 指向错误(RIP 指向超前)
在裸机上,CPU 单步(trap on single-step / TF 标志)会在每条指令后产生 #DB
(debug exception),由调试器/操作系统处理;处理完通常把 RIP
指向下一条要执行的指令(或按调试器修改的上下文继续)。
在硬件虚拟化(VT-x/AMD-V)场景下,guest 的某些异常或控制事件需要由 hypervisor 处理:这会产生 VM-exit(把执行从 guest 切回到 hypervisor/host),hypervisor 处理完后再 VM-entry 回到 guest。
如果 hypervisor(或虚拟化实现)在处理 VM-exit/VM-entry 时的上下文恢复/单步逻辑有 bug(比如没有正确更新或保存 guest 的 TF、RIP、或没有把单步后的指令指针正确设置),那么当异常/单步完成后,guest 的 RIP
可能不会精确指向“应该执行的下一条指令”,而是“超前”到更后面的指令,从而跳过某些指令(例如 nop
后面的指令被执行,或相反)。
为什么会发生
- VM-exit/entry 路径要保存/恢复大量 CPU 状态(RIP、RFLAGS、寄存器、MSR 等)。实现复杂、边界条件多。某些 hypervisor/虚拟化产品在处理单步(TF)或调试中断时逻辑不完善或优化错误,就会把 RIP 恢复错位。
- 有时 hypervisor 为了“优化”对单步的支持,会通过硬件单步模拟、软件插桩或修改跳转逻辑来避免大量 VM-exit,但这样更容易出错。
典型现象
在真实情况下,单步后 RIP 应该指向
nop
指令(或下一条指令);但在有些 VT 实现中,单步返回后 RIP 指向nop
之后的下一条有效指令(也就是跳过了nop
的执行点或多执行了一条),造成控制流不一致。
EPThook(EPT Hook)检测与时间侧信道
EPT 简单回顾
- EPT(Intel 的 Extended Page Table)是 hypervisor 用来把 Guest-physical 映射到 Host-physical 的硬件页表(第二层页表)。Hypervisor 可以通过修改 EPT 条目控制某个 guest 页面是否可读/可写/可执行,并在访问被禁止时触发 EPT violation → VM-exit。
- 基于 EPT 的 hook(EPThook)通常做法:把目标页的 EPT 条目去掉 execute 权或读写权限。Guest 一旦尝试执行该页面或读写该页面,就会触发 EPT-violation(VM-exit),由 hypervisor 在 host 端处理(记录、修改、跳转等),然后再返回 guest。
一些VT会开启EPTHook,要检测EPTHook,和检测VT状态的思路一致。当一个页面被EPTHook了,执行所需的时间远比读取页面的时间要长。因为读取页面不需要频繁的换入换出页面,而先读写后执行则会在在执行和读取页面之间交替换入换出,从而浪费大量时间。
cpu_id检测
CPUID
是一条 x86 架构的处理器指令 用于查询 CPU 的信息(厂商、型号、功能支持、虚拟化状态、缓存大小、是否有超线程等)。
很多虚拟化平台(VMware、VirtualBox、Hyper-V、QEMU)以及沙箱环境都会在
CPUID
的返回值中留下特征。
1 | mov eax, 1 |
ECX bit31 | 状态 |
---|---|
0 | 物理机(bare-metal) |
1 | 虚拟化环境(VMware/VirtualBox/Hyper-V 等) |
1 | int cpu_info[4]; |
E8 检查(E8 Check) 是一种经典的 反外挂 / 反破解 / 调用完整性检查机制,它的名字来源于 x86 指令集中 CALL
指令的机器码 **0xE8
**。
节 E8
表示一条相对调用(CALL rel32
),后面跟 4 字节相对偏移。
当 CPU 执行 CALL
时,会做两件事:
- 把下一条指令的地址压入栈(即返回地址);
- 跳转到目标函数。
例如:
1 | 00401000: E8 1B 00 00 00 call 00401020 |
当执行 call 00401020
时,CPU 会:
- 压栈返回地址:
00401005
; - 跳转到
00401020
。
E8 检查 = 检查调用栈中返回地址的前一条指令是否真的是 CALL(E8 开头)。
例如游戏里某个敏感函数 GetKey()
,只允许被合法逻辑调用。
外挂可能想绕过逻辑直接“跳过去”调用,于是手工修改栈或直接 JMP 到 GetKey
,不走真正的 CALL
。
程序为了防御这种“伪造调用”,就在函数开头做检查:
1 | GetKey: |
如果返回地址前一字节确实是 0xE8 → 说明这个函数是通过
CALL
指令正常被调用的。如果不是 → 说明是外挂或异常跳转 → 触发异常 / 拒绝执行。
按机器特征分发可执行文件 + GetKey
“返回地址检查由客户端分发游戏文件,根据机器某些软硬件特征分发不同的可执行文件,让
GetKey
函数对不同用户不同,外挂不得不调用该函数来获取解密密钥。”
解释:
- 服务端根据机器指纹(例如 CPU id、硬盘序列号、MAC、TPM 等)为每台机器生成不同的可执行文件或不同的 key/逻辑。
- 游戏里有一个
GetKey()
(或类似)函数负责根据本机特征生成/返回解密密钥或解密流程的关键信息。 - 这样,外挂如果要解密/获得游戏内部关键数据,就必须在进程内以正确方式调用
GetKey()
(或模拟其逻辑)。外部简单修改同一文件在别的机器上不一定生效,从而提高逆向门槛。
用途:这是“按机打包 + 绑定密钥”的反破解手段——增加每台机器的差异,降低通用补丁/外挂的可用性。
之前的绕过思路(在没有 E8 检查时)
“在没有 E8 检查之前,可以找到主模块内某一个
ret
指令的地址,将当前位置压栈、压入ret
指令地址、jmp到目标函数。”
解释(高层):
- 攻击者在游戏的主模块里找一个合适的
ret
指令地址(或者任意可返回的地址),构造栈上返回地址,然后直接jmp
到目标函数(比如GetKey
)去调用它。 - 手法本质上是伪造调用栈:把“返回地址”放到栈上并跳转,让目标函数执行完后以伪造的返回地址回到某处,从而避开正常调用约束或上下文检查。
- 这种方法通常用于“进程内调用”但不走正常调用路径(绕过某些前置检查、非法构造调用上下文)。
这是在没有更严格检测时一种常见的内存/控制流操纵技巧。
加入 E8 检查后老方法作废
“对抗:用 Zyais 框架动态分析指令,去除返回地址检查,修补变量地址后调用。” —— 高层含义
- 这句话意思不是说具体如何去做绕过,而是在描述一种高级逆向/动态分析思路的概念性流程:
- 用动态二进制分析/执行框架(提到的 “Zyais”——理解为某种动态分析或动态二进制翻译/插桩工具)对程序运行时的二进制指令进行动态跟踪/修改。
- 找到并识别出“返回地址检查”的那段代码(即验证
E8
的检测逻辑)。 - 在运行时把这段检查绕过或修改(去除/补丁),或者直接在内存中修补相关变量/校验数据,使检查通过。
- 修补完检测/上下文后,再安全地调用目标函数(例如
GetKey
)。
- 换句话说:利用动态分析与内存/指令级修补,在运行时消除或中和防护逻辑,从而恢复对目标函数的可控调用。
防止hook:
CRC32检查和其他定时对.text段进行CRC32完整性检查,
对抗:硬件断点VEH HOOK
攻击者不会直接在目标函数序列上打软件补丁(修改 .text
),而是通过硬件断点(CPU Debug Registers)或注册 VEH(Vectored Exception Handler) 来拦截执行流并在运行时修改寄存器/内存或模拟函数结果。
这样做好处是:.text
没有永久被改写(CRC 在内存视图上仍然是原始),检查 .text
的哈希会通过;但是实际运行时可以在断点触发处动态改变行为(比如在函数入口前拦截并直接返回)。
对抗:NtQuerySystemInformation查询添加的VEH
有人会尝试用系统查询接口来“发现”某些异常处理器或调试对象(比如枚举句柄/系统句柄表来发现调试器/VEH 的痕迹),而攻击者会 hook/伪造这些系统调用来隐藏自己。
对抗:下硬件断点,HookKiUserExceptionHandler不经过异常分派直接处理异常
内核或 NTDLL 里有一条把异常分派给用户态的“桥”/入口(例如 KiUserExceptionDispatcher
/ RtlDispatchException
等在异常分派链上关键函数)。
攻击者可以 hook 或替换该路径(例如替换 KiUserExceptionDispatcher
指向的地址或在内核安装 hook),使得当异常发生时 不经过正常的异常分派链(VEH/SEH)而被攻击者的代码直接处理,从而绕过被用于检测的 handler。
另一种是直接在内核 / ntdll 层挂钩并自己处理异常,或把异常转交给调试器,令目标程序的检测逻辑根本看不到异常发生(或在异常到达检测点前被“吞掉”)。
对抗:添加自己的异常处理器,主动用INT3触发异常,检测分派到自己的异常处理后的寄存器,并进行栈回溯
- 防护代码在进程内部注册自己的 VEH/SEH,并主动触发一个受控异常(例如
INT3
或RaiseException
)。该异常在正常情况下会先到达系统/调试层,再进到进程的 VEH/SEH。 - 检测点:在自家的 handler 中,防护逻辑会检查:
- 寄存器上下文(例如 RIP/EIP、RSP/ESP、寄存器值)是否和触发点一致(或是否被篡改);
- 栈回溯(call stack/backtrace),验证异常来源是预期的调用序列(比如返回地址点位于本模块
.text
的合法位置,并且调用者不是第三方模块); - 返回地址/调用序列的完整性(比如前面你提过的 E8 检查,或更强的 call-stack hash)。
如果这些检查发现异常在到达 handler 前被拦截、修改或被不合法的上下文处理(例如寄存器被替换,返回地址不合法),就可以判定存在 hook/中间人(例如 VEH 被拦截,或 KiUserExceptionDispatcher 被 Hook)。