游戏逆向初探

参考视频:线上培训 -先知社区

Unity3D

Unity将来时:IL2CPP是什么? - 知乎

Unity3D 是一个由 Unity Technologies 开发的 跨平台游戏引擎,主要用于制作 2D 和 3D 游戏、交互式模拟、虚拟现实(VR)、增强现实(AR)以及其他实时三维内容

Unity3D 是一个 游戏引擎(Game Engine),简单来说,它是一个“做游戏的软件框架”。

使用 C# 编写逻辑控制(如人物移动、AI、UI响应等)。

Unity3D 中的 MonoIL2CPP 是两种不同的脚本后端,它们负责将 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
2
3
4
5
6
/Managed/
├── Assembly-CSharp.dll ← 游戏脚本主逻辑
├── Assembly-CSharp-firstpass.dll ← 插件或早期编译的脚本
├── UnityEngine.CoreModule.dll ← Unity 官方 API
├── mscorlib.dll ← .NET 基础类库
└── System.dll / System.Core.dll ← .NET 组件

再介绍IL2CPP前,先介绍下IL

IL 的全称是 Intermediate Language(中间语言)它是 C# 编译后的中间形式,不是机器码,也不是源代码。

如一个C#代码:

1
2
3
4
5
6
7
using System;

class Hello {
static void Main() {
Console.WriteLine("Hello, World!");
}
}

用 C# 编译器(csc.exe)编译后,会生成一个 Hello.exe.dll。但这个文件内部并不是 CPU 能直接运行的机器码,而是 IL 指令,例如:

1
2
3
IL_0000: ldstr "Hello, World!"
IL_0005: call void [System.Console]System.Console::WriteLine(string)
IL_000a: ret

这就是 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
2
3
4
5
6
7
8
9
C# 源码

C# 编译器 (Roslyn)

生成 IL (中间语言)

Mono 虚拟机(Mono Runtime)

在运行时将 IL 即时编译成机器码(JIT)

JIT(即时编译)在运行时转换 IL → 机器码,有额外开销

IL2CPP 是 Unity 自己设计的一个编译后端,全称是 **Intermediate Language To C++**。它的作用就是把 IL(中间语言)转换成 C++ 代码,然后再编译成原生机器码。

简言之,就是把IL中间语言转换成CPP文件。大家如果看明白了上面动态语言的 CLI, IL以及VM,再看到IL2CPP一定心中充满了疑惑。现在的大趋势都是把语言加上动态特性,哪怕是c++这样的静态语言,也出现了适合IL的c++编译 器,为啥Unity要反其道而行之,把IL再弄回静态的CPP呢?

  1. Mono VM在各个平台移植,维护非常耗时,有时甚至不可能完成

    Mono的跨平台是通过Mono VM实现的,有几个平台,就要实现几个VM,像Unity这样支持多平台的引擎,Mono官方的VM肯定是不能满足需求的。所以针对不同的新平 台,Unity的项目组就要把VM给移植一遍,同时解决VM里面发现的bug。这非常耗时耗力。这些能移植的平台还好说,还有比如WebGL这样基于浏览 器的平台。要让WebGL支持Mono的VM几乎是不可能的。

  2. Mono版本授权受限

    很多C#的新特性无法使用。这是因为Mono 授权受限,导致Unity无法升级Mono。如果换做是IL2CPP,IL2CPP VM这套完全自己开发的组件,就解决了这个问题。

  3. 提高运行效率:根据官方的实验数据,换成IL2CPP以后,程序的运行效率有了1.5-2.0倍的提升。

既然 IL 是跨平台的,那为什么 Unity 还要舍弃它、转成 C++?这样不是反而变得更‘依赖平台’了吗?

Unity 引入 IL2CPP 的目的,其实不是“跨平台”,而是解决 性能安全性 问题。

IL → C++:是“提前编译”(AOT),不是“放弃跨平台”

IL2CPP 并不是放弃跨平台,而是把“编译成机器码”的工作提前做好

Unity 仍然“跨平台”,但跨平台的责任从运行时转移到了编译阶段

原来:

image-20251005162206178

IL2CPP

image-20251005162235376

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
2
3
4
5
6
/Managed/
├─ Assembly-CSharp.dll ← 脚本IL
├─ mscorlib.dll
└─ UnityEngine.CoreModule.dll
/Mono/
├─ mono.dll ← 虚拟机执行IL

