UE PAK的加密分析与加固策略

Encrypted Analysis and Special Protection of PAK in UE

在开发项目中后期,直至上线之前,通常还需要处理客户端资产安全性的问题,避免直接利用公版引擎和相关工具直接能把资源解包,以及对于一些后续版本的关键资产(如商业化资产)的保护。

所以需要在引擎层面,对UE的默认加密机制做一些改造和混淆,使其不那么容易地被破解。但因为最终解密还是要在客户端执行的,所以无法保证绝对的安全性,只能尽可能地拉高解密成本。

本篇文章会介绍我对UE默认加密机制的分析,并介绍几种加固思路。因为安全方面的内容较为敏感,所以文章内不会提供具体的修改方法,只提供加固的可行性思路。

默认加密分析

项目加密配置

可以在ProjectSettings-Project-Crypto中配置加密参数:

该配置会存储在工程的Config/DefaultCrypto.ini目录中。

title:"Config/DefaultCrypto.ini"
1
2
3
4
5
6
7
8
9
10
[/Script/CryptoKeys.CryptoKeysSettings]
EncryptionKey=PgpZZZZZZZZZZtu/jXXXXXXXXXXXXXXXXXXEVYBhui0=
bEncryptPakIniFiles=True
bEncryptPakIndex=True
bEncryptUAssetFiles=True
bEncryptAllAssetFiles=True
SigningPublicExponent=AQAB
SigningModulus=FRY0C5Siqs2j2a9e9gflNqOhSTq1q6K/wdF1basIglGZm8FikE3wP8e13DXeqxjoTkxspwPyMdZpbj4OhMUFkYgHjVt4rUl/4Cnya78Y+pfhu3kQR9btSiHefjeEnJW0x26A19ZOc6MXhEpSxR7/kafC18qW4WvM2gnRkv4MsZhIaVIav8l1gBJ56sSAPoLLkHu4Lv2+z4s81XuuUqcrdq3ft73plIFr6JBnW6jFUq9N3qK1IJgFP8gKBWy9/Vyj29odY4pFmbWNajD9gvWwyGwGzL3iGLJzJtJI2qpFq/FfEJTOYL/5VaB8Nf8FIqmnzpt1Vd8q1LfMC0ckk245SKJ63wZfLZWL88LSakLRhZRmigNmpCNYz22YDbeBYAP8Iu2NzWuaxOXPNE6FEF1pq9bp1d/xw/F9MatvyxzH9hpgVKbetYaH+Dr2FylS22dhB87q5I3QeMv/YFVHWvKjU4znXMYQ5y+WSSE474ZDWqKXzDkG31DwaN52cqVhc3D9SM7TQSD/b3j3RlKP1E6+Drb398mNpEukS5FPvUzTIUchGe4IMvT7S9PZ3+Cg+pPTsq3ZZ99WbHSK3aQFzMYIc88gnIZAah73fTpi58qta0Ru4wI1pCr/XCccBFD8JGiAp2Am36OCxhPpSlIMPKpqpbt5y4FKrlZYgucKTl62Lbw=
SigningPrivateExponent="qbacV1tHkg9gqrcID3uVHDFWPIsdrmigeh8LaIwXhpfal3JByWFMPk2cetKb475Jaxspi0AVLL2/Bu/rq7phRtb+f5cgFcz7TlmhQR9RSRaggxtIFPh/EzBsbgOm0U+Dcb9cxUzbgu2VRMO+vhmk6An7HGTA9HHLqI3DRLn83wIvVseid8UHdDiM7W1uea6ADzBSn0xL4A3Hova9+jzeuuMUNO9bI6L2dl4CRHbXkn+D9ccjUhF+4fX8fq/lMmUZzgIdI8sF8suBfPwtEuE9TG4txxcxFqSO8TKzfmZUq18wkASJ1WfazCiPeLssL2bCoL08v3oaYQ16GfiYtwpXiu12o0f1SoQIbpwW8/mvePV3aAm++Xff/j8y7oKJccJ9ZoRxWjdJkQbKMLsxz//EWmvC+jqGdYS/S6gjIhKER04vwBcEkJjsuR0hFXZFTxo4ZyXk5MBxG8vW5PLkbl6iWKbklGpdxJbghQ3ymAJ7WmdnkmPYCDO6SJ1MTIm1MElr9X8VKUf6nAwunSrIhXoVVHDzZ1IHVemf9frLVEINhPHBQxtg5Br7NzMe1gUUEVRg451jUSfIPkzThCoNwPxf9khKRm89APS0r4oybyEACEdq82roe7Ju2qR12wOVNYCnDGmDnWo2/T/7h2tkaFhPZ/xaf5Om/06BTxCNPpiQWDI="
bEnablePakSigning=False

这些配置会被用在UnrealPak打包资源时,以及在编译代码时,把解密的Key编译到可执行程序之内。

其中的EncryptionKeySigningModulus以及SigningPrivateExponent的值都是BASE64编码的,实际编译至代码中的密钥是解码之后的。

title:"UnrealBuildTool\System\EncryptionAndSigning.cs"
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
// Parse encryption key
string EncryptionKeyString;
Ini.GetString(SectionName, "EncryptionKey", out EncryptionKeyString);
if (!string.IsNullOrEmpty(EncryptionKeyString))
{
Settings.EncryptionKey = new EncryptionKey();
Settings.EncryptionKey.Key = System.Convert.FromBase64String(EncryptionKeyString);
Settings.EncryptionKey.Guid = Guid.Empty.ToString();
Settings.EncryptionKey.Name = "Embedded";
}
// ...
// Parse signing key
string PrivateExponent, PublicExponent, Modulus;
Ini.GetString(SectionName, "SigningPrivateExponent", out PrivateExponent);
Ini.GetString(SectionName, "SigningModulus", out Modulus);
Ini.GetString(SectionName, "SigningPublicExponent", out PublicExponent);

