Unreal Resource Management: A raw uasset Encryption Scheme

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

In game project development, a large number of developers from various aspects such as development, art, planning, and outsourcing will be involved. For reasons of resource security and information confidentiality, complex permission controls and synchronization logic are usually implemented.

However, leaving aside permission control and focusing on the resources themselves, in UE, resources are upward compatible, meaning resources can be opened directly with the same version engine or a higher version engine. This means that distributed resources can be used directly in other projects. Therefore, ensuring the security of resources during the development phase is a key focus.

This article provides an idea for encrypting the original UASSET resources in the project and introduces the basic principles that can be used for resource encryption. However, publicly disclosing the specific encryption implementation would be akin to running naked, so this article will only provide an analysis of the uasset resource structure and ideas for implementing encryption, but will not provide specific implementation code.

Core Requirements

  1. Resources exported from the project side can only be accessed within the proprietary engine and cannot be opened with the public version engine.
  2. Encryption only occurs in the Editor stage, without affecting runtime.
  3. Minimize the impact on assets, evaluating the potential for resource damage and its effect on Cook.
  4. Ensure that unencrypted resources remain unaffected, allowing both encrypted and unencrypted resources to be read normally.

uasset Format Analysis

uasset files are resource files for UE, used to store data and content in games. Different resource types, such as textures, models, and animation sequences, are converted into UE’s uasset resources for access and editing within the engine.

Tools like WinHex64 can be used to view uasset files in binary format:

The first four bytes of the uasset file are the PACKAGE_FILE_TAG:

It is a fixed value: Core/Public/UObject/ObjectVersion.h

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

Used to identify whether the file is a uasset resource; when the engine attempts to load the resource, it will first check this TAG. If it is 0x9E2A83C1, it will be considered a resource, and then subsequent serialization actions will occur.

In simple terms, if encryption is not considered, the only way to make the resources unrecognizable in other engines is to change the first four bytes of the uasset file header to another magic number.

Summary Serialization

When the first four bytes of the file are detected as PACKAGE_FILE_TAG being 0x9E2A83C1, the serialized data will be read from the disk to construct a PackageFileSummary structure.

After debugging, here is the data for the PackageFileSummary:

The PackageFileSummary is also serialized directly into the uasset file header, starting from byte 0:

However, the engine does not serialize the entire PackageFileSummary entirely; it serializes each attribute individually, meaning it varies depending on the engine version, different settings, and resource situations. Thus, it is not a fixed size of data.

Taking the Summary data shown in the screenshot as an example, once serialized, I listed the attributes and the byte order of serialization:

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 0x00656E6E4E (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)
...

It can be seen that it is in a compact form, serialized attribute by attribute.

The serialization call stack of PackageFileSummary:

The engine code is found in PackageFileSummary.cpp, specifically in the overloaded operator<< function:

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)
{
// ...
}
}

The serialization mechanism in UE is based on the form of FArchive, meaning whether to read or write depends on the type of Archive passed in. Thus, the overloaded operator<< can realize both loading and writing.

Encryption Ideas

From the previous section, we can understand the asset recognition and serialization method of UE. Now let’s look at what the Summary contains.
Similarly, using the aforementioned resource as an example:

We can see that:

  1. Summary provides an overview of the entire resource.
  2. It records the offset values of key data.
  3. To load data from the resource, it is necessary to base the loading on the offset information contained in the Summary.

If the specific offsets of corresponding data are unknown, the resource cannot be successfully loaded.

Thus, we can convert our encryption needs for uasset into summary encryption.

The ultimate goal is to make our resources unrecognizable in public versions or other engines, and prevent access to the offset values of key data, which can achieve the effect of encryption.

Encryption Algorithm

The engine has various built-in encryption algorithms, such as AES and SHA. This article will take AES as an example. AES is a block cipher-based encryption algorithm with a block length of 128 bits (16 bytes).

AES has several encryption modes, with UE adopting the CBC mode (Cipher Block Chaining), which encrypts by XORing the previous ciphertext with the current plaintext block, helping to avoid identical ciphertexts for identical plaintext blocks and preventing statistical attacks.

Here is a simple code snippet for using AES encryption in UE:

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);

To ensure the security and performance of the encryption algorithm, it is required that both the input data and the key be multiples of 16 bytes (one AES block length). Data that does not meet these criteria must be padded for alignment; otherwise, an assertion will be triggered:

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));

As shown in the figure below:

Based on this method, it is necessary to record the original size of the data before encryption and the padding size to correct it after decryption.

Encryption

As mentioned earlier, we can achieve our needs through Summary encryption, but when should we perform this encryption?

The appropriate timing is during the save operation. When saving resources, data in memory is written to disk. To achieve encryption, the save behavior should be intercepted to encrypt the original Summary data through custom behavior before writing it to disk.

The final implementation is to change the structure of the uasset file after serialization to the following form:

Using a unique Magic Number to indicate whether it is an encrypted resource, the header records the data size, followed by the encrypted buffer.

In UE, saving resources will trigger three serializations of PackageFileSummary:

  1. While saving ArIsSaving == true, the data is not complete yet; it is only to calculate the Summary size, writing a padding summary size for subsequent offset calculations.

  1. Saving ArIsSaving == true, now the data is complete, from pos 0, re-writing the Summary, with serialized size equal to that during the first write.

  1. Reading; after the resource is re-saved, the AssetRegistry data is regenerated by scanning the changed asset’s Summary.

In the implementation, encryption must cover all cases of these three serializations, and serialization during Cook will also follow this process.

Once encrypted, if the resource is opened in an engine that does not support decryption, it will not be recognized as a resource:

Decryption

Similarly, when attempting to load encrypted resources, decryption must be performed.

Taking the loading of AssetRegistry as an example:

At this point, it is in the state of ArIsLoading, using a reference to an empty Summary to receive the results of deserialization. Based on the previous encryption structure, reverse operations should be executed.

Load the TAG from the disk, check if it matches the encrypted MagicNumber, then read the Header, subsequently read the encrypted data from the disk and perform decryption. Finally, correctly fill in the Summary after decryption.

Custom Process

The above content outlines the core content and basic logic for implementing resource encryption in UE. In addition, within this implementation framework, additional obfuscation processes can be as implemented as possible.

Different projects can also add custom processes at various stages as needed, such as encrypting additional data, filling some junk obfuscation data into resources, enhancing key security, etc. The goal is to ensure the complexity of the encryption process and increase the cost of cracking.

Strictly speaking, there is no absolute security; we can only try to increase the cost of reverse engineering. Once the cost of cracking exceeds the cost of redoing, cracking becomes meaningless.

Conclusion

This article researched the uasset file structure and proposed an encryption solution based on the Summary. Based on this method, projects can encrypt the resources they produce, preventing them from being directly exported to other projects. However, specific landing implementations still require customizing some obfuscation processes based on the project to avoid being easily cracked; this part will not be elaborated further.

The article is finished. If you have any questions, please comment and communicate.

Scan the QR code on WeChat and follow me.

Title:Unreal Resource Management: A raw uasset Encryption Scheme
Author:LIPENGZHA
Publish Date:2023/04/07 12:50
World Count:7.4k Words
Link:https://en.imzlp.com/posts/32412/
License: CC BY-NC-SA 4.0
Reprinting of the full article is prohibited.
Your donation will encourage me to keep creating!