UE5 游戏资源解包与逆向工程实践分享

UE 系列游戏的资源解包在大多数情况下并不复杂,但一旦进入非标准实现,就不可避免地需要结合源码、逆向与调试手段。
希望这次实践记录,能为遇到类似问题的读者提供一些思路与参考。


背景

已经很久没有更新博客了。
最近有朋友找我帮忙看看某个游戏是否有办法进行资源解包,在研究和实践的过程中,发现过程还挺有意思的。与此同时,网上关于 UE5 游戏资源解包与逆向工程 的系统性讨论并不算多,于是决定整理一下经验,分享出来,和大家一起学习与交流。

话不多说,直接进入正题。


UE 系列游戏的常规解包思路

针对 Unreal Engine(UE)系列游戏,社区中已经有不少成熟且稳定的工具链。
下面简单介绍常用的解包工具,以及部分引擎级的逆向辅助工具。


常用解包工具

  • AESDumpster
    一键从内存中提取 UE 游戏所使用的 AES Key,对新手非常友好。

  • FModel
    非常著名的 UE 资源浏览与导出工具,支持 UE4 / UE5,多数场景下即开即用。

  • CUE4Parse
    FModel 底层所依赖的核心解析库,负责 Pak 文件解析与解密,是 UE 资源解包的核心组件。

  • UModel(UE Viewer)
    老牌 UE 资源浏览工具,稳定可靠,但支持 UE5 的版本通常需要额外寻找或自行编译。

  • UnrealPak
    Unreal Engine 官方自带的 Pak 打包 / 解包工具,通常随引擎源码或安装包提供。

  • UnrealLocres
    用于解包与处理 UE 语言资源(.locres)文件,常见于本地化与汉化相关场景。

如果只是进行常规的 UE 游戏资源查看与导出,上述工具基本已经可以满足大多数需求。
Unreal Engine 游戏资源提取
一文中对这些工具的使用方式做了较为详细的介绍,可作为补充阅读参考。

需要注意的是,当涉及 游戏修改、汉化或资源回封 时,通常还需要配合使用
UnrealPakUnrealLocres 等工具,并且往往需要绕过 Pak 完整性校验、签名校验或额外的反篡改逻辑
这部分内容与具体项目实现关系较大,本文不作展开,建议根据实际情况自行分析。


工具示意图

AESDumpster 运行界面

R1280x0

FModel / CUE4Parse 加载 Pak 资源界面

R1280x0-2

基础使用流程示意

Snipaste_2025-05-04_12-10-26

引擎 / 逆向辅助工具(进阶)

以下工具更多用于 引擎级分析、逆向工程、功能植入或 Mod 开发,并非纯解包所必需,但在复杂或非标准实现中非常有价值。

  • yinjector
    轻量级 DLL 注入器,常用于注入调试代码、Hook 或自定义逻辑。

  • Dumper-7
    Unreal Engine 游戏的 SDK 生成工具,支持 UE4 与 UE5,可用于生成 SDK、提取 .usmap 等反射与结构映射信息。

  • RE-UE4SS
    常用于制作 Mod 或植入自定义功能的工具,可扩展 UE 游戏运行时能力。

  • PalWorld-Server-Unoffical-Api
    基于 UE 的能力扩展示例项目,可作为理解 UE 运行时交互与功能注入的参考。


非标准解包场景下的逆向工程基础

在实际分析中,并非所有 UE 游戏都遵循引擎的默认实现,常见的非标准情况包括:

  • 常规工具提取到的 Key 错误或不完整
  • AES Key 在运行时动态生成或多阶段派生
  • 使用了自定义 Pak 加载器或对 Pak 进行了二次封装
  • 修改或替换了原有的加密或校验算法

在这些场景下,常规解包工具往往会失效,需要结合逆向工程手段,从引擎实现与游戏自身逻辑入手进行分析。


手动提取 AES Key 的思路

当自动化工具(如 AESDumpster)无法直接提取 AES Key 时,通常需要通过 静态分析与动态调试 手动定位 Key 的真实来源,常见思路包括:

  • 从 UE 的 Pak 加载与解密流程入手,重点关注
    FPakPlatformFileFPakFileFAES::DecryptData 等关键类与函数
  • 通过字符串特征、结构体布局或函数交叉引用(Xref),追踪 AES Key 的初始化、传递与使用路径
  • 在运行时对关键函数或关键数据写入位置下断点,观察 Key 的生成、拼接、解码或派生过程
  • 结合内存 Dump,在合适的时机直接提取内存中的明文 Key,或其最终用于解密的派生结果