if (!String.IsNullOrEmpty(PrivateExponent) && !String.IsNullOrEmpty(PublicExponent) && !String.IsNullOrEmpty(Modulus))
{
Settings.SigningKey = new SigningKeyPair();
Settings.SigningKey.PublicKey.Exponent = System.Convert.FromBase64String(PublicExponent);
Settings.SigningKey.PublicKey.Modulus = System.Convert.FromBase64String(Modulus);
Settings.SigningKey.PrivateKey.Exponent = System.Convert.FromBase64String(PrivateExponent);
Settings.SigningKey.PrivateKey.Modulus = Settings.SigningKey.PublicKey.Modulus;
}

虽然加密密钥是保存在Config/DefaultCrypto.ini中的,但是在打包时会把它剔除,它不会进包,所以不用担心直接被打进版本的问题。

编译时写入

在编译时,执行TargetRules.cs的逻辑,会读取工程的ini,并构造出一个EncryptionAndSigning.CryptoSettings,记录了当前工程的加密和签名配置。
代码在:Engine/Source/Programs/UnrealBuildTool/System/EncryptionAndSigning.cs

如果项目的配置文件中,开启了加密和签名,会往ProjectDefinitions中添加KEY的宏:

title:"Engine/Source/Programs/UnrealBuildTool/Configuration/TargetRules.cs"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Setup macros for signing and encryption keys
EncryptionAndSigning.CryptoSettings CryptoSettings = EncryptionAndSigning.ParseCryptoSettings(DirectoryReference.FromFile(ProjectFile), Platform);
if (CryptoSettings.IsAnyEncryptionEnabled())
{
ProjectDefinitions.Add(String.Format("IMPLEMENT_ENCRYPTION_KEY_REGISTRATION()=UE_REGISTER_ENCRYPTION_KEY({0})", FormatHexBytes(CryptoSettings.EncryptionKey.Key)));
}
else
{
ProjectDefinitions.Add("IMPLEMENT_ENCRYPTION_KEY_REGISTRATION()=");
}

if (CryptoSettings.IsPakSigningEnabled())
{
ProjectDefinitions.Add(String.Format("IMPLEMENT_SIGNING_KEY_REGISTRATION()=UE_REGISTER_SIGNING_KEY(UE_LIST_ARGUMENT({0}), UE_LIST_ARGUMENT({1}))", FormatHexBytes(CryptoSettings.SigningKey.PublicKey.Exponent), FormatHexBytes(CryptoSettings.SigningKey.PublicKey.Modulus)));
}
else
{
ProjectDefinitions.Add("IMPLEMENT_SIGNING_KEY_REGISTRATION()=");
}

这些宏被添加到了项目中的所有模块的定义中:

