I am trying to reverse engineer the asset encryption for "Arcane Knight : Idle RPG". I have made some progress but I am currently stuck. Any advice would be greatly appreciated.
Here is what I have found so far:
The game is built with Unity and uses IL2CPP.
I have successfully used Il2CppDumper to generate dummy DLLs from libil2cpp.so and global-metadata.dat.
Using Ghidra, I have located what I believe is the main asset loading function, LoadAsync, in the EM.AssetManagement.AssetBundleAssetLoader class at address 0x3ECF6F8.
I've traced the function calls and found a promising loop inside the function FUN_0381a354, which seems to process the data blocks. The actual decryption seems to be inside a function it calls, FUN_037d7b80.
I'm having trouble identifying the exact decryption algorithm (like XOR) and the key inside these functions
Below is a practical “check-list” I use when I’m stuck at exactly the same point (found the right function but can’t recognise the cipher/key).
Pick the items that match your tool chain and the time you want to invest.
────────────────────────────────────────
- Tidy-up the static view
────────────────────────────────────────
1.1 Give the functions real names.
• Rename FUN_0381a354 to something like handleBlockLoop() and FUN_037d7b80 to decryptBlock() in Ghidra.
• Decompile both, then press “o” to switch to the decompiler’s “graphs” view – much easier to see loops and branches.
1.2 Identify the I/O interface.
• Where is the input buffer coming from? Look for memcpy/memmove or simple pointer arithmetic right before FUN_037d7b80.
• How large is one round? If the loop in 0381a354 increments the pointer by 0x10 or 0x08 you can already guess:
0x10 → AES / MD5 style blocks
0x08 → DES / TEA
any length → XOR or rolling XOR
1.3 Collect the constants.
• Highlight every literal in decryptBlock() – Ghidra shows them in a side list.
• Typical giveaways:
0x9e3779b9 / 0xc6ef3720 → TEA / XXTEA
0x6a09e667 / 0xbb67ae85 … → SHA-256/512 IVs
AES S-box → 256-byte table starting with 0x63 0x7c 0x77 0x7b …
Tiny numbers (1,2,3,4) only → XOR / Rotate / Add
────────────────────────────────────────
2. Track data & code cross-refs
────────────────────────────────────────
2.1 Strings as bait
search_strings filter:
“BundleKey”, “Encrypt”, “EM.”, “bytes”, “IV”, “pass”, “salt”
Any function that references the same strings as decryptBlock() usually contains the key/IV generation logic.
2.2 Who else calls FUN_037d7b80?
Ghidra –> References –> Incoming.
If you see a one-shot caller that just passes two arguments (buf,len) and returns, that thin wrapper is perfect for hooking.
──────────────────────────────────────── 3. Do a one-time dynamic dump ──────────────────────────────────────── (IL2CPP = native ARM code, so normal Android debuggers/Frida work)
3.1 Get the symbol address at runtime
• adb shell “cat /proc/<pid>/maps | grep libil2cpp.so” → base
• RuntimeAddress = base + 0x37d7b80 – imageBase you set in Ghidra.
3.2 Hook and dump Example Frida snippet:
const decrypt = Module.findBaseAddress("libil2cpp.so").add(0x37d7b80);
Interceptor.attach(decrypt, {
onEnter(args) {
this.buf = args[0];
this.len = args[1].toInt32();
},
onLeave(ret) {
const data = Memory.readByteArray(this.buf, this.len);
send({event:"decrypted", size:this.len}, data);
}
});
Run the game once, capture the plaintext bundle → now you have ground truth to compare with whatever algorithm you reverse statically.
────────────────────────────────────────
4. Recognise common Unity bundle “tricks”
────────────────────────────────────────
• Simple XOR with a short key (4–32 bytes)
– You’ll see byte ^ key[i & mask]
pattern.
• XOR preceded by LZ4/zip decompression
– Look for calls to LZ4_decompress_safe() (string “decompress_safe” often left in binary).
• AES-CTR / AES-CBC
– You’ll spot the 256-byte S-box table or direct calls to OpenSSL/BoringSSL functions if they didn’t strip symbols.
• XXTEA (popular in Asian mobile games)
– Two 32-bit loops, delta = 0x9e3779b9, rounds = 32-64.
────────────────────────────────────────
5. Finding the key/IV
────────────────────────────────────────
5.1 Hard-coded array
– Search for db XX XX XX XX XX
where XX are the constants you saw being XORed with input.
– In IL2CPP builds those arrays often live in .rodata right below the function.
5.2 Generated from device/account
– Follow the call chain upward until you hit UnityEngine.Application… functions (device GUID, playerPrefs, etc.).
– Much easier to just dump the key at runtime with Frida’s Interceptor.returnValue.
5.3 External file / native-C# bridge
– Any call into android :: ContentResolver
, JNI_OnLoad
, or EM.FileManager
could be reading an obb/asset for the key.
──────────────────────────────────────── 6. Validate quickly ──────────────────────────────────────── When you think you know the algorithm & key:
- Copy 32–64 bytes of raw encrypted data from the first bundle block (use adb pull).
- Re-implement the suspected algorithm in Python.
- Compare result with the memory dump from section 3. If they match – done!
────────────────────────────────────────
7. If you’re still stuck
────────────────────────────────────────
• Post the decompiled pseudocode of FUN_037d7b80 (or share the constants you see).
Community eyes can often spot TEA/AES patterns instantly.
Good luck! Once you get a single bundle to decrypt, the rest of the game’s assets fall in minutes.
the decompiled code
I skim-med the pseudocode you uploaded (FUN_03ecef74). It is a gigantic “driver” that:
• Allocates/initialises a huge amount of game infrastructure (UI, scene graph, audio, network, etc.).
• Dispatches to many other helpers (names that start with 03f7…, 046e…, 06b1… and so on).
• Finishes by pushing several resources into job queues and returns.
In other words: it is not the inner decryptor you’re hunting. It is several layers above it – most likely the coroutine that orchestrates an entire bundle-loading phase. Reversing it won’t reveal the cipher itself; it will only keep you busy reading Unity plumbing.
How to get back on the right track
-
Jump to 0x037d7b80 (your suspected decrypt function) in Ghidra and let the decompiler regenerate that function alone.
• It should be tiny compared with the 1 000-line blob you just posted.
• If it still looks huge, you are one level too high; step into the callee(s) that actually touch the data buffer. -
Visual cues to confirm you are in the decryptor:
a. The function’s first argument is usually avoid* buffer
oruint8_t* data
.
b. Inside the body you should see a tightfor
/while
loop with one of these patterns:
– XOR (^
,eor
,xor
,vpxor
…)
– Add/Sub (+ key[i&3]
)
– Rotate (ROR
,ROL
)
– Table look-ups (Sbox[ byte ]
)
c. Very few other calls; maybe one to memcpy/memmove or to LZ4 if the file is compressed first. -
Look for constants • 0x9e3779b9 / 0xc6ef3720 → TEA / XXTEA
• 256-byte table starting with 0x63 0x7c 0x77 … → AES S-box
• Two or four hard-coded 32-bit words (e.g. 0xA3B1BAC6) → XTEA / Blowfish variants
• Only 1,2,3,4 as constants → plain XOR with rolling key -
Use Ghidra’s “Search -> For Scalars” and “Search -> For Strings” • The real key is often an 8–32 byte static array in .rodata right next to the decrypt function.
• Or it is fetched from PlayerPrefs / Android keystore; if so you’ll see calls to JNI or UnityEngine.PlayerPrefs before the decrypt loop. -
Dynamic confirmation (quick & easy) • Attach Frida to libil2cpp, set
Interceptor.attach
at 0x037d7b80.
• Dump the input and output buffers for one call.
• Compare with a Python proof-of-concept implementation of the candidate algorithm; iterate until they match.
Why the big function you posted is still useful
It tells you:
• Which resource-loading branch ends up calling the decryptor (follow the call chain: FUN_03ecef74 → … → FUN_0381a354 → FUN_037d7b80).
• Where the plaintext goes afterwards (good for breakpoints).
• Which global singletons are initialised right before the decrypt (good places to search for the key if it is generated at runtime).
Next steps
- Decompile only FUN_037d7b80 (or its immediate callee if it just forwards).
- Post that much smaller snippet (a few dozen lines) – the community can usually identify the cipher immediately.
- Once the algorithm is confirmed, dump a sample ciphertext/plaintext pair with Frida and re-implement the decryptor in Python to make sure you have the right key.
Let me know when you have the 0x037d7b80 pseudocode or the raw constants; I’ll help you recognise the exact algorithm and pull the key out.