IL2CPP 模式:

游戏包里包含:

1
2
/libil2cpp.so             ← 已经编译好的C++机器码
/global-metadata.dat ← 元数据表

global-metadata.dat元数据表(类、方法、字符串等)

如SUSCTF2025的一道题为例:xxx

一般dll类型的unity游戏逆向,唯一核心就是逆向/修改某个 dll 文件就可以了。而一般IL2CPP的Unity3D游戏的逆向,大多只需要根据global-metadata.dat和libil2cpp.so来进行就可以了。目标异常明确,这也是 Unity3D 和 其它安卓逆向不同的地方。

奇安信攻防社区-浅谈CTF中的unity游戏逆向

image-20251005164925430

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
2
3
4
5
6
7
┌────────────────────────┐
│ MetadataHeader │← 文件头:记录各段偏移与长度
├────────────────────────┤
│ Metadata Tables │← 核心:各种定义表(Type/Method/Field等)
├────────────────────────┤
│ String Literal Section │← 字符串字面量常量区(如类名、方法名)
└────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct MetadataHeader {
int signature; // 魔数标识 (0xFAB11BAF)
int version; // 版本号(24~29等)
int stringLiteralOffset;
int stringLiteralCount;
int stringLiteralDataOffset;
int stringLiteralDataCount;
int stringOffset;
int stringCount;
int eventsOffset;
int eventsCount;
int propertiesOffset;
int propertiesCount;
int methodsOffset;
int methodsCount;
int parameterDefaultValuesOffset;
...
};

Unity一个比较显著的特点就是一切都由Manager实例进行管理。实际利用的时候,往往是找到对应类的get instance函数,直接获取该类实例的地址,然后调用其对应的成员函数,来获取实体列表等

在 Unity 引擎底层(尤其是 C++ 引擎代码中),所有子系统(场景、物理、渲染、音频、输入、资源等)都由各自的 Manager 类(管理器) 统一管理。

比如:

Manager 名称 管理的内容
GameObjectManager 所有场景中的游戏对象(GameObject)
ComponentManager 所有组件(Component)
SceneManager 场景加载与切换
PhysicsManager 物理系统(刚体、碰撞体)
AudioManager 声音与音效
InputManager 键鼠/手柄输入
ResourceManager 资源加载与卸载
PlayerLoopManager 主循环(每帧更新逻辑)

这些类通常都被设计成 单例(Singleton)
也就是说:

在游戏运行过程中,全局只存在一个实例。

在单例模式中,一个类通常提供一个静态的访问方法,比如:

1
2
3
4
5
class GameObjectManager {
public:
static GameObjectManager* GetInstance(); // 获取唯一实例
void UpdateAllObjects();
};

当你在逆向分析 Unity 游戏时(尤其是 IL2CPP 或 Native 段),很多有用的数据都藏在这些 Manager 实例 里。

比如:

  • 所有当前场景的玩家对象(GameObject)
  • 想枚举所有正在播放的音效
  • 读取当前摄像机的参数

这些数据都不是独立散落的,而是存储在某个 Manager 结构中。所以逆向时的流程通常是:

  1. 找到 XXXManager::GetInstance() 的函数地址(单例获取器)
  2. 调用它,拿到全局 Manager 的内存地址
  3. 从该对象的成员变量中,访问各种列表、容器、实体对象

举个例子(伪代码)

1
2
3
4
5
6
7
8
// 1. 获取 GameObjectManager 的实例
GameObjectManager* mgr = GameObjectManager::GetInstance();

// 2. 调用成员函数或直接访问成员变量
std::vector<GameObject*> allObjects = mgr->GetActiveObjects();
for (auto obj : allObjects) {
printf("Name = %s\n", obj->name);
}

在逆向时:

  • 你在 IDA 里定位 GameObjectManager::GetInstance
  • Hook 或调用它;
  • 从返回的地址(即单例实例)偏移出列表字段;
  • 遍历得到所有对象指针。

U3D的防御一般是global-metadata.dat的加密,函数名混淆等

UE引擎

UE简介

UE 采用对象(UObject)管理系统,所有的UE反射系统管理的类都是UOject的继承,并由 UE的全局对象数组(GObjects)和名称表(GNames)进行管理。UE的反射系统存储了类、对象、变量、函数等信息,主要与GObjects和 GNames这两个全局数据结构有关。

image-20251005172923941

