资源管理:UASSET资源加密方案

Unreal Resource Management: A raw uasset Encryption Scheme

在游戏项目开发中,会涉及到大量的开发、美术、策划以及外包等等各个方面的研发人员。出于资源安全和信息保密的考虑,通常会做一些复杂的权限控制和同步逻辑。

但抛开权限控制,仅探讨资源本身,在UE中资源是向上兼容的,用同版本号的引擎或者更高版本号的引擎可以直接打开。这意味着分发出去的资源,可以直接在另外的项目里使用。所以,该如何保证开发阶段资源的安全性是要重点关注的。

本篇文章提供一种加密工程中原始UASSET资源的思路,并介绍可以用于资源加密的基础原理。但如果公开具体的加密实现,就等同于裸奔,所以本篇文章仅提供对uasset资源结构的分析以及可用于实现加密的思路,但不会提供具体的实现代码。

核心需求

  1. 让项目侧导出的资源只在自有引擎内能才能访问,不能被公版引擎打开。
  2. 加密仅在Editor阶段,不对运行时造成影响。
  3. 降低对资产的影响,是否有可能造成资源损坏、以及对Cook的影响
  4. 不影响未加密的资源,让加密后和未加密的资源都能正常读取

uasset格式分析

uasset的文件是UE的资源文件,用于存储游戏中的数据和内容。导入不同的类型的资源,如贴图、模型、动画序列等,都会将其转换为UE的uasset资源,用于在引擎中访问与编辑。

可以使用WinHex64等工具,以二进制的形式查看uasset文件:

uasset文件的起始4个字节就是PACKAGE_FILE_TAG

它是一个固定值:Core/Public/UObject/ObjectVersion.h

1
2
#define PACKAGE_FILE_TAG          0x9E2A83C1  
#define PACKAGE_FILE_TAG_SWAPPED 0xC1832A9E

用于标识该文件是否是uasset的资源,引擎尝试加载资源时,会先检测该TAG,如果为0x9E2A83C1则认为它是资源,再进行后续的序列化行为。

简单地说,如果不考虑加密,只实现在其他引擎中不能识别资源而言,只把uasset文件头的4位数据改成另外一个magic number就可以了。

Summary序列化

当检测文件的前4个字节的PACKAGE_FILE_TAG0x9E2A83C1后,则会继续从磁盘上读取已序列化的数据,从而构造出一个PackageFileSummary的结构。

经过调试,PackageFileSummary的数据如下:

而PackageFileSummary也是直接序列化到uasset的文件头中的,从第0个字节开始:

但引擎中并非是完完全全把整个PackageFileSummary都序列化的,而是逐个属性序列化,引擎的版本、不同的设置、以及资源的情况不同而不同。所以它并不是一个固定大小的数据。

以上面截图中的Summary数据为例,将其序列化后,我列出了属性和序列化的字节排列顺序:

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
Tag(int32) 0x9E2A83C1 C1832A9F 0x9E2A83C1
LegacyFileVersion(int32) -7 F9FFFFFF 0xfffffff9
LegacyUE3Version(int32) 863 60300000 0x360
FileVersionUE4(int32) 520 08020000 0x208
FileVersionLicenseeUE4(int32) 0 00000000 0x0
CustomVersions(Array):
uint32 ArraySize 2 0x2 02000000
Version.Key(4*uint32)
A 0x29E575DD
B 0xE0A34627
C 0x9D10D276
D 0x232CDCEA
Version(int32) 17 11000000 0x11
Version.Key(4*uint32)
A 0xE4B068ED
B 0xF49442E9
C 0xA231DA0B
D 0x2E46BB41
Version(int32) 38 26000000 0x26
TotalHeaderSize(int32) 55184 90D70000 0xd790
FolderName(FString)
FolderName Num(int32) 5 05000000 0x5
FolderName Str(ANSICHAR) None 4E6F6E6500 0x00656E6F4E (end \0)
PackageFlags(int32) 0 00000000 0x0
NameCount(int32) 38 0x26
NameOffset(int32) 302 2E010000 0x012E
LocalizationId(FString) Num=33 L"23FD62A744D4EEEE38CE78805684C51E"
LocalizationId Num(int32) 33 21000000 0x21
FolderName Str(ANSICHAR) 23FD62A744D4EEEE38CE78805684C51E(end \0)
2 3 F D 6 2 A 7 4 4 D 4 E E E E 3 8 C E 7 8 8 0 5 6 8 4 C 5 1 E 0
32 33 46 44 36 32 41 37 34 34 44 34 45 45 45 45 33 38 43 45 37 38 38 30 35 36 38 34 43 35 31 45 \0
GatherableTextDataCount(int32) 0 00000000 0x0
GatherableTextDataOffset(int32)
...

可以看到是逐属性的紧凑形式。

PackageFileSummary的数据序列化调用栈:

引擎中的代码为PackageFileSummary.cpp中的operator<<操作符重载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Runtime\CoreUObject\Private\UObject\PackageFileSummary.cpp
void operator<<(FStructuredArchive::FSlot Slot, FPackageFileSummary& Sum)
{
// ...
if (bCanStartSerializing)
{
Record << SA_VALUE(TEXT("Tag"), Sum.Tag);
}
// only keep loading if we match the magic
if (Sum.Tag == PACKAGE_FILE_TAG || Sum.Tag == PACKAGE_FILE_TAG_SWAPPED)
{
// ...
}
}

