Reversing Etrange Overlord Demo Asset Bundle Encryption
Breaking down the custom bundle-loading stream used by Etrange Overlord Demo.
Introduction
Several asset bundles from the Etrange Overlord Demo were refusing to behave like normal Unity files. A post on ResHax showed scrambled data where the usual UnityFS header should have been. Farther into the files, however, the data still appeared to have some structure. AES was the working theory.
It looked like a good problem to dig into. The game could read these bundles somehow, which meant the missing step had to exist in its loading code. I wanted to find that step and see what happened before the data reached Unity.
At this point, I did not know whether the entire file was encrypted, only part of it was changed or whether the AES theory was correct at all. Time to open the files and find out.
Looking at the bundle files
I opened several bundles in a hex editor before touching GameAssembly.dll. None of them began with UnityFS, and searching through the files did not uncover the signature at another offset.
Useful, but only just. It did not tell me what the game had done to them.
I was not getting an answer by staring at the scrambled bytes. Time to see what the game did when it opened the file.
Figure 1. The opening bytes of an encrypted asset bundle in the hex editor.
Finding the bundle loader
I searched the names in IDA for AssetBundle and found AssetBundleEncryptProvider and AssetBundleEncryptResource almost straight away.
Provide does not contain much of interest. It makes the resource, calls Load and returns. So I moved into Load.
Next comes the filename setup. The .bundle extension is removed. Names under 16 characters get underscores appended, while longer names are clipped. Either way, the salt ends up at 16 characters. Load gets the UTF-8 bytes, opens the file and puts Partida.SeekAesStream in front of the original stream.
The wrapper call has 0, 128 and 128. Reading Initialize cleared those up: offset zero, 128 bytes to process and a 128-bit AES key.
AssetBundle.LoadFromStreamAsync gets the wrapper. Not the original file stream. That was the link back to the odd header from the first hex check.
Figure 2. AssetBundleEncryptResource.Load preparing the encrypted stream.
Building the bundle key
In SeekAesStream.Initialize, I mapped the constructor call to Rfc2898DeriveBytes_ctor_Default1000_SHA1. The function asks it for keySizeBits / 8 bytes. With 128 coming from the caller, that gives a 16-byte key.
The password I recovered is xKci6s8ZAkbfj85pWbR9BKFfZqKFG3Yr6Urtw9D6. Every bundle uses it. The other input comes from the filename, so the key changes even though the password does not. I derived a few of them separately to make sure.
After that, the constructor sets Mode to 2 and Padding to 1. The .NET enum values map to ECB and None. The IV is a fresh 16-byte array filled with zeroes, and CreateEncryptor finishes the setup.
ECB looked odd here at first. It is not run across the bundle in the usual way. The next routine feeds counters into that encryptor and XORs its output with the file data.
Figure 3. SeekAesStream.Initialize setting up PBKDF2 and the AES options.
How the bytes are changed
The AES setup stopped looking quite so strange once I got into the byte routine. The stream position is divided by 16. Add one and that becomes the counter, beginning at 1. Its bytes go into the input block in little-endian order, then through AES.
What comes back is XORed with the bundle. Cross into another block and the counter moves with it. Put the same bytes through the routine again and the change is undone.
There is a cutoff at 128 bytes. After that, SeekAesStream reads from the original file without touching it. That was the missing bit. The Etrange Overlord Demo had scrambled the header rather than the whole bundle.
Figure 4. SeekAesStream calculating the counter from the current stream position.
Figure 5. The counter block being encrypted and XORed with the bundle data.
Getting the Unity header back
I put the recovered routine into a small script and picked one bundle for a test. Its filename became the salt and PBKDF2 gave me the 16-byte key. From there, I ran the counter loop over 0x80 bytes and copied everything after that as-is.
The output opened with UnityFS at offset zero. The other header fields made sense too. Even the length recorded there agreed with the file on disk. I put the remaining bundles through the same check and kept seeing valid headers.
Figure 6. Archive Lab parsing the restored UnityFS bundle and listing its contents.
The Python script
I wanted the decryptor to work without IDA or the game running, so I rewrote it in Python. Point it at one bundle or a directory, then give it somewhere to put the decrypted copies. It only reads the encrypted files.
There is one package to install. PBKDF2 comes from hashlib, which ships with Python, while the AES call uses cryptography:
1
python -m pip install cryptography
The checks from my first test are still there. Nothing is saved unless the result begins with UnityFS and the size in its header agrees with the file itself. A wrong filename, password or key should fail at that point.
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
import argparse
import hashlib
import shutil
from pathlib import Path
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
def build_salt(bundle_path: Path) -> bytes:
name = bundle_path.stem.lower()
return name[:16].ljust(16, "_").encode("utf-8")
def derive_key(bundle_path: Path, password: bytes) -> bytes:
return hashlib.pbkdf2_hmac(
"sha1",
password,
build_salt(bundle_path),
1_000,
dklen=16,
)
def transform_prefix(prefix: bytearray, key: bytes) -> None:
encryptor = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
for offset in range(0, len(prefix), 16):
counter = offset // 16 + 1
counter_block = counter.to_bytes(8, "little") + bytes(8)
key_stream = encryptor.update(counter_block)
block = prefix[offset : offset + 16]
prefix[offset : offset + len(block)] = bytes(
value ^ key_stream[index] for index, value in enumerate(block)
)
encryptor.finalize()
def validate_unityfs(header: bytes, file_size: int) -> None:
if not header.startswith(b"UnityFS\0"):
raise ValueError("decryption did not produce a UnityFS header")
position = 12
try:
for _ in range(2):
position = header.index(0, position) + 1
except ValueError as error:
raise ValueError("the UnityFS header is incomplete") from error
if position + 8 > len(header):
raise ValueError("the UnityFS header does not contain a file size")
declared_size = int.from_bytes(header[position : position + 8], "big")
if declared_size != file_size:
raise ValueError(
f"UnityFS size mismatch: header={declared_size}, file={file_size}"
)
def decrypt_bundle(
source: Path,
destination: Path,
password: bytes,
overwrite: bool,
) -> None:
if source.resolve() == destination.resolve():
raise ValueError("input and output paths must be different")
file_size = source.stat().st_size
with source.open("rb") as input_file:
prefix = bytearray(input_file.read(128))
transform_prefix(prefix, derive_key(source, password))
validate_unityfs(prefix, file_size)
destination.parent.mkdir(parents=True, exist_ok=True)
with destination.open("wb" if overwrite else "xb") as output_file:
output_file.write(prefix)
shutil.copyfileobj(input_file, output_file)
def decrypt_path(
source: Path,
destination: Path,
password: bytes,
recursive: bool,
overwrite: bool,
) -> None:
if source.is_file():
output_file = (
destination / source.name if destination.is_dir() else destination
)
decrypt_bundle(source, output_file, password, overwrite)
print(output_file)
return
if not source.is_dir():
raise FileNotFoundError(f"input path does not exist: {source}")
bundles = sorted(
source.rglob("*.bundle") if recursive else source.glob("*.bundle")
)
if not bundles:
raise FileNotFoundError(f"no bundle files found in: {source}")
for bundle in bundles:
output_file = destination / bundle.relative_to(source)
decrypt_bundle(bundle, output_file, password, overwrite)
print(output_file)
def create_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Decrypt Etrange Overlord Demo Unity asset bundles."
)
parser.add_argument(
"input",
type=Path,
help="encrypted bundle or directory",
)
parser.add_argument(
"output",
type=Path,
help="output file or directory",
)
parser.add_argument(
"-r",
"--recursive",
action="store_true",
help="search subdirectories when the input is a directory",
)
parser.add_argument(
"-f",
"--force",
action="store_true",
help="overwrite existing output files",
)
return parser
def main() -> None:
parser = create_parser()
arguments = parser.parse_args()
try:
decrypt_path(
arguments.input,
arguments.output,
b"xKci6s8ZAkbfj85pWbR9BKFfZqKFG3Yr6Urtw9D6",
arguments.recursive,
arguments.force,
)
except (OSError, ValueError) as error:
parser.exit(1, f"error: {error}\n")
if __name__ == "__main__":
main()
Run it against one bundle like this:
1
python .\etrange_bundle_decrypt.py ".\encrypted.bundle" ".\decrypted.bundle"
For a directory, pass both folders instead:
1
python .\etrange_bundle_decrypt.py ".\Master" ".\Master_decrypted"
Download
You can grab the script from the project page below:
Etrange Overlord Demo Bundle Decryptor
Closing notes
The password was easy enough to find. The stream class was where I lost most of my time. I kept seeing ECB in the setup and trying to make the file fit that idea, which sent me in the wrong direction. It only made sense after I followed the counter and saw where the XOR happened.
Once I had the stream class mapped, the idea behind it was almost annoyingly sensible. Change just enough of the header to stop Unity tools from recognizing the bundle and leave everything else alone.