title:Intermediate\Build\Android\FGame\Development\FGame\Definitions.FGame.h
1
2
#define IMPLEMENT_ENCRYPTION_KEY_REGISTRATION() UE_REGISTER_ENCRYPTION_KEY(0x3E,0x8A,0x5B,0x8D,0x5F,0xA8,0x6A,0xF5,0x83,0x42,0xFB,0x1F,0x8D,0xC6,0x78,0x8D,0x73,0x48,0x3E,0x14,0x7B,0x32,0xA4,0x41,0x2A,0x5A,0x04,0x55,0x80,0x6F,0xBD,0x2D)
#define IMPLEMENT_SIGNING_KEY_REGISTRATION() UE_REGISTER_SIGNING_KEY(UE_LIST_ARGUMENT(0x01,0x00,0x01), UE_LIST_ARGUMENT(0x15,0x16,0x34,0x0B,0x94,0xA2,0xAA,0xCD,0xA3,0xD9,0xAF,0x5E,0xF6,0x07,0xE5,0x36,0xA3,0xA1,0x49,0x3A,0xB5,0xAB,0xA2,0xBF,0xC1,0xD1,0x75,0x6D,0xAB,0x08,0x82,0x51,0x99,0x9B,0xC1,0x62,0x90,0x4D,0xF0,0x3F,0xC7,0xB5,0xDC,0x35,0xDE,0xAB,0x18,0xE8,0x4E,0x4C,0x6C,0xA7,0x03,0xF2,0x31,0xD6,0x69,0x6E,0x3E,0x0E,0x84,0xC5,0x05,0x91,0x88,0x07,0x8D,0x5B,0x78,0xAD,0x49,0x7F,0xE0,0x29,0xF2,0x6B,0xBF,0x18,0xFA,0x97,0xE1,0xBB,0x79,0x10,0x47,0xD6,0xED,0x4A,0x21,0xDE,0x7E,0x37,0x84,0x9C,0x95,0xB4,0xC7,0x6E,0x80,0xD7,0xD6,0x4E,0x73,0xA3,0x17,0x84,0x4A,0x52,0xC5,0x1E,0xFF,0x91,0xA7,0xC2,0xD7,0xCA,0x96,0xE1,0x6B,0xCC,0xDA,0x09,0xD1,0x92,0xFE,0x0C,0xB1,0x98,0x48,0x69,0x52,0x1A,0xBF,0xC9,0x75,0x80,0x12,0x79,0xEA,0xC4,0x80,0x3E,0x82,0xCB,0x90,0x7B,0xB8,0x2E,0xFD,0xBE,0xCF,0x8B,0x3C,0xD5,0x7B,0xAE,0x52,0xA7,0x2B,0x76,0xAD,0xDF,0xB7,0xBD,0xE9,0x94,0x81,0x6B,0xE8,0x90,0x67,0x5B,0xA8,0xC5,0x52,0xAF,0x4D,0xDE,0xA2,0xB5,0x20,0x98,0x05,0x3F,0xC8,0x0A,0x05,0x6C,0xBD,0xFD,0x5C,0xA3,0xDB,0xDA,0x1D,0x63,0x8A,0x45,0x99,0xB5,0x8D,0x6A,0x30,0xFD,0x82,0xF5,0xB0,0xC8,0x6C,0x06,0xCC,0xBD,0xE2,0x18,0xB2,0x73,0x26,0xD2,0x48,0xDA,0xAA,0x45,0xAB,0xF1,0x5F,0x10,0x94,0xCE,0x60,0xBF,0xF9,0x55,0xA0,0x7C,0x35,0xFF,0x05,0x22,0xA9,0xA7,0xCE,0x9B,0x75,0x55,0xDF,0x2A,0xD4,0xB7,0xCC,0x0B,0x47,0x24,0x93,0x6E,0x39,0x48,0xA2,0x7A,0xDF,0x06,0x5F,0x2D,0x95,0x8B,0xF3,0xC2,0xD2,0x6A,0x42,0xD1,0x85,0x94,0x66,0x8A,0x03,0x66,0xA4,0x23,0x58,0xCF,0x6D,0x98,0x0D,0xB7,0x81,0x60,0x03,0xFC,0x22,0xED,0x8D,0xCD,0x6B,0x9A,0xC4,0xE5,0xCF,0x34,0x4E,0x85,0x10,0x5D,0x69,0xAB,0xD6,0xE9,0xD5,0xDF,0xF1,0xC3,0xF1,0x7D,0x31,0xAB,0x6F,0xCB,0x1C,0xC7,0xF6,0x1A,0x60,0x54,0xA6,0xDE,0xB5,0x86,0x87,0xF8,0x3A,0xF6,0x17,0x29,0x52,0xDB,0x67,0x61,0x07,0xCE,0xEA,0xE4,0x8D,0xD0,0x78,0xCB,0xFF,0x60,0x55,0x47,0x5A,0xF2,0xA3,0x53,0x8C,0xE7,0x5C,0xC6,0x10,0xE7,0x2F,0x96,0x49,0x21,0x38,0xEF,0x86,0x43,0x5A,0xA2,0x97,0xCC,0x39,0x06,0xDF,0x50,0xF0,0x68,0xDE,0x76,0x72,0xA5,0x61,0x73,0x70,0xFD,0x48,0xCE,0xD3,0x41,0x20,0xFF,0x6F,0x78,0xF7,0x46,0x52,0x8F,0xD4,0x4E,0xBE,0x0E,0xB6,0xF7,0xF7,0xC9,0x8D,0xA4,0x4B,0xA4,0x4B,0x91,0x4F,0xBD,0x4C,0xD3,0x21,0x47,0x21,0x19,0xEE,0x08,0x32,0xF4,0xFB,0x4B,0xD3,0xD9,0xDF,0xE0,0xA0,0xFA,0x93,0xD3,0xB2,0xAD,0xD9,0x67,0xDF,0x56,0x6C,0x74,0x8A,0xDD,0xA4,0x05,0xCC,0xC6,0x08,0x73,0xCF,0x20,0x9C,0x86,0x40,0x6A,0x1E,0xF7,0x7D,0x3A,0x62,0xE7,0xCA,0xAD,0x6B,0x44,0x6E,0xE3,0x02,0x35,0xA4,0x2A,0xFF,0x5C,0x27,0x1C,0x04,0x50,0xFC,0x24,0x68,0x80,0xA7,0x60,0x26,0xDF,0xA3,0x82,0xC6,0x13,0xE9,0x4A,0x52,0x0C,0x3C,0xAA,0x6A,0xA5,0xBB,0x79,0xCB,0x81,0x4A,0xAE,0x56,0x58,0x82,0xE7,0x0A,0x4E,0x5E,0xB6,0x2D,0xBC))

这两个宏,会被用在IMPLEMENT_PRIMARY_GAME_MODULE中:

1
IMPLEMENT_PRIMARY_GAME_MODULE(FGameModule, FGame, "FGame");

它的作用为定义模块。在打包后的运行时,代码编译都是Monolithic的。
所以对于移动平台来说,IMPLEMENT_PRIMARY_GAME_MODULE的宏定义为:

title:IMPLEMENT_PRIMARY_GAME_MODULE
1
2
3
4
5
6
7
8
9
10
11
12
13
#define IMPLEMENT_PRIMARY_GAME_MODULE( ModuleImplClass, ModuleName, DEPRECATED_GameName ) \
/* For monolithic builds, we must statically define the game's name string (See Core.h) */ \
TCHAR GInternalProjectName[64] = TEXT( PREPROCESSOR_TO_STRING(UE_PROJECT_NAME) ); \
PER_MODULE_BOILERPLATE \
IMPLEMENT_FOREIGN_ENGINE_DIR() \
IMPLEMENT_LIVE_CODING_ENGINE_DIR() \
IMPLEMENT_LIVE_CODING_PROJECT() \
IMPLEMENT_SIGNING_KEY_REGISTRATION() \
IMPLEMENT_ENCRYPTION_KEY_REGISTRATION() \
IMPLEMENT_TARGET_NAME_REGISTRATION() \
IMPLEMENT_GAME_MODULE( ModuleImplClass, ModuleName ) \
/* Implement the GIsGameAgnosticExe variable (See Core.h). */ \
bool GIsGameAgnosticExe = false;