UE的序列化机制,是基于FArchive的形式进行的,究竟是读取还是写入,取决于传进来的Archive的类型。
所以,对于单个operator<<操作符的重载既可以实现加载,也能够实现写入。

加密思路

通过前面一节,能够知道UE的资产识别和序列化方式。现在我们来看Summary包含了哪些内容。
同样以上面的资源为例:

可以看到:

  1. Summary的信息,是对整个资源的概述
  2. 记录个关键数据的偏移值
  3. 当需要加载资源中的数据,必须基于Summary中的信息,去对应的偏移中加载

如果不知道对应数据的具体offset,则无法成功地加载出资源。

那么到此,我们对于uasset的加密需求,就可以转换为对于Summary的加密。

最终的目的是让我们的资源,在公版或其他引擎中无法识别,并无法访问关键数据的offset偏移,就能够起到加密的作用。

加密算法

引擎内内置了多种加密算法,AES、SHA等等,本文以AES为例。AES是一个基于分组密码的加密算法,一个分组的长度是128位(16字节)。

AES具有多种加密模式,UE中采用的是CBC模式(Cipher Block Chaining),采用前一块密文与当前块铭文异或的方式加密,能够避免相同明文块加密后密文相同的情况,避免被基于统计学的方式解密。

在UE中使用AES进行加密的简要代码:

1
2
3
4
5
6
7
8
TArray<uint8> Text;  
FString Str = TEXT("helloworld");
for(auto Char:Str)
{
Text.Add(*TCHAR_TO_ANSI(&Char));
}
while((Text.Num() % 16) != 0){ Text.AddZeroed(); }
FAES::EncryptData(Text.GetData(),Text.Num(),AES_KEY);

为了保证加密算法的安全性和性能,要求输入数据和密钥都是16字节的整数倍(一个AES分组长度),对于不满足的数据,需要填充对齐。
不然会触发断言:

1
2
checkf((NumBytes & (AESBlockSize - 1)) == 0, TEXT("NumBytes needs to be a multiple of 16 bytes"));
checkf(NumKeyBytes >= KEYLENGTH(AES_KEYBITS), TEXT("AES key needs to be at least %d characters"), KEYLENGTH(AES_KEYBITS));

如下图所示:

基于这种方式,需要记录加密前数据的原始大小和填充大小,在解密之后进行修正。

加密

前面讲到了,可以通过对Summary的加密实现我们的需求,但在什么样的时机去做加密这件事情呢?

合适的时机是在保存时,当保存资源时,是把内存中的数据写入到磁盘。想要实现加密,就要在保存时拦截住存盘的行为,通过把原始的Summary数据执行自定义的加密行为之后,再进行存盘。

最终的实现,是要把uasset的文件结构,在序列化之后,改成如下的形式:

使用一个独特的Magic Number标识是否为加密后的资源,Header中记录数据大小,后续跟加密后的Buffer。

在UE中保存资源会触发三次PackageFileSummary的序列化:

  1. 保存 ArIsSaving == true,但此时的数据是不完全的,只是为了计算Summary的大小,写入一个填充Summary大小的数据,为后续的数据计算偏移。

  1. 保存 ArIsSaving == true,此时数据是完全的,从pos 0重新写入Summary,序列化的大小与第一次写入时相同。

  1. 读取,当资源被重新保存之后就会重新生成AssetRegistry数据,就是通过扫描变动资产的Summary进而获取的。

在实现中,加密要能覆盖到这三次序列化的所有情况,并且Cook时的序列化也会走到这个流程。

资源加密后,如果在不支持解密的引擎中打开资源会无法识别为资源:

解密

同样地,在尝试加载资源时,要对已经执行加密的资源,进行解密。

以建立AssetRegistry的加载为例:

此时是ArIsLoading的状态,并通过一个空的Summary的引用用于接收反序列化后的结果。基于前面的加密结构,执行反向操作。

从磁盘加载TAG,检测是否为加密后的MagicNumber,然后读取Header,进而将加密后的数据从磁盘读出,然后进行解密。并在解密之后,正确地把Summary填充。

自定义流程

上面的内容,就是在UE中实现资源加密的核心内容和基础逻辑。但除此之外,在本实现框架内,可以尽可能多地去实现额外的混淆流程。

不同的项目还可以按需在各个阶段添加自定义的流程,如加密额外的数据、往资源中填充一些垃圾混淆数据、提升Key的安全性等等。保证加密行为的复杂性,增加被破解的成本。

严格来说,没有绝对的安全,只能尽可能地增加逆向的成本,当破解的成本大于重做本身,那破解也就没有意义了。

结语

本篇文章研究了uasset文件结构,并提出了一种基于Summary的加密方案。基于此种方式,项目侧就可以加密产出的资源,防止将资源直接导出到其他项目中。
但具体的落地实现还需要根据项目去做一些自定义的混淆流程,避免被简单破解的情况,这部分内容就不再赘述了。

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

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

本文标题:资源管理:UASSET资源加密方案
文章作者:查利鹏
发布时间:2023年04月07日 12时50分
本文字数:本文一共有2.9k字
原始链接:https://imzlp.com/posts/32412/
许可协议: CC BY-NC-SA 4.0
文章禁止全文转载,摘要转发请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!