这让引擎能:

  • 在编辑器中自动显示属性;
  • 支持序列化;
  • 与蓝图交互;
  • 实现运行时元信息访问。

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可以拿到所需要的几乎一切数据。

image-20251005173729067

1
2
3
4
5
6
7
8
9
10
11
UWorld
├── PersistentLevel ← 当前持久关卡
│ ├── AActor (角色、物体)
│ │ └── UComponent(组件,如Mesh, Collider, etc.)
│ ├── PlayerController / Pawn / Camera
│ └── Light, Sky, Landscape...
├── StreamingLevels[] ← 流式加载的子关卡
├── GameState / GameMode
├── NavigationSystem ← AI导航网格
├── PhysicsScene / Scene ← 物理世界
└── LevelScriptActor ← 关卡逻辑脚本

引擎大致会经历以下过程:

1
2
3
4
UWorld* World = NewObject<UWorld>();
World->InitializeNewWorld();
World->LoadPersistentLevel(MapName);
World->BeginPlay();

蓝图(Blueprint)是 Unreal Engine 的可视化脚本系统(Visual Scripting System)。它允许你不用写 C++ 代码,就能用 拖拽节点 的方式来编写逻辑、控制游戏行为、操作对象。

可以把它理解为: 用图形化方式表示代码逻辑的脚本语言。

UFunction

UFunction 是 Unreal 引擎中用来描述 C++ 函数元信息(metadata)的类。

它不是一个“函数本身”, 而是一个用于记录函数信息的对象——例如函数名、参数类型、返回值、访问标志、蓝图可见性、所属类等等。

它继承自 UE 的反射基类 UFieldUStructUFunction

1
2
3
4
5
6
7
8
9
class UFunction : public UStruct {
public:
uint32 FunctionFlags; // 函数修饰符
uint8 NumParms; // 参数数量
uint16 ParmsSize; // 参数总字节数
uint16 ReturnValueOffset; // 返回值偏移
EFunctionFlags Flags; // 执行标志(Native, BlueprintCallable 等)
...
};

UE 的反射系统由三大支柱组成:

1
2
3
UClass     → 描述类
UProperty → 描述类中的变量
UFunction → 描述类中的函数

因此,UFunction 的作用可以概括为三点:

功能 说明
反射调用 允许在运行时通过函数名调用函数,而不依赖编译时符号。
蓝图绑定 让蓝图能够调用或实现 C++ 函数。
网络与事件支持 用于标记函数为 RPC(客户端 / 服务器调用)。

假设写了一个 C++ 类:

1
2
3
4
5
6
7
8
9
UCLASS()
class AMyCharacter : public ACharacter
{
GENERATED_BODY()

public:
UFUNCTION(BlueprintCallable, Category="Player")
void JumpHigher(float Power);
};

在编译时,UE 的反射系统(通过 UnrealHeaderTool)会自动为这个类生成元信息:

  • 创建一个 UFunction 对象描述 JumpHigher
  • 记录函数名、参数类型、返回值类型;
  • 注册到 AMyCharacterUClass 里。

于是:

蓝图就能在节点里看到 “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
2
3
4
5
6
7
8
9
struct UFunction : public UStruct
{
EFunctionFlags FunctionFlags; // 标志(是否可蓝图、是否原生、是否RPC)
uint8 NumParms; // 参数数量
uint16 ParmsSize; // 参数区大小
uint16 ReturnValueOffset; // 返回值偏移
uint8 RepOffset; // 网络同步偏移
FNativeFuncPtr Func; // 指向原生C++函数的指针
};

特别重要的是这一行:

1
FNativeFuncPtr Func;

它指向真正的 C++ 函数实现。反射系统在 ProcessEvent 时会用这个指针去调用底层实现。

在 C++ 层可以直接使用 UFunction 来调用函数:

1
2
3
4
5
6
7
8
9
10
11
UObject* Obj = GetSomeUObject();
UFunction* Func = Obj->FindFunction(TEXT("JumpHigher"));
if (Func)
{
struct
{
float Power;
} Params;
Params.Power = 100.0f;
Obj->ProcessEvent(Func, &Params);
}