前面列出的,在TargetRules.cs里定义的记录加密和签名Key的宏,是一个嵌套的结构:

1
2
#define IMPLEMENT_ENCRYPTION_KEY_REGISTRATION() UE_REGISTER_ENCRYPTION_KEY(...)
#define IMPLEMENT_SIGNING_KEY_REGISTRATION() UE_REGISTER_SIGNING_KEY(...)

从而转发到了UE_REGISTER_ENCRYPTION_KEYUE_REGISTER_SIGNING_KEY这两个宏里。它们也是在ModuleManager.h里定义的。

UE_REGISTER_ENCRYPTION_KEY

它的所用是构造出一个FEncryptionKeyRegistration的类定义,并定义出一个GEncryptionKeyRegistration对象。
并在它的构造函数中,注册了获取加密Key的Callback。

title:UE_REGISTER_ENCRYPTION_KEY
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Macro for registering encryption key for a project.
#define UE_REGISTER_ENCRYPTION_KEY(...) \
struct FEncryptionKeyRegistration \
{ \
FEncryptionKeyRegistration() \
{ \
extern void RegisterEncryptionKeyCallback(void (*)(unsigned char OutKey[32])); \
RegisterEncryptionKeyCallback(&Callback); \
} \
static void Callback(unsigned char OutKey[32]) \
{ \
const unsigned char Key[32] = { __VA_ARGS__ }; \
for(int ByteIdx = 0; ByteIdx < 32; ByteIdx++) \
{ \
OutKey[ByteIdx] = Key[ByteIdx]; \
} \
} \
} GEncryptionKeyRegistration;

预处理后的结果为:

title:FEncryptionKeyRegistration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct FEncryptionKeyRegistration  
{
FEncryptionKeyRegistration()
{
extern void RegisterEncryptionKeyCallback(void (*)(unsigned char OutKey[32]));
RegisterEncryptionKeyCallback(&Callback);
}
static void Callback(unsigned char OutKey[32])
{
const unsigned char Key[32] = { 0x3E,0x0A,0x59,0x8D,0x5F,0xA8,0x6A,0xF5,0x83,0x42,0xDB,0xBF,0xAD,0xC6,0xB8,0x8D,0x7D,0x48,0x3E,0x1F,0x3B,0x32,0xA4,0x41,0x2A,0x5A,0x04,0x55,0x80,0x61,0xBA,0x2D };
for(int ByteIdx = 0; ByteIdx < 32; ByteIdx++)
{
OutKey[ByteIdx] = Key[ByteIdx];
}
}
}GEncryptionKeyRegistration;

UE_REGISTER_SIGNING_KEY

它的所用是构造出一个FSigningKeyRegistration的类定义,并定义出一个GSigningKeyRegistration对象。
并在它的构造函数中,注册了获取签名Key的Callback。

title:UE_REGISTER_SIGNING_KEY
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
// Macro for registering signing keys for a project.
#define UE_REGISTER_SIGNING_KEY(ExponentValue, ModulusValue) \
struct FSigningKeyRegistration \
{ \
FSigningKeyRegistration() \
{ \
extern void RegisterSigningKeyCallback(void (*)(TArray<uint8>&, TArray<uint8>&)); \
RegisterSigningKeyCallback(&Callback); \
} \
static void Callback(TArray<uint8>& OutExponent, TArray<uint8>& OutModulus) \
{ \
const uint8 Exponent[] = { ExponentValue }; \
const uint8 Modulus[] = { ModulusValue }; \
OutExponent.SetNum(UE_ARRAY_COUNT(Exponent)); \
OutModulus.SetNum(UE_ARRAY_COUNT(Modulus)); \
for(int ByteIdx = 0; ByteIdx < UE_ARRAY_COUNT(Exponent); ByteIdx++) \
{ \
OutExponent[ByteIdx] = Exponent[ByteIdx]; \
} \
for(int ByteIdx = 0; ByteIdx < UE_ARRAY_COUNT(Modulus); ByteIdx++) \
{ \
OutModulus[ByteIdx] = Modulus[ByteIdx]; \
} \
} \
} GSigningKeyRegistration;

预处理后的结果为:

