Watch out, it’s the fuzz!

We begin the proceedings by creating a small C# project which will load the image, call the function at the virtual address specified as a command-line argument and check the result.

Immediately we hit a stumbling block: certain kinds of unmanaged exceptions such as stack overflows and access violations (attempts to access memory outside the bounds of the process, or to write or execute read-only memory) are converted to corrupted state exceptions (CSEs) in .NET. By default, these are not handled and the application will crash.

.NET Framework 3.5 and onwards provide a mechanism to handle this scenario by adding the HandleProcessCorruptedStateExceptions and SecurityCritical attributes to a method which may generate CSEs. They can then be captured as a regular managed exception in a try...catch block. As of .NET Framework 4.0, the SecurityCritical attribute is no longer required.

CSE handling is not implemented in .NET Core (any version) or .NET 5, and the attribute type is defined but has no effect. Therefore we cannot use .NET Core for this task, so I have compiled the code below against .NET Framework 4.8.

We don’t strictly need to handle CSEs and can just let the process crash, but we may want to produce logging output to help in our analysis later. By handling them, we have the opportunity to write to a log file before terminating the process.

Here then is our first stab at the code:

using System;
using System.IO;
using System.Runtime.ExceptionServices;
using System.Runtime.InteropServices;
// ...
 
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
private extern static IntPtr LoadLibrary(string path);
 
[DllImport("kernel32.dll")]
private extern static bool FreeLibrary(IntPtr hModule);
// ...
 
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate IntPtr DecryptDelegate(byte[] encryptedData, int length);
// ...
 
[HandleProcessCorruptedStateExceptions]
public static void Main(string[] args) {
    var hModule = LoadLibrary("UnityPlayer.dll");
    var encryptedData = File.ReadAllBytes("global-metadata.dat");
    var length = encryptedData.Length;
 
    var offset = (int) (Convert.ToInt64(args[0], 16) - 0x180000000);
    var rva = hModule + offset;
     
    var Decrypt = (DecryptDelegate) Marshal.GetDelegateForFunctionPointer(rva, typeof(DecryptDelegate));
 
    Console.WriteLine($"Trying {offset:x8}");
 
    try {
        var decrypted = Decrypt(encryptedData, length);
 
        byte[] decryptedData = new byte[length];
        Marshal.Copy(decrypted, decryptedData, 0, length);
 
        Console.WriteLine("Saving...");
        File.WriteAllBytes($"possibly-decrypted-{offset:x8}.dat", decryptedData);
    }
    catch (Exception ex) {
        Console.WriteLine("Exception thrown - " + ex.GetType().Name + " - " + ex.Message);
    }
    finally {
        FreeLibrary(hModule);
    }
}

Lines 7-11 import LoadLibrary and FreeLibrary from the Win32 API to enable us to load an unmanaged DLL at runtime.

Lines 14-15 define the calling convention and signature of the function we wish to find.

Line 20 loads the target image (UnityPlayer.dll in this case) and lines 21-22 prepare the function arguments. In this case we load the encrypted data file and retrieve its length.

We will supply the virtual address of the function to call as the command-line argument. Line 24 interprets the hex string supplied on the command-line as an integer, and subtracts the image’s preferred base address (which you can find from the image file’s PE header), leaving us with the file offset of the address to call. Line 25 then adds this offset to the actual image base address after it is loaded in memory to determine the memory location to call into.

Line 27 creates a managed delegate that allows us to call the desired function at the memory address we just calculated, with the correct argument and return types.

Line 32 attempts to actually invoke the function in the image, and more often than not will throw an exception.

Lines 34-35 retrieve the result of the function call (a byte array in this case) and line 38 saves it to a file so that we can inspect it, suffixing the filename with the address offset used so that if we are successful we’ll actually know the correct function address.

Line 41 handles any thrown exceptions including CSEs and line 44 unloads the DLL from memory, in a finally block so that it executes whether or not an exception was thrown.

Once we’ve compiled the project, we can invoke it on the command line as follows:

./FunctionSearch.exe 1800123456
Output:

Trying 00123456

Nothing else happens in this case, indicating some other kind of unhandled unmanaged exception that just causes the process to terminate. This is fine and will be the case much of the time.