Sharpen your knives
We’re quietly hopeful we’ve found the decryption function at this point, starting at 0x7FFF4E2C2110
,
which given the rebased image base of 0x7FFF4E280000
puts it at offset 0x42110
in the file.
If we can call this function in isolation, we can decrypt the metadata without needing to reverse engineer that horrendous code,
albeit we won’t actually understand how the encryption works.
Note there is no guarantee this will “just work”. There may be other initialization that needs to be performed first,
but as always we try to take the path of least resistance. If it doesn’t work, we just have to go back to the disassembly
and look through the rest of the call stack to find any other extra code.
It’s arguably easier to use C or C++ for this test, but I like to work in C# so I’ll demonstrate with that.
The code is pretty simple:
using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
public static class Test
{
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi)]
private static extern IntPtr LoadLibrary(string path);
[DllImport("kernel32.dll")]
private static extern bool FreeLibrary(IntPtr hModule);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate IntPtr DecryptMetadata(byte[] bytes, int length);
public static void Main(string[] args) {
IntPtr hModule = LoadLibrary("UnityPlayer.dll");
IntPtr moduleBase = Process.GetCurrentProcess().Modules.Cast<ProcessModule>().First(m => m.ModuleName == "UnityPlayer.dll").BaseAddress;
byte[] metadata = File.ReadAllBytes("global-metadata.dat");
var pDecryptMetadata = (DecryptMetadata) Marshal.GetDelegateForFunctionPointer(moduleBase + 0x42110, typeof(DecryptMetadata));
IntPtr pDecrypted = pDecryptMetadata(metadata, metadata.Length);
byte[] decryptedMetadata;
Marshal.Copy(pDecrypted, decryptedMetadata, 0, metadata.Length);
FreeLibrary(hModule);
File.WriteAllBytes("global-metadata-decrypted.dat", decryptedMetadata);
}
}
The Windows APIs LoadLibrary
and FreeLibrary
are used to dynamically load and unload DLLs at runtime. .NET doesn’t have this functionality in the base class library so we use the
DllImport
attribute to import them directly from kernel32.dll
where they are defined (lines 8-12) (I will leave it as an exercise for the reader to figure out how
kernel32.dll
gets loaded
).
Delegates are .NET’s type-safe version of function pointers, so we define a delegate that matches the signature of the
function we want to call and decorate it with
UnmanagedFunctionPointer
(lines 14-15). The single argument – a member of the CallingConvention
enum – is extremely important to get right, as it specifies how the delegate arguments will be passed to the unmanaged function,
ie. whether they will be passed in registers, pushed onto the stack or a combination thereof. Get this wrong and the target function won’t
receive its arguments correctly, and probably crash. For 64-bit applications, it’s actually not much of a problem because all of the
common calling conventions
– cdecl, stdcall, fastcall and thiscall – behave the same way: the first four arguments are passed in RCX, RDX, R8 and R9,
and the rest are pushed on the stack from right-to-left; the return value is supplied in RAX. When working with 32-bit applications, however,
all of these calling conventions work differently and you must look at the assembly code to determine which is in use.
Honkai Impact is shipped as 64-bit binary so we don’t need to worry, but just for the sake of completeness let’s take a look at the call site:
.text:00007FFF41EE0720 ; 168: v27 = getFileSize(hFile_1, &error);
.text:00007FFF41EE0720 lea rdx, [rbp+57h+error]
.text:00007FFF41EE0724 mov rcx, rax
.text:00007FFF41EE0727 call getFileSize
.text:00007FFF41EE072C ; 169: metadataSize = v27.LowPart;
.text:00007FFF41EE072C mov r12, rax
.text:00007FFF41EE072F ; 170: if ( !*&error )
.text:00007FFF41EE072F cmp [rbp+57h+error], 0
.text:00007FFF41EE0733 jnz short loc_7FFF41EE076F
.text:00007FFF41EE0735 ; 172: hFile = mapFile(hFile_1, 0i64, 0);
.text:00007FFF41EE0735 xor r8d, r8d ; dwFileOffsetLow
.text:00007FFF41EE0738 xor edx, edx ; dwNumberOfBytesToMap
.text:00007FFF41EE073A mov rcx, rbx ; hFile
.text:00007FFF41EE073D call mapFile
.text:00007FFF41EE0742 mov r14, rax
.text:00007FFF41EE0745 ; 173: closeFile(hFile_1, &error);
.text:00007FFF41EE0745 lea rdx, [rbp+57h+error]
.text:00007FFF41EE0749 mov rcx, rbx
.text:00007FFF41EE074C call closeFile
.text:00007FFF41EE0751 mov rcx, r14 ; lpBaseAddress
.text:00007FFF41EE0754 ; 174: if ( *&error )
.text:00007FFF41EE0754 cmp [rbp+57h+error], 0
.text:00007FFF41EE0758 jz short loc_7FFF41EE0763
.text:00007FFF41EE075A ; 175: unmapFile(hFile);
.text:00007FFF41EE075A xor edx, edx
.text:00007FFF41EE075C call unmapFile
.text:00007FFF41EE0761 jmp short loc_7FFF41EE076F
.text:00007FFF41EE0763 ; 177: v0 = pDoSomethingWithMetadata(hFile, metadataSize);
.text:00007FFF41EE0763 mov edx, r12d ; _QWORD
.text:00007FFF41EE0766 call cs:pDoSomethingWithMetadata
.text:00007FFF41EE076C mov rsi, rax
On line 6, the return value from getFileSize
is stored in R12. On line 15, the return value from mapFile
is stored in R14. On lines 20 and 29, RCX and RDX are set to the two arguments of DoSomethingWithMetadata
– the memory pointer (from R14) and the file size (from R12) respectively. The function is called on line 30, and on line 31 the return value from RAX is stored in RSI.
Going back to the C# code, we first load UnityPlayer.dll
into memory (line 18) and then find its base address in memory (line 20). This code iterates through every DLL loaded in the process until it finds one called UnityPlayer.dll
, then takes its base address.
Line 22 loads global-metadata.dat
into an array of bytes. Line 24 is the key, and essentially the main result of our work so far: it creates a delegate which points to our DoSomethingWithMetadata
function at offset 0x42110
from the loaded image base address, using the correct parameter types.
Line 26 calls the function in UnityPlayer.dll
. The returned pointer is in unmanaged memory of course, so lines 28-29 copy it into a managed array. Line 31 releases the lock on the DLL, and line 33 writes the output of the function to a file.
We run the program and open up the output in a hex editor next to the original metadata:
Well, it’s done… something. The first 0x28
(or possibly 0x24
) bytes still don’t make any sense to us, but we can clearly see that some bytes have been changed into metadata header entries consistent with what appears immediately following.
The two blocks of unknown data are still as garbled as ever (we assume the one at the end of the file is a decryption key of some kind though), but what about those periodic encrypted 0x40
-byte blocks scattered throughout the file?