title:FSigningKeyRegistration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct FSigningKeyRegistration
{
FSigningKeyRegistration()
{
extern void RegisterSigningKeyCallback(void (*)(TArray<uint8>&, TArray<uint8>&));
RegisterSigningKeyCallback(&Callback);
}
static void Callback(TArray<uint8>& OutExponent, TArray<uint8>& OutModulus)
{
const uint8 Exponent[] = { 0x01,0x00,0x01 };
const uint8 Modulus[] = { 0x15,0x16,0x34,0x0B,0x94,0xA2,0xAA,0xCD,0xA3,0xD9,0xAF,0x5E,0xF6,0x07,0xE5,0x36,0xA3,0xA1,0x49,0x3A,0xB5,0xAB,0xA2,0xBF,0xC1,0xD1,0x75,0x6D,0xAB,0x08,0x82,0x51,0x99,0x9B,0xC1,0x62,0x90,0x4D,0xF0,0x3F,0xC7,0xB5,0xDC,0x35,0xDE,0xAB,0x18,0xE8,0x4E,0x4C,0x6C,0xA7,0x03,0xF2,0x31,0xD6,0x69,0x6E,0x3E,0x0E,0x84,0xC5,0x05,0x91,0x88,0x07,0x8D,0x5B,0x78,0xAD,0x49,0x7F,0xE0,0x29,0xF2,0x6B,0xBF,0x18,0xFA,0x97,0xE1,0xBB,0x79,0x10,0x47,0xD6,0xED,0x4A,0x21,0xDE,0x7E,0x37,0x84,0x9C,0x95,0xB4,0xC7,0x6E,0x80,0xD7,0xD6,0x4E,0x73,0xA3,0x17,0x84,0x4A,0x52,0xC5,0x1E,0xFF,0x91,0xA7,0xC2,0xD7,0xCA,0x96,0xE1,0x6B,0xCC,0xDA,0x09,0xD1,0x92,0xFE,0x0C,0xB1,0x98,0x48,0x69,0x52,0x1A,0xBF,0xC9,0x75,0x80,0x12,0x79,0xEA,0xC4,0x80,0x3E,0x82,0xCB,0x90,0x7B,0xB8,0x2E,0xFD,0xBE,0xCF,0x8B,0x3C,0xD5,0x7B,0xAE,0x52,0xA7,0x2B,0x76,0xAD,0xDF,0xB7,0xBD,0xE9,0x94,0x81,0x6B,0xE8,0x90,0x67,0x5B,0xA8,0xC5,0x52,0xAF,0x4D,0xDE,0xA2,0xB5,0x20,0x98,0x05,0x3F,0xC8,0x0A,0x05,0x6C,0xBD,0xFD,0x5C,0xA3,0xDB,0xDA,0x1D,0x63,0x8A,0x45,0x99,0xB5,0x8D,0x6A,0x30,0xFD,0x82,0xF5,0xB0,0xC8,0x6C,0x06,0xCC,0xBD,0xE2,0x18,0xB2,0x73,0x26,0xD2,0x48,0xDA,0xAA,0x45,0xAB,0xF1,0x5F,0x10,0x94,0xCE,0x60,0xBF,0xF9,0x55,0xA0,0x7C,0x35,0xFF,0x05,0x22,0xA9,0xA7,0xCE,0x9B,0x75,0x55,0xDF,0x2A,0xD4,0xB7,0xCC,0x0B,0x47,0x24,0x93,0x6E,0x39,0x48,0xA2,0x7A,0xDF,0x06,0x5F,0x2D,0x95,0x8B,0xF3,0xC2,0xD2,0x6A,0x42,0xD1,0x85,0x94,0x66,0x8A,0x03,0x66,0xA4,0x23,0x58,0xCF,0x6D,0x98,0x0D,0xB7,0x81,0x60,0x03,0xFC,0x22,0xED,0x8D,0xCD,0x6B,0x9A,0xC4,0xE5,0xCF,0x34,0x4E,0x85,0x10,0x5D,0x69,0xAB,0xD6,0xE9,0xD5,0xDF,0xF1,0xC3,0xF1,0x7D,0x31,0xAB,0x6F,0xCB,0x1C,0xC7,0xF6,0x1A,0x60,0x54,0xA6,0xDE,0xB5,0x86,0x87,0xF8,0x3A,0xF6,0x17,0x29,0x52,0xDB,0x67,0x61,0x07,0xCE,0xEA,0xE4,0x8D,0xD0,0x78,0xCB,0xFF,0x60,0x55,0x47,0x5A,0xF2,0xA3,0x53,0x8C,0xE7,0x5C,0xC6,0x10,0xE7,0x2F,0x96,0x49,0x21,0x38,0xEF,0x86,0x43,0x5A,0xA2,0x97,0xCC,0x39,0x06,0xDF,0x50,0xF0,0x68,0xDE,0x76,0x72,0xA5,0x61,0x73,0x70,0xFD,0x48,0xCE,0xD3,0x41,0x20,0xFF,0x6F,0x78,0xF7,0x46,0x52,0x8F,0xD4,0x4E,0xBE,0x0E,0xB6,0xF7,0xF7,0xC9,0x8D,0xA4,0x4B,0xA4,0x4B,0x91,0x4F,0xBD,0x4C,0xD3,0x21,0x47,0x21,0x19,0xEE,0x08,0x32,0xF4,0xFB,0x4B,0xD3,0xD9,0xDF,0xE0,0xA0,0xFA,0x93,0xD3,0xB2,0xAD,0xD9,0x67,0xDF,0x56,0x6C,0x74,0x8A,0xDD,0xA4,0x05,0xCC,0xC6,0x08,0x73,0xCF,0x20,0x9C,0x86,0x40,0x6A,0x1E,0xF7,0x7D,0x3A,0x62,0xE7,0xCA,0xAD,0x6B,0x44,0x6E,0xE3,0x02,0x35,0xA4,0x2A,0xFF,0x5C,0x27,0x1C,0x04,0x50,0xFC,0x24,0x68,0x80,0xA7,0x60,0x26,0xDF,0xA3,0x82,0xC6,0x13,0xE9,0x4A,0x52,0x0C,0x3C,0xAA,0x6A,0xA5,0xBB,0x79,0xCB,0x81,0x4A,0xAE,0x56,0x58,0x82,0xE7,0x0A,0x4E,0x5E,0xB6,0x2D,0xBC };
OutExponent.SetNum(UE_ARRAY_COUNT(Exponent));
OutModulus.SetNum(UE_ARRAY_COUNT(Modulus));
for(int ByteIdx = 0; ByteIdx < UE_ARRAY_COUNT(Exponent); ByteIdx++)
{
OutExponent[ByteIdx] = Exponent[ByteIdx];
}
for(int ByteIdx = 0; ByteIdx < UE_ARRAY_COUNT(Modulus); ByteIdx++)
{
OutModulus[ByteIdx] = Modulus[ByteIdx];
}
}
} GSigningKeyRegistration;