这就实现了“用字符串名调用函数”的效果(类似于 C# 的反射调用)

ProcessEvent

在源码中(UObject/ScriptCore.cpp):

1
virtual void ProcessEvent(UFunction* Function, void* Parameters);

给定一个函数描述(UFunction)和参数数据, ProcessEvent 就会执行这个函数,无论它是 C++ 实现、蓝图实现,还是网络 RPC。

ProcessEvent = “执行一个函数(UFunction)+ 自动处理参数传递 + 调度蓝图或原生逻辑”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void UObject::ProcessEvent(UFunction* Function, void* Parameters)
{
// 处理网络RPC(Server/Client)
if (Function->FunctionFlags & FUNC_Net)
{
// 可能是远程调用,走网络同步流程
CallRemoteFunction(Function, Parameters);
return;
}

// 调用C++原生函数(如果有实现)
if (Function->Func)
{
// 调用真实C++函数指针
Function->Func(this, Parameters);
}
else
{
// 蓝图函数(无原生实现) → 由脚本VM执行
CallScriptFunction(Function, Parameters);
}
}

假设写了一个函数:

1
2
UFUNCTION(BlueprintCallable)
void JumpHigher(float Power);

当蓝图中调用它时,流程如下:

1
2
3
4
5
6
7
8
9
蓝图节点点击 → 蓝图虚拟机 → ProcessEvent()

找到 JumpHigher 对应的 UFunction

准备参数区 (float Power)

ProcessEvent(JumpHigher, &Params)

调用 UFunction::Func (指向 C++ 实现)

换句话说:

蓝图、反射、RPC,全都用同一个执行路径:UObject::ProcessEvent

也可以在 C++ 中手动使用 ProcessEvent 调用任意函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
UObject* Obj = GetSomeActor();
UFunction* Func = Obj->FindFunction(TEXT("JumpHigher"));
if (Func)
{
struct
{
float Power;
} Params;

Params.Power = 500.f;

Obj->ProcessEvent(Func, &Params);
}

这就相当于运行时通过反射调用了 JumpHigher()

类似 C# 的:

1
obj.GetType().GetMethod("JumpHigher").Invoke(obj, new object[]{500});

在 IL2CPP 或 UE Native 分析时:

  • ProcessEvent 是最常被 Hook 的函数之一;
  • 它几乎会被调用成千上万次;
  • Hook 它可以打印出所有正在执行的蓝图/C++ 函数名称与参数;
  • 在作弊、调试、自动化脚本中非常常用。

例如(伪代码):

1
2
3
4
5
6
7
void (*original_ProcessEvent)(UObject*, UFunction*, void*);

void hooked_ProcessEvent(UObject* Obj, UFunction* Func, void* Params)
{
printf("[CALL] %s::%s\n", *Obj->GetName(), *Func->GetName());
original_ProcessEvent(Obj, Func, Params);
}

这能让你实时看到游戏中执行的每个函数调用,非常有价值。

为什么要通过ProcessEvent的方式去调用比如Obj->ProcessEvent(Func, &Params);,不能直接new个对象然后对象.方法调用吗,这么做的意义在哪?

假设我们在纯 C++ 世界里:

1
2
AMyCharacter* Obj = new AMyCharacter();
Obj->JumpHigher(100.f);

这当然没问题,也最直接。编译器知道 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
2
3
4
5
6
UFunction* Func = Obj->FindFunction(TEXT("JumpHigher"));
if (Func)
{
struct { float Power; } Params = { 100.0f };
Obj->ProcessEvent(Func, &Params);
}

因为编译时你根本不知道会调用哪个函数,只能通过运行时的 字符串 → UFunction → ProcessEvent 动态调用。

virtual void ProcessEvent(UFunction* Function, void* Parameters);

有两个参数:

参数名 类型 含义
Function UFunction* 描述要调用的函数的元信息对象
Parameters void* 指向参数数据结构的指针(通常是一个栈结构或用户定义的 struct)

image-20251006221156275

1
2
3
4
5
6
UObject::ProcessEvent(UFunction* Func, void* Params)
└─(检查RPC/调试钩子等)→ 若为原生(Native)
└─ UFunction::Invoke(...) / CallFunction(...)
└─ (*FuncPtr)(Obj, Stack, RESULT_PARAM) ← 这里的 FuncPtr 是函数指针
└─ *.gen.cpp 里由 UHT 生成的 Thunk(桩函数)
└─ 真正的 C++ 实现(你写的函数 / _Implementation)
  1. ProcessEvent:统一入口
  • UObject::ProcessEvent(UFunction* Function, void* Params) 是统一调度口:做网络 RPC 判断、蓝图/原生分流、调试钩子等。
  • 判断是“原生函数”就走 Native 路径,否则走蓝图 VM。
  1. UFunction::Invoke / CallFunction
  • 不同版本命名略有差异,本质是根据 UFunction 的元信息调用其绑定的原生函数指针
  • 这里会把 void* Params/FFrame Stack 这样的参数区准备好,随后调用函数指针。
  1. (*Func)(Obj, Stack, RESULT_PARAM)
  • FuncUFunction 里保存的 原生函数指针(类型一般是 FNativeFuncPtr)。

  • 调用签名常见为:

    1
    using FNativeFuncPtr = void(*)(UObject* Context, FFrame& Stack, RESULT_DECL);
    • Obj/Context:调用对象
    • Stack:参数解析用的执行栈(从里面按元信息取出各参数)
    • RESULT_PARAM/RESULT_DECL:返回值写回位置
  1. *.gen.cpp 的 Thunk(桩函数)
  • 这个函数指针 不是直接指向你写的 C++ 方法,而是指向 UHT 生成的“Thunk”(桩函数),文件名形如 YourClass.gen.cpp
  • Thunk 的职责:
    • 用宏从 FFrame解码参数P_GET_PROPERTY / P_GET_OBJECT 等)
    • P_FINISH; 检查参数读取完毕
    • 调用真正实现
      • 普通 UFUNCTIONP_THIS->YourFunc(DecodedParams...)
      • BlueprintNativeEvent:调用 YourFunc_Implementation(...)
    • 如果有返回值,写入 RESULT_PARAM

