在游戏项目开发中,会涉及到大量的开发、美术、策划以及外包等等各个方面的研发人员。出于资源安全和信息保密的考虑,通常会做一些复杂的权限控制和同步逻辑。
但抛开权限控制,仅探讨资源本身,在UE中资源是向上兼容的,用同版本号的引擎或者更高版本号的引擎可以直接打开。这意味着分发出去的资源,可以直接在另外的项目里使用。所以,该如何保证开发阶段资源的安全性是要重点关注的。
本篇文章提供一种加密工程中原始UASSET资源的思路,并介绍可以用于资源加密的基础原理。但如果公开具体的加密实现,就等同于裸奔,所以本篇文章仅提供对uasset资源结构的分析以及可用于实现加密的思路,但不会提供具体的实现代码。
核心需求
- 让项目侧导出的资源只在自有引擎内能才能访问,不能被公版引擎打开。
- 加密仅在Editor阶段,不对运行时造成影响。
- 降低对资产的影响,是否有可能造成资源损坏、以及对Cook的影响
- 不影响未加密的资源,让加密后和未加密的资源都能正常读取
uasset格式分析
uasset的文件是UE的资源文件,用于存储游戏中的数据和内容。导入不同的类型的资源,如贴图、模型、动画序列等,都会将其转换为UE的uasset资源,用于在引擎中访问与编辑。
可以使用WinHex64等工具,以二进制的形式查看uasset文件:
uasset文件的起始4个字节就是PACKAGE_FILE_TAG
:
它是一个固定值:Core/Public/UObject/ObjectVersion.h
1 |
用于标识该文件是否是uasset的资源,引擎尝试加载资源时,会先检测该TAG,如果为0x9E2A83C1
则认为它是资源,再进行后续的序列化行为。
简单地说,如果不考虑加密,只实现在其他引擎中不能识别资源而言,只把uasset文件头的4位数据改成另外一个magic number就可以了。
Summary序列化
当检测文件的前4个字节的PACKAGE_FILE_TAG
为0x9E2A83C1
后,则会继续从磁盘上读取已序列化的数据,从而构造出一个PackageFileSummary
的结构。
经过调试,PackageFileSummary的数据如下:
而PackageFileSummary也是直接序列化到uasset的文件头中的,从第0个字节开始:
但引擎中并非是完完全全把整个PackageFileSummary都序列化的,而是逐个属性序列化,引擎的版本、不同的设置、以及资源的情况不同而不同。所以它并不是一个固定大小的数据。
以上面截图中的Summary数据为例,将其序列化后,我列出了属性和序列化的字节排列顺序:
1 | Tag(int32) 0x9E2A83C1 C1832A9F 0x9E2A83C1 |
可以看到是逐属性的紧凑形式。
PackageFileSummary的数据序列化调用栈:
引擎中的代码为PackageFileSummary.cpp
中的operator<<
操作符重载:
1 | // Runtime\CoreUObject\Private\UObject\PackageFileSummary.cpp |
UE的序列化机制,是基于FArchive的形式进行的,究竟是读取还是写入,取决于传进来的Archive的类型。
所以,对于单个operator<<
操作符的重载既可以实现加载,也能够实现写入。
加密思路
通过前面一节,能够知道UE的资产识别和序列化方式。现在我们来看Summary包含了哪些内容。
同样以上面的资源为例:
可以看到:
- Summary的信息,是对整个资源的概述
- 记录个关键数据的偏移值
- 当需要加载资源中的数据,必须基于Summary中的信息,去对应的偏移中加载
如果不知道对应数据的具体offset,则无法成功地加载出资源。
那么到此,我们对于uasset的加密需求,就可以转换为对于Summary的加密。
最终的目的是让我们的资源,在公版或其他引擎中无法识别,并无法访问关键数据的offset偏移,就能够起到加密的作用。
加密算法
引擎内内置了多种加密算法,AES、SHA等等,本文以AES为例。AES是一个基于分组密码的加密算法,一个分组的长度是128位(16字节)。
AES具有多种加密模式,UE中采用的是CBC
模式(Cipher Block Chaining),采用前一块密文与当前块铭文异或的方式加密,能够避免相同明文块加密后密文相同的情况,避免被基于统计学的方式解密。
在UE中使用AES进行加密的简要代码:
1 | TArray<uint8> Text; |
为了保证加密算法的安全性和性能,要求输入数据和密钥都是16字节的整数倍(一个AES分组长度),对于不满足的数据,需要填充对齐。
不然会触发断言:
1 | checkf((NumBytes & (AESBlockSize - 1)) == 0, TEXT("NumBytes needs to be a multiple of 16 bytes")); |
如下图所示:
基于这种方式,需要记录加密前数据的原始大小和填充大小,在解密之后进行修正。
加密
前面讲到了,可以通过对Summary的加密实现我们的需求,但在什么样的时机去做加密这件事情呢?
合适的时机是在保存时,当保存资源时,是把内存中的数据写入到磁盘。想要实现加密,就要在保存时拦截住存盘的行为,通过把原始的Summary数据执行自定义的加密行为之后,再进行存盘。
最终的实现,是要把uasset的文件结构,在序列化之后,改成如下的形式:
使用一个独特的Magic Number标识是否为加密后的资源,Header中记录数据大小,后续跟加密后的Buffer。
在UE中保存资源会触发三次PackageFileSummary
的序列化:
- 保存
ArIsSaving == true
,但此时的数据是不完全的,只是为了计算Summary的大小,写入一个填充Summary大小的数据,为后续的数据计算偏移。
- 保存
ArIsSaving == true
,此时数据是完全的,从pos 0重新写入Summary,序列化的大小与第一次写入时相同。
- 读取,当资源被重新保存之后就会重新生成AssetRegistry数据,就是通过扫描变动资产的Summary进而获取的。
在实现中,加密要能覆盖到这三次序列化的所有情况,并且Cook时的序列化也会走到这个流程。
资源加密后,如果在不支持解密的引擎中打开资源会无法识别为资源:
解密
同样地,在尝试加载资源时,要对已经执行加密的资源,进行解密。
以建立AssetRegistry的加载为例:
此时是ArIsLoading的状态,并通过一个空的Summary的引用用于接收反序列化后的结果。基于前面的加密结构,执行反向操作。
从磁盘加载TAG,检测是否为加密后的MagicNumber,然后读取Header,进而将加密后的数据从磁盘读出,然后进行解密。并在解密之后,正确地把Summary填充。
自定义流程
上面的内容,就是在UE中实现资源加密的核心内容和基础逻辑。但除此之外,在本实现框架内,可以尽可能多地去实现额外的混淆流程。
不同的项目还可以按需在各个阶段添加自定义的流程,如加密额外的数据、往资源中填充一些垃圾混淆数据、提升Key的安全性等等。保证加密行为的复杂性,增加被破解的成本。
严格来说,没有绝对的安全,只能尽可能地增加逆向的成本,当破解的成本大于重做本身,那破解也就没有意义了。
结语
本篇文章研究了uasset文件结构,并提出了一种基于Summary的加密方案。基于此种方式,项目侧就可以加密产出的资源,防止将资源直接导出到其他项目中。
但具体的落地实现还需要根据项目去做一些自定义的混淆流程,避免被简单破解的情况,这部分内容就不再赘述了。