以下是两篇非常值得参考的资料,详细讲解了 AES Key 的定位与 Dump 方法:


逆向分析常用手段

在非标准解包场景中,通常需要结合以下工具与方法:

  • 静态分析:IDA / Ghidra
    用于还原 UE 版本对应的 Pak 加载与解密逻辑,理解函数调用关系与数据流向。

  • 动态调试:x64dbg / WinDbg
    用于验证静态分析结论,观察运行时 Key 的生成、使用与销毁时机。


实践之某非标准加密资源提取

本节记录一次真实项目中,针对 非标准 Pak 加密实现 的资源提取与逆向分析过程。


确认引擎版本与源码准备

分析开始前,首先需要确认目标程序对应的 Unreal Engine 版本,以便后续对照引擎源码。

常见的确认方式有两种:

  1. 使用
    AES Dumpster – Unreal Engine AES Key Scanner
    将游戏主程序拖入,即可识别其 UE 版本信息。

  2. Binaries\Win64Engine\Binaries\Win64 目录下找到游戏 exe,通过文件属性查看版本信息。

image-20260109010601200

确认版本后,建议阅读官方文档
Unreal Engine on GitHub
并在 GitHub 上获取对应版本的 Unreal Engine 源码(需 Epic 授权),这对后续逆向分析非常有帮助。

image-20260109003349998

在资源解包相关场景中,通常重点关注以下目录:

1
Engine/Source/Runtime/PakFile/Private/

其中较为关键的文件包括:

  • IPlatformFilePak.cpp
    Pak 解密逻辑,包含 DecryptData

  • PakFile.cpp
    Pak 结构解析核心,包含 LoadIndexLoadIndexInternal

  • SignedArchiveReader.cpp
    Pak 索引相关的序列化逻辑,核心函数为 Serialize

这些文件基本覆盖了 Pak 文件的加载与解密流程。


AES Key 定位

在确认源码结构后,下一步是定位 AES Key。

通过在 x64dbg 中对主模块进行字符串搜索,可以较快定位到 Pak 解密相关函数,在其调用前后下断点进行跟踪。

源码中的 Key 使用位置

image-20260109011026176 image-20260109010920291

x64dbg 中的表现

a806ab35da0a3332326e46fee23f4438f8f3bcb78466d7b3f4b596588871d6de image-20260109041345461

断点(FAES::DecryptData)触发后可以观察到,r8(第三个参数)指向 AES Key。
通常 AES Key 的长度为 16 的倍数,实际项目中多为 32 字节

进一步分析可以发现,用于获取 Key 的 GetPakEncryptionKey 已被编译器内联,因此在反汇编中不会以独立函数形式出现。


解包流程与 CUE4Parse 修正

将获取到的 Key 填入 FModel 进行快速验证,如果能直接解包,问题基本结束。
本例中验证失败,说明 Pak 结构或解密流程并非标准实现。

从零开始编写 Pak 解析器成本较高,因此这里选择在
CUE4Parse 的基础上进行修正。

环境调整

  • CUE4Parse.Example 设为启动项目
  • Program.cs 原用于 APK 解包,可将其 Main 重命名
  • Unpacker.cs 的入口方法改为 Main
  • 在代码中手动设置:
    • _archiveDirectory
    • _aesKey
    • Pak 文件名

Unpacker.cs 结构说明

image-20260109012226880

运行后即可动态跟踪解包流程。
需要注意的是,OodleHelper 相关依赖在网络环境较差时可能下载失败,
可手动下载并放置到对应目录,否则将无法正常解压。


GameType 定义与索引修正

由于标准版本配置无法正确解析该游戏的 Pak,通常需要新增一个 GameType

可以参考 EGame.GAME_UE5_6 的定义,在对应版本附近添加新的游戏类型。

GameType 定义位置示意

image-20260109012629419