典型生成代码(示意):

1
2
3
4
5
6
7
8
// 定义由 UHT 生成的原生桩
DEFINE_FUNCTION(AMyCharacter::execJumpHigher)
{
P_GET_PROPERTY(FFloatProperty, Z_Param_Power);
P_FINISH;
P_THIS->JumpHigher(Z_Param_Power); // 调到你写的 C++ 实现
// 如有返回值:*(ReturnType*)RESULT_PARAM = Ret;
}
  1. “真正的 C++ 函数”
  • 就是你在类里写的那个实现:

    1
    2
    UFUNCTION(BlueprintCallable)
    void JumpHigher(float Power) { /* 你的逻辑 */ }
  • 若是 BlueprintNativeEvent

    1
    2
    3
    4
    UFUNCTION(BlueprintNativeEvent)
    void DoX(int A);
    void DoX_Implementation(int A) { /* 真正实现 */ }
    // Thunk 会调用 _Implementation
  1. 为什么需要 Thunk?
  • 编组/解组(marshalling):把反射/蓝图世界里的“无类型参数块(FFrame)”转换成强类型的 C++ 形参。
  • 统一通道:同一套机制兼容蓝图、RPC、编辑器调用、反射调用。
  • 元信息驱动:根据 UFunctionProperty 链表解参数/写返回值。
  • 隔离生成代码:参数处理、错误检查、默认值、标志处理都放在自动生成的桩里,你的业务函数保持干净。

LineTraceSingleLineOfSightToStaticFindObject(光线类的效果,判断是否遮挡)
K2 DrawText,K2 DrawLine(引擎绘制)

防御

UE开发中,对引擎做改动是比较困难的,目前的游戏厂商常用的有以下的方式:

指针加密

在运行时不直接存放真实指针,而是存放加密后的值;每次使用前先解密,用完再丢弃或重新加密。

假设原本:

1
UWorld* GWorld;

被修改为:

1
2
3
4
5
6
uintptr_t GWorld_Encrypted;

UWorld* GetGWorld()
{
return reinterpret_cast<UWorld*>(GWorld_Encrypted ^ 0x5A5A5A5A5A5A5A5A);
}

在引擎访问全局对象时,都通过一个 解密函数

1
2
auto World = GetGWorld();
World->Tick(...);

修改引擎关键结构体

官方 UE4(简化):

1
2
3
4
5
struct FUObjectItem {
UObject* Object;
int32 Flags;
int32 ClusterRootIndex;
};

被厂商修改为:

1
2
3
4
5
struct FUObjectItem_Protected {
uint64 EncodedPtr; // Object指针被编码
uint32 Flags;
uint32 Magic; // 新增无用字段
};

或直接改偏移:

1
2
// 原始偏移 0x10 改为 0x18
UObject* Object = *(UObject**)((uintptr_t)item + 0x18);

