在开发项目中后期,直至上线之前,通常还需要处理客户端资产安全性的问题,避免直接利用公版引擎和相关工具直接能把资源解包,以及对于一些后续版本的关键资产(如商业化资产)的保护。
所以需要在引擎层面,对UE的默认加密机制做一些改造和混淆,使其不那么容易地被破解。但因为最终解密还是要在客户端执行的,所以无法保证绝对的安全性,只能尽可能地拉高解密成本。
本篇文章会介绍我对UE默认加密机制的分析,并介绍几种加固思路。因为安全方面的内容较为敏感,所以文章内不会提供具体的修改方法,只提供加固的可行性思路。
默认加密分析
项目加密配置
可以在ProjectSettings
-Project
-Crypto
中配置加密参数:
该配置会存储在工程的Config/DefaultCrypto.ini
目录中。
1 | [/Script/CryptoKeys.CryptoKeysSettings] |
这些配置会被用在UnrealPak打包资源时,以及在编译代码时,把解密的Key编译到可执行程序之内。
其中的EncryptionKey
、SigningModulus
以及SigningPrivateExponent
的值都是BASE64编码的,实际编译至代码中的密钥是解码之后的。
1 | // Parse encryption key |
虽然加密密钥是保存在
Config/DefaultCrypto.ini
中的,但是在打包时会把它剔除,它不会进包,所以不用担心直接被打进版本的问题。
编译时写入
在编译时,执行TargetRules.cs的逻辑,会读取工程的ini,并构造出一个EncryptionAndSigning.CryptoSettings
,记录了当前工程的加密和签名配置。
代码在:Engine/Source/Programs/UnrealBuildTool/System/EncryptionAndSigning.cs
如果项目的配置文件中,开启了加密和签名,会往ProjectDefinitions
中添加KEY的宏:
1 | // Setup macros for signing and encryption keys |
这些宏被添加到了项目中的所有模块的定义中:
1 |
这两个宏,会被用在IMPLEMENT_PRIMARY_GAME_MODULE
中:
1 | IMPLEMENT_PRIMARY_GAME_MODULE(FGameModule, FGame, "FGame"); |
它的作用为定义模块。在打包后的运行时,代码编译都是Monolithic的。
所以对于移动平台来说,IMPLEMENT_PRIMARY_GAME_MODULE
的宏定义为:
1 |
前面列出的,在TargetRules.cs
里定义的记录加密和签名Key的宏,是一个嵌套的结构:
1 |
从而转发到了UE_REGISTER_ENCRYPTION_KEY
和UE_REGISTER_SIGNING_KEY
这两个宏里。它们也是在ModuleManager.h
里定义的。
UE_REGISTER_ENCRYPTION_KEY
它的所用是构造出一个FEncryptionKeyRegistration
的类定义,并定义出一个GEncryptionKeyRegistration
对象。
并在它的构造函数中,注册了获取加密Key的Callback。
1 | // Macro for registering encryption key for a project. |
预处理后的结果为:
1 | struct FEncryptionKeyRegistration |
UE_REGISTER_SIGNING_KEY
它的所用是构造出一个FSigningKeyRegistration
的类定义,并定义出一个GSigningKeyRegistration
对象。
并在它的构造函数中,注册了获取签名Key的Callback。
1 | // Macro for registering signing keys for a project. |
预处理后的结果为:
1 | struct FSigningKeyRegistration |
简单来说,UE解密密钥的序列化方式,本质上就是在UBT里把解密的KEY,以宏定义的形式编译到了代码中,供运行时解密访问。运行时访问的部分后面会具体介绍。
打包PAK启用加密
引擎的所有依赖的文件和资源,都存储在PAK文件中。当项目指定了加密和签名配置,实际上影响的是在打包PAK时对资源进行加密和签名。
在调用UnrealPak
时,可以传递-cryptokeys=
参数,读取加密配置。
UAT打包时,会根据DefaultCrypto.ini
里的配置在Saved/Cooked/[PLATFORM]/[PROJECT_NAME]/Metadata
目录下生成一个json文件,用于UnrealPak打包时指定。
该json文件的结构为:
1 | { |
引擎打包pakchunk0时的命令行:
1 | /Users/buildmachine/Client/Saved/StagedBuilds/IOS/cookeddata/fgame/content/paks/pakchunk0-ios.pak |
在PakFileUtilities.cpp
中的LoadKeyChain
函数中,从命令行读取Crypto.json
并解析出加密配置:LoadKeyChain,将在PAK生成时使用。
在生成PAK时,实际执行逻辑在PakFileUtilities.cpp
的CreatePakFile
函数中:
1 | bool CreatePakFile(const TCHAR* Filename, TArray<FPakInputPair>& FilesToAdd, const FPakCommandLineParameters& CmdLineParameters, const FKeyChain& InKeyChain); |
最后一个参数传递的就是加密配置,如果检测到KeyChain配置里有启用加密,它就会使用AES进行加密:
1 | FMemoryImageResult Result; |
本质上来说,就是用AES去加密数据,并把加密后的数据序列化到PAK文件里。
我在之前的文章(虚幻引擎中Pak的运行时重组方案)中,曾详细介绍了PAK的序列化结构:
而UE的加密,就是针对PAK结构里的某些数据进行的。图与上文结合起来看更容易理解。
运行时获取解密Key
对于Pak的加密和签名,在运行时都需要获取对应的key才可以。
而在上面UE_REGISTER_ENCRYPTION_KEY
与UE_REGISTER_SIGNING_KEY
的定义中,可以看到,里面有两个extern
:
1 | extern void RegisterEncryptionKeyCallback(void (*)(unsigned char OutKey[32])); |
这两个函数就是引擎获取Key的方法:
1 | typedef void(*TSigningKeyFunc)(TArray<uint8>&, TArray<uint8>&); |
在这两个函数被调用时(也就是FEncryptionKeyRegistration
与FSigningKeyRegistration
的构造函数)。
回想一下之前文章里写的Monolithic模块的初始化流程:UE插件与工具开发:基础概念#Monolithic 模式。
它们会在模块启动的时候,就会被创建出来。
在加载PAK时,会调用FCoreDelegates::GetPakEncryptionKeyDelegate()
来获取密钥:
1 | void FPakPlatformFile::GetPakEncryptionKey(FAES::FAESKey& OutKey, const FGuid& InEncryptionKeyGuid) |
之后用来解密数据:
1 | void DecryptData(uint8* InData, uint32 InDataSize, FGuid InEncryptionKeyGuid) |
从而实现了从构建时加密到运行时解密的整套流程。
Pak的序列化结构
之前文章中已经提到过,UE的PAK是一种典型的Archive的结构:
通过一定的序列化规则生成出来的,而且运行时读取也是按照格式规则进行的:
- 挂载PAK时首先从文件尾读取PakInfo,然后获取到Index的偏移位置
- 根据Index偏移获取到EncodePakEntrys,可以获取到每一个文件的PakEntry信息
- 每个PakEntry又记录了当前的文件的信息,用于指导如何读取和解压
它也是UE本身虚拟文件系统的重要部分。
但是,因为UE源代码是开放的,所以PAK本身的序列化结构并没有什么保密性可言,如果通过逆向拿到加密密钥之后,就能够直接通过官版引擎UnrealPak的逻辑解出PAK内的文件。
而且也有一些第三方工具,如UModel或UnrealPakViewer等,都可以做到这一点。所以,除了密钥、解密混淆之外,还需要对PAK本身的序列化结构进行改造。
加固方案
默认方案的缺点
根据前面两个小结的介绍,可以得出下面的结论:
- 解密的key直接以宏定义的形式编译到代码中
- 通过引擎源代码 + 静态分析,比较容易能够定位到获取密钥的函数
- 代码中的导出符号,可以直接暴露解密函数位置
- 代码中的PAK字符串(日志/ProfilingTag)可以辅助定位到关键的解密函数
- 默认使用AES加密,有明显的代码特征,易被分析
- PAK的序列化格式比较通用,获取密钥后能被公版引擎的UnrealPak或UModel/UnrealPakViewer等工具解密
所以,基于这些情况,我们需要对加密方案做一些多方面的改造,尽可能地提高解密成本。但同样地,基于安全的需求我只能介绍一些加密思路,并不会提供具体的代码。
密钥加固
第一节提到了,密钥是通过Base64编码后生成了一个宏定义,然后运行时获取的,我们可以通过写一些独特的密钥混淆逻辑,使对密钥本身做一层处理。避免直接静态分析就可以拿到编码后的密钥。
符号剔除
默认情况下,如果没有把符号剔除的话,用IDA等逆向工具打开可执行程序,是可以直接看到函数名字的:
这样直接就能看到解密函数,非常容易就能拿到密钥了。所以需要对所有的发行平台的包,都需要剔除符号。
剔除后的符号情况:
可以参考我上一篇文章的内容:极致优化 UE Android APK 的大小
PAK字符串剔除
相同的原因,引擎在挂载PAK相关逻辑中打印出了很多的日志,分析这些日志字符串的访问位置,也能辅助解密逻辑的定位:
找到解密函数后,附加调试器,看一下内存就能拿到实际的密钥了。
所以,剔除pak相关的字符串是非常必要的。
剔除LOG
剔除方式,希望在Dev里依然保留日志,但是在Shipping时剔除:
1 | //++[lipengzha] 在shipping时剔除PAK相关字符串 |
所以可以对UE_LOG
的宏做一层封装,Dev默认转发,Shipping时替换为空宏。
剔除BOOT_TIMING
还需要剔除SCOPED_BOOT_TIMING
引入的字符串:
1 | //++[lipengzha] 在shipping时剔除PAK相关字符串 |
然后把IPlatformFilePak.cpp
与IPlatformFilePak.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 | class CORE_API FCoreDelegates |
所以可以在更新流程中实现一套动态密钥机制,加密后的PAK在程序本体内不包含解密密钥,只要到特定时间版本开放后,再把密钥动态下发,客户端才能使用,这样能够保证不会被提前解包泄露的情况。
总结
本篇文章介绍了UE默认的加密逻辑,并且分析了潜在的风险,并提供了一些可行的加固策略,可以参考使用。
注:文章内介绍了引擎加密流程的分析以及逆向手段,只是为了分析如何能够更安全,并非破解教程。任何游戏逆向相关行为均与作者无关,谢谢!