在代码层面,重点关注以下关键位置与调用流程:

  • CUE4Parse\UE4\Pak\PakFileReader.cs
    主要调用链:
    PakFileReaderFPakInfo.ReadFPakInfonew FPakInfo
    需要逐项核对 Pak Header 中的 偏移、大小等字段信息

  • CUE4Parse\FileProvider\Vfs\AbstractVfsFileProvider.cs
    主要调用链:
    SubmitKeysAsyncMountToPakFileReader.Mount
    ReadIndexUpdatedReadAndDecryptIndex

在流程正确的情况下,索引成功解密后即可完成 Pak 挂载。
如果 Pak 结构被修改较多,后续相关偏移同样需要同步修正。

通常可以认为,除加密方式与偏移调整外,不会对虚拟文件系统结构做大规模改动
否则实现复杂度、兼容风险与维护成本都会显著增加。


同步逆向与加载流程确认

IDA 中的参考

image-20260109015153547

通过 Pak 中的字符串特征,可以在 IDA 中快速定位到
InitializeLoadIndexLoadIndexInternal 等关键函数。

Pak 的整体加载流程基本围绕上述函数展开,是后续动态跟踪与
偏移校对的主要切入点。

将关键偏移带回 x64dbg 中进行动态跟踪,逐项核对以下内容:

  • Pak 头部与索引结构字段
  • 各结构在文件中的偏移与大小
  • 数据对齐与填充规则
  • 解密前后的数据内容

并与 CUE4Parse 中对应实现进行逐一比对,以修正解析逻辑。

需要说明的是,这一步骤本身非常枯燥且繁琐,虽然描述看似简单,
但在实际操作中往往是整个流程中最耗时的部分。


调试时机问题与解决方案

程序通常由根目录下的 Launcher 负责拉起,
因此无法直接调试 Binaries\Win64 下的主程序,只能在进程运行后附加调试。

尝试在 Initialize 下断点后,多次运行未能命中。

此时通常存在两种可能:

  1. 调试器附加时机过晚
  2. 使用了非标准 Pak 加载流程

一种快速验证方式是,将其他 UE 游戏的 Pak 拷贝到当前目录以触发解密失败错误。
通过错误信息与调用堆栈,可以间接观察内部加载逻辑。

image-20260109024317328

本例中属于第一种情况。

为尽可能提前介入调试,可以采用多种方式,例如在 Launcher 中拦截进程创建并以挂起方式启动。

这里更推荐使用 DLL 搜索顺序劫持注入 的方法,在游戏目录下放置伪造的系统 DLL(如 winhttp.dll),

可借助 AheadLib 快速生成对应的劫持代码。

注入验证示例

image-20260109024437399

游戏启动后会被 MessageBox 阻塞,此时附加调试即可早于 Pak 加载阶段,从而完整跟踪 LoadIndex 流程。


加密算法异常与替代方案

在逐步修正索引与偏移等解析细节后,执行到 DecryptData 阶段,
CUE4ParseIsValidIndex 中抛出异常,这提示 解密算法并非标准 AES

从调试器中提取:

  • Cipher
  • Key

并编写测试代码进行验证:

image-20260109031425328

测试结果与 CUE4Parse 的解密结果一致,但与游戏内部解密结果不一致。
说明该算法在形式上接近 AES,但内部实现存在改动。

常规做法是完整逆向算法与密码表,但成本较高。
本例中采用另一种思路:直接复用游戏自身的解密函数

通过在前述 DLL 劫持注入基础上,实现一套简易 RPC 接口,将解密请求转发给游戏进程内部执行(具体实现见附录)。

最终,CUE4Parse 的解密流程直接调用游戏原生逻辑,验证成功。

image-20260109035330472

总结

本文从一次实际的资源解包需求出发,记录了在常规工具失效的情况下,
如何逐步介入 UE5 游戏的 Pak 加载与解密流程。

从确认 UE 版本、定位 Pak 结构与加载路径,到修正解析偏移、同步逆向验证,
再到处理非标准 AES 加密并复用游戏内解密逻辑,整个流程环环相扣,循序推进。

UE 系列游戏的资源解包在大多数情况下并不复杂,但一旦进入非标准实现,
就不可避免地需要结合源码、逆向与调试手段。
希望这次实践记录,能为遇到类似问题的读者提供一些思路与参考。


附录