调换变量的顺序也行

所有现成的 SDK / Dumper 工具失效;

混淆函数名

UE 的反射系统和蓝图系统依赖字符串(如 UFunctionUClass 名)。

global-metadata.datGNames 中可以看到很多原始函数名,例如:

1
2
Function /Script/Game.Character.Jump
Function /Script/Game.Player.FireWeapon

这些字符串在反编译或 SDK Dump 时非常显眼。混淆函数名的思路是:

在打包或编译阶段对所有函数、类、属性名进行哈希或替换,保证引擎运行时可识别,但肉眼无法理解。

编译前:

1
2
UFUNCTION(BlueprintCallable)
void FireWeapon();

混淆处理后(自动脚本):

1
2
UFUNCTION(BlueprintCallable)
void a5F4E1C8();

或者动态加密:

1
RegisterFunction(DecryptString("0xA93F1221B"));

最终在反射表中看到的函数名就变成:

1
Function /Script/Game.a5F4E1C8

更多可以看

UE4 反射系统 | 十三

[原创]UE4.27SDK-Dump-软件逆向-看雪论坛-安全社区|非营利性质技术交流社区

记录一次虚幻4(UE4)手游逆向 - 吾爱破解 - 52pojie.cn

逆向UE4最简单的一集 - 吾爱破解 - 52pojie.cn

这些博客

外挂原理

image-20251006223212793

外挂主要有两种方式,跨进程和注入式dll

跨进程(External):作弊程序驻外部进程,通过读取/写入目标进程的内存、模拟输入、或监听渲染帧来实现功能;不把代码直接放入游戏进程。