简单来说,UE解密密钥的序列化方式,本质上就是在UBT里把解密的KEY,以宏定义的形式编译到了代码中,供运行时解密访问。运行时访问的部分后面会具体介绍。

打包PAK启用加密

引擎的所有依赖的文件和资源,都存储在PAK文件中。当项目指定了加密和签名配置,实际上影响的是在打包PAK时对资源进行加密和签名。
在调用UnrealPak时,可以传递-cryptokeys=参数,读取加密配置。
UAT打包时,会根据DefaultCrypto.ini里的配置在Saved/Cooked/[PLATFORM]/[PROJECT_NAME]/Metadata目录下生成一个json文件,用于UnrealPak打包时指定。
该json文件的结构为:

title:Crypto.json
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
{
"$types":{
"UnrealBuildTool.EncryptionAndSigning+CryptoSettings, UnrealBuildTool, Version=4.0.0.0, Culture=neutral, PublicKeyToken=null":"1",
"UnrealBuildTool.EncryptionAndSigning+EncryptionKey, UnrealBuildTool, Version=4.0.0.0, Culture=neutral, PublicKeyToken=null":"2",
"UnrealBuildTool.EncryptionAndSigning+SigningKeyPair, UnrealBuildTool, Version=4.0.0.0, Culture=neutral, PublicKeyToken=null":"3",
"UnrealBuildTool.EncryptionAndSigning+SigningKey, UnrealBuildTool, Version=4.0.0.0, Culture=neutral, PublicKeyToken=null":"4"
},
"$type":"1",
"EncryptionKey":{
"$type":"2",
"Name":"Embedded",
"Guid":"00000000-0000-0000-0000-000000000000",
"Key":"PgpZjV+oavWDQtu/jca4jXNIPhR7MqRBKloEVYBhui0="
},
"SigningKey":{
"$type":"3",
"PublicKey":{
"$type":"4",
"Exponent":"AQAB",
"Modulus":""
},
"PrivateKey":{
"$type":"4",
"Exponent":"",
"Modulus":""
}
},
"bEnablePakSigning":true,
"bEnablePakIndexEncryption":true,
"bEnablePakIniEncryption":true,
"bEnablePakUAssetEncryption":true,
"bEnablePakFullAssetEncryption":true,
"bDataCryptoRequired":true,
"PakEncryptionRequired":true,
"PakSigningRequired":true,
"SecondaryEncryptionKeys":[

]
}

引擎打包pakchunk0时的命令行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/Users/buildmachine/Client/Saved/StagedBuilds/IOS/cookeddata/fgame/content/paks/pakchunk0-ios.pak
-create="/Users/buildmachine/Library/Logs/Unreal Engine/LocalBuildLogs/BuildCookRun/PakList_pakchunk0-ios.txt"
-cryptokeys=/Users/buildmachine/Client/Saved/Cooked/IOS/FGame/Metadata/Crypto.json
-order=/Users/buildmachine/Client/Build/IOS/FileOpenOrder/EditorOpenOrder.log
-encryptindex
-sign
-allowForIndexUnload
-platform=IOS
-AlignForMemoryMapping=16384
-compressionformats=Oodle,Zlib
-multiprocess
-abslog="/Users/buildmachine/Library/Logs/Unreal Engine/LocalBuildLogs/BuildCookRun/UnrealPak-pakchunk0-ios-2024.12.23-04.32.38.txt"
-compressmethod=Kraken
-compresslevel=fast

PakFileUtilities.cpp中的LoadKeyChain函数中,从命令行读取Crypto.json并解析出加密配置:LoadKeyChain,将在PAK生成时使用。

在生成PAK时,实际执行逻辑在PakFileUtilities.cppCreatePakFile函数中:

1
bool CreatePakFile(const TCHAR* Filename, TArray<FPakInputPair>& FilesToAdd, const FPakCommandLineParameters& CmdLineParameters, const FKeyChain& InKeyChain);

最后一个参数传递的就是加密配置,如果检测到KeyChain配置里有启用加密,它就会使用AES进行加密:

title:CreatePakFile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FMemoryImageResult Result;
MemoryImage.Flatten(Result);

if (Info.bEncryptedIndex)
{
check(InKeyChain.MasterEncryptionKey);
Result.Bytes.AddZeroed(Align(Result.Bytes.Num(), FAES::AESBlockSize) - Result.Bytes.Num());
FSHA1::HashBuffer(Result.Bytes.GetData(), Result.Bytes.Num(), Info.IndexHash.Hash);
FAES::EncryptData(Result.Bytes.GetData(), Result.Bytes.Num(), InKeyChain.MasterEncryptionKey->Key);
TotalEncryptedDataSize += Result.Bytes.Num();
}

int32 Size = Result.Bytes.Num();
PakFileHandle->Serialize(Result.Bytes.GetData(), Size);
Result.SaveToArchive(*PakFileHandle);

本质上来说,就是用AES去加密数据,并把加密后的数据序列化到PAK文件里。

我在之前的文章(虚幻引擎中Pak的运行时重组方案)中,曾详细介绍了PAK的序列化结构:
image.png
而UE的加密,就是针对PAK结构里的某些数据进行的。图与上文结合起来看更容易理解。