游戏端

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
VOID MainEntry() {
int ret = MessageBoxA(
NULL,
"Start TCP server?",
"Confirm",
MB_OKCANCEL | MB_ICONQUESTION
);

if (ret != IDOK)
return; // 取消

std::thread([] {
// 初始化 WinSock
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0)
return;

SOCKET server = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (server == INVALID_SOCKET)
return;

sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;

if (bind(server, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR)
return;

listen(server, SOMAXCONN);

// key
static uint8_t key[32] = {
0x??, ...
};

uintptr_t module_base = (uintptr_t)GetModuleHandleA(NULL);
uintptr_t func_addr = module_base + fake_aes_rva;

typedef __int64(__fastcall* fake_aes_t)(
unsigned char* edata,
unsigned int len,
unsigned char* key
);

fake_aes_t fake_aes = (fake_aes_t)func_addr;

auto handle_client = [fake_aes](SOCKET client) {
while (true) {
// 先接收 4 字节长度
uint32_t net_len = 0;
int r = recv(client, (char*)&net_len, sizeof(net_len), 0);
if (r <= 0) break;
uint32_t len = ntohl(net_len);

if (len == 0) continue;

// 接收数据
std::vector<uint8_t> buffer(len);
size_t received = 0;
while (received < len) {
int ret = recv(client, (char*)buffer.data() + received, len - received, 0);
if (ret <= 0) goto cleanup;
received += ret;
}

// 解密
fake_aes(buffer.data(), buffer.size(), key);

// 发送长度 + 数据
uint32_t send_len = htonl((uint32_t)buffer.size());
send(client, (char*)&send_len, sizeof(send_len), 0);
send(client, (char*)buffer.data(), buffer.size(), 0);
}
cleanup:
closesocket(client);
};

// 循环接收客户端
while (true) {
SOCKET client = accept(server, nullptr, nullptr);
if (client != INVALID_SOCKET) {
std::thread(handle_client, client).detach(); // 简易的多线程处理
}
}

closesocket(server);

WSACleanup();
}).detach();
}

CUE4Parse

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
using System;
using System.Net.Sockets;
using CUE4Parse.UE4.VirtualFileSystem;

namespace CUE4Parse.GameTypes.XX.Encryption.Aes;
public static class XXAes
{
// 全局 TcpClient + NetworkStream
private static readonly TcpClient Client;
private static readonly NetworkStream Stream;
private static readonly object LockObj = new object(); // 多线程安全

static XXAes()
{
// 全局只有一个套接字,避免过多的消耗客户端端口
Client = new TcpClient("127.0.0.1", 8080);
Stream = Client.GetStream();
}

public static byte[] XXDecrypt(
byte[] bytes,
int beginOffset,
int count,
bool isIndex,
IAesVfsReader reader)
{
// UE 允许空块
if (count == 0)
return Array.Empty<byte>();

if (bytes.Length < beginOffset + count)
throw new IndexOutOfRangeException(
"beginOffset + count is larger than the length of bytes");

if ((count & 0xF) != 0)
throw new ArgumentException("count must be a multiple of 16");

// 取 slice
byte[] slice = new byte[count];
Buffer.BlockCopy(bytes, beginOffset, slice, 0, count);

// 套接字锁,单套接字并发需额外设计,此处直接用锁
lock (LockObj)
{
try
{
// 发送长度 + 数据
byte[] lenBytes = BitConverter.GetBytes(
System.Net.IPAddress.HostToNetworkOrder(slice.Length)
);
Stream.Write(lenBytes, 0, 4);
Stream.Write(slice, 0, slice.Length);

// 接收长度
byte[] recvLenBytes = new byte[4];
int read = 0;
while (read < 4)
read += Stream.Read(recvLenBytes, read, 4 - read);

int recvLen = System.Net.IPAddress.NetworkToHostOrder(
BitConverter.ToInt32(recvLenBytes, 0)
);

if (recvLen <= 0)
return Array.Empty<byte>();

// 接收解密后的数据
byte[] buffer = new byte[recvLen];
read = 0;
while (read < recvLen)
read += Stream.Read(buffer, read, recvLen - read);

return buffer;
}
catch (Exception ex)
{
throw new InvalidOperationException("Socket decrypt failed", ex);
}
}
}
}