Reversing mcsrfairplay's Native Module

Reversing fairplay_lib_x64.dll, the native behind mcsrfairplay, the Minecraft speedrun integrity mod.

mcsrfairplay is the mod used for Minecraft Java Edition speedruns for ensuring integrity. It ships a native DLL (fairplay_lib_x64.dll) that does a bit of the monitoring work, and I was curious how deep it went so I loaded it up in Ghidra.

The Java side uses XOR encrypted strings and I/l lookalike class names like IIIIIlI, lIlIlII. Not that interesting, you just find every invokestatic to the decryptor and replay the XOR to get the real strings. The native component is where it gets fun :3

The Obfuscation

The first thing you notice is every function has the same weird dead code pattern throughout it:

bVar8 = ((DAT_180061758 + -1) * DAT_180061758 & 1U) == 0;
if ((DAT_18006175c >= 10 || !bVar8) && (DAT_18006175c < 10 == bVar8))
    goto LAB_180005915;  // never taken
while (true) {
    BCryptOpenAlgorithmProvider(
        (BCRYPT_ALG_HANDLE *)(lVar1 + 0x10), L"SHA512", (LPCWSTR)0x0, 0);
    bVar8 = ((DAT_180061760 + 1) * DAT_180061760 & 1U) == 0;
    if ((DAT_180061764 < 10 && bVar8) || (DAT_180061764 < 10 != bVar8)) break;  // always breaks
}

(n-1)*n and (n+1)*n are always even so & 1 is always 0. The branch conditions are constant at runtime, the whole goto / while (true) structure is just there to duplicate blocks and confuse the decompiler. Once you know what to look for you can skip past it every time and get to the real code.

What It Does

There are five exports: JNI_OnLoad, NativeCallback_a, NativeCallback_b, NativeCallback_c, and entry. JNI_OnLoad sets up a critical section to guard a global module hash map, caches a Java callback method (j on class exersolver/mcsrfairplay/natives/NativeCallback, one character presumably to be less obvious), and installs MinHook on LoadLibraryW and LoadLibraryA to catch any DLLs loaded after startup.

NativeCallback_a then spawns a thread that does a one-shot snapshot of every module already in the process via Toolhelp32:

snap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, GetCurrentProcessId());
Module32FirstW(snap, &me32);
do {
    result = FUN_180010470(me32.szExePath);
    // acquire mutex, insert into hash map, release
    // call NativeCallback.j() with result
} while (Module32NextW(snap, &me32));
CloseHandle(snap);

FUN_180010470 is the interesting part. It builds a 6-field record for each module covering the path, SHA-512 hash, PE metadata, creation timestamp, last-write timestamp, and Authenticode status. The SHA-512 is computed via BCrypt:

// this is the part that matters, everything else is obfuscation scaffolding
BCryptOpenAlgorithmProvider(hAlg, L"SHA512", NULL, 0);
BCryptCreateHash(hAlg, &hHash, pbHashObject, cbHashObject, NULL, 0, 0);
BCryptHashData(hHash, pbData, cbData, 0);  // hash the file on disk
BCryptFinishHash(hHash, pbOutput, cbOutput, 0);

The reason this is significant is that it is hashing the actual file on disk. If you swap out or patch a DLL on disk, the hash won't match what was recorded at startup and the run gets flagged.

Timestamps come from a helper that takes a flag to switch between creation time and last-write time:

hFile = CreateFileW(param_2, 0x80000000, 1, NULL, 3, 0, NULL);

p_Var9 = (LPFILETIME)(*plVar5 + 0x110);  // lpCreationTime
p_Var6 = (LPFILETIME)0x0;
if (param_3 == '\0') {
    p_Var9 = p_Var6;
    p_Var6 = &local_3c8;                 // lpLastWriteTime instead
}
GetFileTime(hFile, p_Var9, NULL, p_Var6);  // the actual read we care about

// formatting, not that interesting
FileTimeToLocalFileTime((FILETIME *)(lVar2 + 0x110), (LPFILETIME)(*plVar5 + 0x10));
FileTimeToSystemTime((FILETIME *)(lVar2 + 0x10), (LPSYSTEMTIME)(*plVar5 + 0x10));

uVar1 = *(uint *)(*plVar5 + 0x88);  // CERT_TRUST_STATUS.dwErrorStatus
switch (((uVar1 ^ 0xffffffdd) & uVar1) == 2) { }  // cert trust check

That cert check at the end is interesting, ((uVar1 ^ 0xffffffdd) & uVar1) == 2 is testing specific bits in dwErrorStatus. 0xffffffdd flips bits 1 and 5, so it is essentially checking for CERT_TRUST_NO_SIGNATURE. The Authenticode strings ([Signed], [Unsigned], [Hash Failed: ...]) come from WinVerifyTrust which is not in the static import table and is resolved dynamically via GetProcAddress, which is why Ghidra shows no xrefs to it.

NativeCallback_b and _c are a nice little trick. _b copies a Java byte array into a native global buffer:

iVar2 = (**(code **)(*param_1 + 0x558))(param_1, param_3);      // GetArrayLength
puVar3 = (**(code **)(*param_1 + 0x5c0))(param_1, param_3, 0); // GetByteArrayElements

if ((ulonglong)(DAT_1800615c0 - DAT_1800615b0) < uVar4) {
    FUN_18001e740(&DAT_1800615b0, uVar4);  // resize if needed
}
FUN_180025080(DAT_1800615b0, puVar3, uVar4);  // memcpy into native buffer
DAT_1800615b8 = (longlong)puVar1 + uVar4;
(**(code **)(*param_1 + 0x600))(param_1, param_3, puVar3, 2);  // ReleaseByteArrayElements

_c then takes another byte array and compares it against what is stored there. The Java side generates a nonce with SecureRandom, stores it in native via _b, then a daemon thread named FairPlay-Native periodically calls _c with the same bytes. Mismatch logs heartbeatError. If you patch the native buffer in memory the nonce no longer matches and it fires.

The Java callback NativeCallback.j() gets the 6-field record and checks if the module is already in a HashSet of seen paths, then checks the filename against a whitelist of prefixes: jna, native_fairplay_lib, liblogger, libjcocoa, JNativeHook. If it is new and not whitelisted it gets logged and reported back to the mcsrfairplay backend:

moduleLoaded <base64(path)> <filename> <base64(authenticode_status)> <sha512_hex>

Path and signature are base64 encoded since they contain spaces. The run can then be flagged as potentially compromised on the backend side. Every DLL that touches the process gets a full fingerprint, the LoadLibrary hooks catch injections in real time, the startup snapshot catches anything preloaded, and the heartbeat makes in-memory patching detectable. It's okay for a basic anticheat ..

Thanks for reading :3