运行时获取解密Key

对于Pak的加密和签名,在运行时都需要获取对应的key才可以。

而在上面UE_REGISTER_ENCRYPTION_KEYUE_REGISTER_SIGNING_KEY的定义中,可以看到,里面有两个extern

1
2
extern void RegisterEncryptionKeyCallback(void (*)(unsigned char OutKey[32])); 
extern void RegisterSigningKeyCallback(void (*)(TArray<uint8>&, TArray<uint8>&));

这两个函数就是引擎获取Key的方法:

title:Engine/Source/Runtime/Core/Private/Misc/CoreDelegates.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef void(*TSigningKeyFunc)(TArray<uint8>&, TArray<uint8>&);
typedef void(*TEncryptionKeyFunc)(unsigned char[32]);

void RegisterSigningKeyCallback(TSigningKeyFunc InCallback)
{
FCoreDelegates::GetPakSigningKeysDelegate().BindLambda([InCallback](TArray<uint8>& OutExponent, TArray<uint8>& OutModulus)
{
InCallback(OutExponent, OutModulus);
});
}

void RegisterEncryptionKeyCallback(TEncryptionKeyFunc InCallback)
{
FCoreDelegates::GetPakEncryptionKeyDelegate().BindLambda([InCallback](uint8 OutKey[32])
{
InCallback(OutKey);
});
}

在这两个函数被调用时(也就是FEncryptionKeyRegistrationFSigningKeyRegistration的构造函数)。

回想一下之前文章里写的Monolithic模块的初始化流程:UE插件与工具开发:基础概念#Monolithic 模式
它们会在模块启动的时候,就会被创建出来。

在加载PAK时,会调用FCoreDelegates::GetPakEncryptionKeyDelegate()来获取密钥:

title:"Engine/Source/Runtime/PakFile/Private/IPlatformFilePak.cpp"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void FPakPlatformFile::GetPakEncryptionKey(FAES::FAESKey& OutKey, const FGuid& InEncryptionKeyGuid)
{
OutKey.Reset();

if (!GetRegisteredEncryptionKeys().GetKey(InEncryptionKeyGuid, OutKey))
{
if (!InEncryptionKeyGuid.IsValid() && FCoreDelegates::GetPakEncryptionKeyDelegate().IsBound())
{
FCoreDelegates::GetPakEncryptionKeyDelegate().Execute(OutKey.Key);
}
else
{
UE_LOG(LogPakFile, Fatal, TEXT("Failed to find requested encryption key %s"), *InEncryptionKeyGuid.ToString());
}
}
}

之后用来解密数据:

title:"Engine\Source\Runtime\PakFile\Private\IPlatformFilePak.cpp"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void DecryptData(uint8* InData, uint32 InDataSize, FGuid InEncryptionKeyGuid)
{
if (FPakPlatformFile::GetPakCustomEncryptionDelegate().IsBound())
{
FPakPlatformFile::GetPakCustomEncryptionDelegate().Execute(InData, InDataSize, InEncryptionKeyGuid);
}
else
{
SCOPE_SECONDS_ACCUMULATOR(STAT_PakCache_DecryptTime);
FAES::FAESKey Key;
FPakPlatformFile::GetPakEncryptionKey(Key, InEncryptionKeyGuid);
check(Key.IsValid());
FAES::DecryptData(InData, InDataSize, Key);
}
}

从而实现了从构建时加密到运行时解密的整套流程。

Pak的序列化结构

之前文章中已经提到过,UE的PAK是一种典型的Archive的结构:

通过一定的序列化规则生成出来的,而且运行时读取也是按照格式规则进行的:

  1. 挂载PAK时首先从文件尾读取PakInfo,然后获取到Index的偏移位置
  2. 根据Index偏移获取到EncodePakEntrys,可以获取到每一个文件的PakEntry信息
  3. 每个PakEntry又记录了当前的文件的信息,用于指导如何读取和解压
    它也是UE本身虚拟文件系统的重要部分。

但是,因为UE源代码是开放的,所以PAK本身的序列化结构并没有什么保密性可言,如果通过逆向拿到加密密钥之后,就能够直接通过官版引擎UnrealPak的逻辑解出PAK内的文件。

而且也有一些第三方工具,如UModel或UnrealPakViewer等,都可以做到这一点。所以,除了密钥、解密混淆之外,还需要对PAK本身的序列化结构进行改造。

加固方案

默认方案的缺点

根据前面两个小结的介绍,可以得出下面的结论:

  1. 解密的key直接以宏定义的形式编译到代码中
  2. 通过引擎源代码 + 静态分析,比较容易能够定位到获取密钥的函数
  3. 代码中的导出符号,可以直接暴露解密函数位置
  4. 代码中的PAK字符串(日志/ProfilingTag)可以辅助定位到关键的解密函数
  5. 默认使用AES加密,有明显的代码特征,易被分析
  6. PAK的序列化格式比较通用,获取密钥后能被公版引擎的UnrealPak或UModel/UnrealPakViewer等工具解密

所以,基于这些情况,我们需要对加密方案做一些多方面的改造,尽可能地提高解密成本。但同样地,基于安全的需求我只能介绍一些加密思路,并不会提供具体的代码。

密钥加固

第一节提到了,密钥是通过Base64编码后生成了一个宏定义,然后运行时获取的,我们可以通过写一些独特的密钥混淆逻辑,使对密钥本身做一层处理。避免直接静态分析就可以拿到编码后的密钥。

符号剔除

