Post

Decrypting Lua Files from Dynasty Legends 2

A small change to XXTEA was enough to keep the Lua files encrypted.

Decrypting Lua Files from Dynasty Legends 2

Introduction


This one began with a request on ResHax. The attached .lua files were neither readable source nor Lua bytecode, and the first attempts to decode them had gone nowhere.

I installed the Steam version and found the same encrypted format in three separate Lua directories. Regular XXTEA still produced nothing readable.

Instead of guessing at more keys, I followed one file through the game’s Lua loader. The path left GameAssembly.dll and ended up in a small native plugin named Infra.dll. Both the key setup and the change to XXTEA were in there.

Lua only by name


I used Boss_LvBu.lua as the first sample because it was small enough to inspect without much noise. The file was 3,324 bytes long. Its contents were not text, and the opening bytes did not match compiled Lua either.

The three Lua directories held 11,328 files. I checked their lengths next: each one landed on a four-byte boundary.

The encrypted contents of a Dynasty Legends 2 Lua file Figure 1. Boss_LvBu.lua before decryption. There is no readable source or standard Lua bytecode header.

Finding the Lua loader


The IL2CPP metadata included a private method named TK2Aux.GameSystems.LuaSystem.LuaFileLoader. I found its native code in GameAssembly.dll and renamed it DL2_LuaFileLoader in the IDA database.

The function builds the script path and asks TKData_ReadBytes for the file. If that lookup fails, it tries the fallback path. Once either copy has been read, the byte array goes straight into DL2_DecryptLuaBytes.

That call was the useful part. LuaSystem only sees the byte array after DL2_DecryptLuaBytes has returned.

The Lua file loader passing encrypted bytes to the decryptor Figure 2. LuaFileLoader reads the requested script and sends its byte array to DL2_DecryptLuaBytes.

The call into Infra.dll


DL2_DecryptLuaBytes does not contain the cipher. It takes the data pointer and length from the managed byte array, then resolves a native import named DecoderBytes from Infra.dll.

The imported function receives the same buffer, its length and the padding flag. Its return value is only used to resize the managed array when padding should be removed. The Lua loader passes false, so the decrypted bytes remain in the original array.

DecoderBytes was implemented in Infra.dll, so I continued the analysis there.

The managed decrypt wrapper resolving DecoderBytes from Infra.dll Figure 3. The managed wrapper resolves Infra.dll!DecoderBytes and passes it the Lua file buffer.

Opening Infra.dll


Infra.dll exports DecoderBytes, the function requested by the managed wrapper. I renamed it DL2_DecryptXXTEABuffer after cleaning up its arguments and the small amount of NativeAOT setup around it.

The function works on the supplied buffer rather than making a new one. It divides the byte count by four and passes the result to DL2_XXTEADecryptWords, along with the key state kept by the plugin.

There is also an option to remove spaces from the end of the result. LuaSystem leaves that option disabled, but the matching encryption function explains why it exists: input is padded with ASCII spaces until its length is divisible by four.

The native DecoderBytes export calling the XXTEA word routine Figure 4. DecoderBytes passes the supplied buffer to the word-based decryptor.

Recovering the key


The four words in the plugin’s key state are not used as they are stored. At the start of DL2_XXTEADecryptWords, each one is XORed with a different constant and copied into a local key buffer.

I checked the key state after Infra.dll had initialised. Its stored words were 78ACEB50, 4F098F80, 97480E84 and B74C498E. Applying the four masks produced E69B92E9, 89E6B8A0, B09CE5AB and 32A783E5.

I read the four words back as little-endian bytes. Together they spell 钛核扫地僧2 in UTF-8, giving a key that is exactly 16 bytes long. The files still came out wrong with the usual XXTEA code. The key was right; the routine was using a different round count.

The XXTEA routine rebuilding its four key words Figure 5. Four XOR operations reconstruct the key used by the XXTEA loop.

The extra rounds


The first line in Figure 6 was the part I had been missing. Regular XXTEA uses 6 + 52 / word_count rounds. This routine uses 9 + 56 / word_count instead.

The delta value and the word-mixing loop are otherwise familiar. I changed the round count in my test decoder and tried Boss_LvBu.lua again. This time it came back as readable UTF-8 Lua. Using the regular count with the same key still left the file scrambled.

The modified XXTEA decryption loop used by Dynasty Legends 2 Figure 6. The familiar XXTEA loop is run with 9 + 56 / word_count rounds.

Back to readable Lua


With the key and round count in place, I ran the first file through a small test decoder. Boss_LvBu.lua now began with local _moveData = and continued as an ordinary Lua table.

I opened the result in the same hex view used for Figure 1. The difference was immediate. Animation names, counters and boolean values were all readable in the text column, with no second layer of encoding or compression to deal with.

I ran the same check over the other 11,327 files after that. The Python decoder and DecoderBytes returned identical output for all 11,328 files. Every result was valid UTF-8 Lua. I also sent them back through CoderBytes; each encrypted file matched its original byte for byte.

The contents of Boss_LvBu.lua after decryption Figure 7. The same file after decryption, now containing ordinary UTF-8 Lua source.

Download


I packaged the decoder as a Python script. The download and commands for running it are on the project page:

Dynasty Legends 2 Lua Decryptor

Closing notes


The .lua extension was accurate. These were ordinary source files underneath the encryption.

The different round count was why the usual XXTEA result looked wrong. Once the Python decoder matched Infra.dll, the files decrypted normally.

This post is licensed under CC BY 4.0 by the author.