做法

  • 外部进程使用系统API(例如 Windows 的 ReadProcessMemory / WriteProcessMemoryGetWindowRectSendInput、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
2
3
4
5
6
7
8
9
┌───────────────┐
│ Ring0 │ ← 内核态(最高权限)
│ 操作系统内核 │
│ 驱动程序 │
├───────────────┤
│ Ring3 │ ← 用户态(最低权限)
│ 应用程序 │
│ 游戏 / 浏览器 │
└───────────────┘

详见逆向常见反调试总结 | Matriy’s blog

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
2
mov eax, 1
cpuid
ECX bit31 状态
0 物理机(bare-metal)
1 虚拟化环境(VMware/VirtualBox/Hyper-V 等)

image-20251007200842968

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
int cpu_info[4];

// ① 调用 CPUID(EAX=1) 获取基本 CPU 功能信息
__cpuid(cpu_info, 1);

// ② 检查 ECX[31] (bit 31):Hypervisor bit
if ( (cpu_info[2] >> 31) & 1 ) { // 存在 Hypervisor

// 清零寄存器(可选)
cpu_info[1] = 0;
cpu_info[2] = 0;
cpu_info[3] = 0;

// ③ 调用 CPUID(EAX=0x40000000) 获取 hypervisor vendor ID
__cpuid(cpu_info, 0x40000000);

// 判断 EBX, ECX, EDX 是否为 "Microsoft Hv"
if (cpu_info[1] == 0x7263694d && // "Micr"
cpu_info[2] == 0x666f736f && // "osoft"
cpu_info[3] == 0x76482074) { // " Hv" + "t"(Microsoft Hv)

// 再调用 CPUID(EAX=0x40000003) 获取 Hyper-V 特定功能信息
__cpuid(cpu_info, 0x40000003);

// ④ 检查 EBX bit0:root partition flag
if (cpu_info[1] & 1)
return false; // 在 Hyper-V 的 root 分区(宿主层)
else
return true; // 在 Hyper-V 的子分区(虚拟机中)
}
}
return false; // 没检测到 Hyper-V

E8 检查(E8 Check) 是一种经典的 反外挂 / 反破解 / 调用完整性检查机制,它的名字来源于 x86 指令集中 CALL 指令的机器码 **0xE8**。

E8 表示一条相对调用(CALL rel32),后面跟 4 字节相对偏移。

当 CPU 执行 CALL 时,会做两件事:

  1. 把下一条指令的地址压入栈(即返回地址)
  2. 跳转到目标函数

例如:

1
2
3
00401000: E8 1B 00 00 00    call 00401020
00401005: 90 nop
00401006: ...

当执行 call 00401020 时,CPU 会:

  • 压栈返回地址:00401005
  • 跳转到 00401020

E8 检查 = 检查调用栈中返回地址的前一条指令是否真的是 CALL(E8 开头)。

例如游戏里某个敏感函数 GetKey(),只允许被合法逻辑调用。

外挂可能想绕过逻辑直接“跳过去”调用,于是手工修改栈或直接 JMP 到 GetKey,不走真正的 CALL

程序为了防御这种“伪造调用”,就在函数开头做检查:

1
2
3
4
5
6
GetKey:
mov eax, [esp] ; 取返回地址
dec eax ; 返回地址 - 1 (指向 CALL 的 E8 字节)
cmp byte ptr [eax], 0xE8 ; 检查是不是 E8 CALL
jne IllegalCallDetected
...

如果返回地址前一字节确实是 0xE8 → 说明这个函数是通过 CALL 指令正常被调用的。

如果不是 → 说明是外挂或异常跳转 → 触发异常 / 拒绝执行。

按机器特征分发可执行文件 + GetKey

“返回地址检查由客户端分发游戏文件,根据机器某些软硬件特征分发不同的可执行文件,让 GetKey 函数对不同用户不同,外挂不得不调用该函数来获取解密密钥。”

解释:

  • 服务端根据机器指纹(例如 CPU id、硬盘序列号、MAC、TPM 等)为每台机器生成不同的可执行文件或不同的 key/逻辑。
  • 游戏里有一个 GetKey()(或类似)函数负责根据本机特征生成/返回解密密钥或解密流程的关键信息。
  • 这样,外挂如果要解密/获得游戏内部关键数据,就必须在进程内以正确方式调用 GetKey()(或模拟其逻辑)。外部简单修改同一文件在别的机器上不一定生效,从而提高逆向门槛。

用途:这是“按机打包 + 绑定密钥”的反破解手段——增加每台机器的差异,降低通用补丁/外挂的可用性。

之前的绕过思路(在没有 E8 检查时)

“在没有 E8 检查之前,可以找到主模块内某一个 ret 指令的地址,将当前位置压栈、压入 ret 指令地址、jmp到目标函数。”

解释(高层):

  • 攻击者在游戏的主模块里找一个合适的 ret 指令地址(或者任意可返回的地址),构造栈上返回地址,然后直接 jmp 到目标函数(比如 GetKey)去调用它。
  • 手法本质上是伪造调用栈:把“返回地址”放到栈上并跳转,让目标函数执行完后以伪造的返回地址回到某处,从而避开正常调用约束或上下文检查。
  • 这种方法通常用于“进程内调用”但不走正常调用路径(绕过某些前置检查、非法构造调用上下文)。

这是在没有更严格检测时一种常见的内存/控制流操纵技巧。

加入 E8 检查后老方法作废

“对抗:用 Zyais 框架动态分析指令,去除返回地址检查,修补变量地址后调用。” —— 高层含义

  • 这句话意思不是说具体如何去做绕过,而是在描述一种高级逆向/动态分析思路的概念性流程
    1. 用动态二进制分析/执行框架(提到的 “Zyais”——理解为某种动态分析或动态二进制翻译/插桩工具)对程序运行时的二进制指令进行动态跟踪/修改
    2. 找到并识别出“返回地址检查”的那段代码(即验证 E8 的检测逻辑)。
    3. 在运行时把这段检查绕过或修改(去除/补丁),或者直接在内存中修补相关变量/校验数据,使检查通过。
    4. 修补完检测/上下文后,再安全地调用目标函数(例如 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,并主动触发一个受控异常(例如 INT3RaiseException)。该异常在正常情况下会先到达系统/调试层,再进到进程的 VEH/SEH。
  • 检测点:在自家的 handler 中,防护逻辑会检查:
    • 寄存器上下文(例如 RIP/EIP、RSP/ESP、寄存器值)是否和触发点一致(或是否被篡改);
    • 栈回溯(call stack/backtrace),验证异常来源是预期的调用序列(比如返回地址点位于本模块 .text 的合法位置,并且调用者不是第三方模块);
    • 返回地址/调用序列的完整性(比如前面你提过的 E8 检查,或更强的 call-stack hash)。

如果这些检查发现异常在到达 handler 前被拦截、修改或被不合法的上下文处理(例如寄存器被替换,返回地址不合法),就可以判定存在 hook/中间人(例如 VEH 被拦截,或 KiUserExceptionDispatcher 被 Hook)。