默认情况下,如果没有把符号剔除的话,用IDA等逆向工具打开可执行程序,是可以直接看到函数名字的:

这样直接就能看到解密函数,非常容易就能拿到密钥了。所以需要对所有的发行平台的包,都需要剔除符号。
剔除后的符号情况:

可以参考我上一篇文章的内容:极致优化 UE Android APK 的大小

PAK字符串剔除

相同的原因,引擎在挂载PAK相关逻辑中打印出了很多的日志,分析这些日志字符串的访问位置,也能辅助解密逻辑的定位:

找到解密函数后,附加调试器,看一下内存就能拿到实际的密钥了。

所以,剔除pak相关的字符串是非常必要的。

剔除LOG

剔除方式,希望在Dev里依然保留日志,但是在Shipping时剔除:

1
2
3
4
5
6
7
8
9
10
//++[lipengzha] 在shipping时剔除PAK相关字符串
#ifndef DISABLE_PAK_LOG_IN_SHIPPING
#define DISABLE_PAK_LOG_IN_SHIPPING 0
#endif
#if UE_BUILD_SHIPPING && DISABLE_PAK_LOG_IN_SHIPPING
#define UE_PAK_LOG(...)
#else
#define UE_PAK_LOG(LogCategory,LogLevel,Format, ...) UE_LOG(LogCategory,LogLevel,Format,##__VA_ARGS__)
#endif
//--

所以可以对UE_LOG的宏做一层封装,Dev默认转发,Shipping时替换为空宏。

剔除BOOT_TIMING

还需要剔除SCOPED_BOOT_TIMING引入的字符串:

1
2
3
4
5
6
7
8
9
10
11
12
//++[lipengzha] 在shipping时剔除PAK相关字符串
#ifndef REMOVE_PAK_TEXT_IN_SHIPPING
#define REMOVE_PAK_TEXT_IN_SHIPPING 0
#endif
#if UE_BUILD_SHIPPING && REMOVE_PAK_TEXT_IN_SHIPPING
#define UE_PAK_LOG(...)
#define UE_PAK_SCOPED_BOOT_TIMING(...)
#else
#define UE_PAK_LOG(LogCategory,LogLevel,Format, ...) UE_LOG(LogCategory,LogLevel,Format,##__VA_ARGS__)
#define UE_PAK_SCOPED_BOOT_TIMING(x) SCOPED_BOOT_TIMING(x)
#endif
//--

然后把IPlatformFilePak.cppIPlatformFilePak.h两个文件中,所有的UE_LOG替换为UE_PAK_LOG,所有的SCOPED_BOOT_TIMING替换为UE_PAK_SCOPED_BOOT_TIMING

就能在Shipping时自动剔除这部分字符串了。

PAK结构混淆

在前面的小节中(Pak的序列化结构),我介绍了PAK结构本身的序列化方式,所以对于商业项目而言对结构本身的混淆也是需要做的。

可以在保持原有UFS文件系统的基础上对PAK的序列化进行改造,对结构的组织方式、数据进行加密或修改,避免公版引擎和开源工具能够在拿到密钥后解密的情况。也可以做到每个pak的结构和解密的唯一性,避免一个被破解然后一窝端的情况。

这部分内容就不再具体阐述了,只能每个项目各自实现了。

解压算法混淆

因为在文件在打包进PAK后,通常会用压缩算法进行压缩,然后运行时需要解压,而且每个文件用了哪一个压缩算法是序列化到了PakInfo内的,在每个PakEntry中有记录Index。

所以也可以在这方面做一些处理,就算拿到了密钥和逆向了PAK结构,也可以在把实际文件解压出来时多一个混淆行为:

替换AES算法

因为UE中AES是标配,在逆向时也可以通过特征码来定位到解密逻辑。所以也可以对AES进行替换或对代码逻辑做一些改造。
具体替换成什么算法,这个需要根据项目情况自己评估了。

关键资产的动态密钥下发

按照前面的分析,如果所有资产只用一个默认密钥,是直接就被编译到了代码中。这个密钥如果被破解,那么所有的资源都会面临泄露的情况。而且本地解密确实没有太高的安全性可言。

而这种情况下,对于一些关键的资产或文件(如未开放的商业化内容),有时资产已经提前预埋进客户端了,但还未到开放时间,这种情况下,如果共用同一套密钥就会存在很大风险。

引擎内本身也提供了动态注册解密密钥的逻辑:

1
2
3
4
5
6
7
class CORE_API FCoreDelegates  
{
public:
// Callback for registering a new encryption key
DECLARE_DELEGATE_TwoParams(FRegisterEncryptionKeyDelegate, const FGuid&, const FAES::FAESKey&);
// ...
};

所以可以在更新流程中实现一套动态密钥机制,加密后的PAK在程序本体内不包含解密密钥,只要到特定时间版本开放后,再把密钥动态下发,客户端才能使用,这样能够保证不会被提前解包泄露的情况。

总结

本篇文章介绍了UE默认的加密逻辑,并且分析了潜在的风险,并提供了一些可行的加固策略,可以参考使用。

注:文章内介绍了引擎加密流程的分析以及逆向手段,只是为了分析如何能够更安全,并非破解教程。任何游戏逆向相关行为均与作者无关,谢谢!

全文完,若有不足之处请评论指正。

微信扫描二维码,关注我的公众号。

本文标题:UE PAK的加密分析与加固策略
文章作者:查利鹏
发布时间:2025/07/11 09:21
本文字数:5.1k 字
原始链接:https://imzlp.com/posts/88478/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!