Disclaimer

Please note that all the code posted from here on is simply heavily beautified pseudo-code. Sorry in advance for the styling inconsistencies you may find in it; I would like to add it represents in no way the original source code of the program. I do not condone using any of the information gathered from this blog post to produce/write/develop cheats for any sort of game protected by EQU8 or its emulation.

This blog post is being written and published solely for educational purposes.

Introduction

This blog post will be a complete analysis of the EQU8 anti-cheat’s kernel driver. The kernel driver is comprised of only 24 functions, and its main goal seems to be to simply keep away people from making external cheats by accessing the game’s memory via traditional handle duplication / opening methods.

EQU8 seems to be on its way to become a fairly wide-spread anti-cheating software. In this post we will see how it works and what could be done to improve it.

What is EQU8’s anti-cheat system comprised of?

The anti-cheat is comprised of a few user-mode modules and service agents and the kernel driver, which this post will focus on. The kernel driver, as stated above, is responsible for the main protection from external cheats.

Kernel driver’s location

The kernel driver is located in (at least on my system) C:\Windows\System32\drivers\EQU8_HELPER_36.sys. It seems to be downloaded and installed the first time you run an EQU8 protected game.

Beginning the analysis

The kernel driver is not packed nor virtualized, as a simple look on the file size coupled with a look at its entropy will tell you immediately.

Here’s how the driver’s entropy looks (Graph generated from here):

Therefore, given the fact that it’s not packed nor virtualized, I loaded it up in IDA and started my static analysis of the driver. The pooltag for all of the driver’s allocations passed to ExAllocatePoolWithTag is 8UQE.

The DriverEntry

The execution begins here, in DriverEntry, the entrypoint of all kernel drivers.

Here’s how it looks:

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
  _security_init_cookie();
  return RealDriverEntry(DriverObject, RegistryPath);
}

It seems evident that this is just an entry placed here by the compiler to initialize the security cookie on the stack, so let’s see what’s in the RealDriverEntry function:

NTSTATUS RealDriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
  PDEVICE_OBJECT DeviceObject = nullptr;
  
  NTSTATUS StatusCodeBuffer = BuildDosAndDeviceNameFromSessionId(RegistryPath);
  
  if ( StatusCodeBuffer >= STATUS_SUCCESS )
  {
    StatusCodeBuffer = IoCreateDevice(DriverObject, 0, &DeviceName, 0x22u, 0, 1u, &DeviceObject);
    
    if ( StatusCodeBuffer >= STATUS_SUCCESS )
    {
      StatusCodeBuffer = IoCreateSymbolicLink(&SymbolicLinkName, &DeviceName);

      if ( StatusCodeBuffer >= STATUS_SUCCESS )
      {
        DriverObject->MajorFunction[IRP_MJ_DEVICE_IO_CONTROL] = MajorFunctionControl;
        DriverObject->MajorFunction[IRP_MJ_CREATE] = MajorFunctionCreate;
        DriverObject->MajorFunction[IRP_MJ_CLOSE] = MajorFunctionClose;
        DriverObject->DriverUnload = DriverUnload;
        StatusCodeBuffer = SetupHandleCallbacks();
        
        if ( StatusCodeBuffer >= STATUS_SUCCESS ) 
        {
          return StatusCodeBuffer;
        }

        IoDeleteSymbolicLink(&SymbolicLinkName);
      }
    }
  }

  FreeStringBuffers();

  if ( DeviceObject ) 
  {
    IoDeleteDevice(DeviceObject);
  }

  return StatusCodeBuffer;
}

The driver seems to simply allocate the DeviceName and SymbolicLinkName in the BuildDosAndDeviceNameFromSessionId function, and then proceeds to set up a few MajorFunction pointers to be able to communicate with user-mode.

As the name of the function suggests, BuildDosAndDeviceNameFromSessionId simply build a SymbolicLinkName and a DeviceName based on the session id of the computer running the software.

Here’s the pseudo-code for the BuildDosAndDeviceNameFromSessionId function:

NTSTATUS GetDosDeviceName(PUNICODE_STRING RegistryPath)
{
  _KEY_VALUE_FULL_INFORMATION *KeyValueInformationStructure; 
  NTSTATUS StatusCode; 
  NTSTATUS StatusCodeBuffer;  
  ULONG v4; 
  char *v5;  
  ULONG v6; 
  _WORD *v7;  
  unsigned int v8; 
  _UNICODE_STRING DestinationString;  
  struct _OBJECT_ATTRIBUTES ObjectAttributes;  
  ULONG ResultLength;  
  void *KeyHandle; 
 
  // This is bad, it should've been initialized with INVALID_HANDLE_VALUE, not 0. 
  KeyHandle = 0i64;
  KeyValueInformationStructure = 0i64;
  
  if ( !RegistryPath || !RegistryPath->Buffer || !RegistryPath->Length ) \
  {
    return STATUS_INVALID_PARAMETER;
  }

  ObjectAttributes.ObjectName = RegistryPath;
  ObjectAttributes.Length = 48;
  ObjectAttributes.RootDirectory = 0i64;
  ObjectAttributes.Attributes = 576;
  *&ObjectAttributes.SecurityDescriptor = 0i64;
  StatusCode = ZwOpenKey(&KeyHandle, 0xF003Fu, &ObjectAttributes);
  
  if ( StatusCode < 0 )
  {
      FreeStringBuffers();
      goto END;
  }
    
  RtlInitUnicodeString(&DestinationString, L"SessionId");
  StatusCodeBuffer = ZwQueryValueKey(KeyHandle, &DestinationString, KeyValueFullInformation, 0i64, 0, &ResultLength);
  StatusCode = StatusCodeBuffer;
  if ( StatusCodeBuffer == STATUS_BUFFER_TOO_SMALL || StatusCodeBuffer == STATUS_BUFFER_OVERFLOW)
  {
    KeyValueInformationStructure = ExAllocatePoolWithTag(PagedPool, ResultLength, 0x38555145u);
    if ( KeyValueInformationStructure )
    {
      StatusCode = ZwQueryValueKey(
                     KeyHandle,
                     &DestinationString,
                     KeyValueFullInformation,
                     KeyValueInformationStructure,
                     ResultLength,
                     &ResultLength);
      if ( StatusCode < 0 )
      {
        FreeStringBuffers();
        goto END;
      }
      if ( KeyValueInformationStructure->Type == 1 )
      {
        v4 = 0;
        v5 = KeyValueInformationStructure + KeyValueInformationStructure->DataOffset;
        v6 = KeyValueInformationStructure->DataLength >> 1;
        if ( v6 )
        {
          v7 = (KeyValueInformationStructure + KeyValueInformationStructure->DataOffset);
          do
          {
            if ( !*v7 )
              break;
            ++v4;
            ++v7;
          }
          while ( v4 < v6 );
        }
        v8 = 2 * v4;
        StatusCode = ConcatenateUnicodeStrings(
                       &DeviceName,
                       L"\\Device\\",
                       KeyValueInformationStructure + KeyValueInformationStructure->DataOffset,
                       2 * v4);
        if ( StatusCode < 0 ) 
        {
          FreeStringBuffers();
          goto END;
        }
          
        StatusCode = ConcatenateUnicodeStrings(&SymbolicLinkName, L"\\DosDevices\\", v5, v8);
      }
    }
  }

  if ( StatusCode < 0 )
  {
    FreeStringBuffers();
    goto END;
  }

END:

  if ( KeyValueInformationStructure ) 
  {
    ExFreePoolWithTag(KeyValueInformationStructure, 0x38555145u);
  }

  // I'm fairly certain this is not how you check whether a handle has been returned or not.
  // You should check if (KeyHandle != INVALID_HANDLE_VALUE).
  // Furthermore, KeyHandle should have been initialized with INVALID_HANDLE_VALUE.
  if ( KeyHandle ) 
  {
    ZwClose(KeyHandle);
  }

  return StatusCode;
}

The FreeStringBuffers function simply frees the DeviceName and SymbolicLinkName global variables' allocated buffers.

Here’s FreeStringBuffers' pseudo-code:

NTSTATUS FreeStringBuffers()
{
  NTSTATUS result;

  if ( DeviceName.Buffer )
  {
    ExFreePoolWithTag(DeviceName.Buffer, 0x38555145u);
    result = 0;
    *&DeviceName.Length = 0i64;
    DeviceName.Buffer = 0i64;
  }
  
  if ( SymbolicLinkName.Buffer )
  {
    ExFreePoolWithTag(SymbolicLinkName.Buffer, 0x38555145u);
    result = 0;
    *&SymbolicLinkName.Length = 0i64;
    SymbolicLinkName.Buffer = 0i64;
  }

  return result;
}

Another utility function used is ConcatenateUnicodeStrings, simply concatenates two unicode strings.

Here’s the pseudo-code for ConcatenateUnicodeStrings:

int64_t __fastcall ConcatenateUnicodeStrings(
        PUNICODE_STRING BaseString,
        wchar_t *AdditionString,
        const void *DataOffset,
        unsigned int Size)
{
  __int64 AdditionStringSize;
  size_t SizeBuffer;
  size_t RealAdditionStringByteSize;
  WCHAR *PoolWithQuotaTag;

  AdditionStringSize = -1i64;
  SizeBuffer = Size;
  do
    ++AdditionStringSize;
  while ( AdditionString[AdditionStringSize] );
  RealAdditionStringByteSize = (2 * AdditionStringSize);
  
  if ( RealAdditionStringByteSize + Size >= 0x10000 ) 
  {
    return STATUS_BUFFER_OVERFLOW;
  }
  
  PoolWithQuotaTag = ExAllocatePoolWithQuotaTag(PagedPool, RealAdditionStringByteSize + Size, 0x38555145u);
  BaseString->Buffer = PoolWithQuotaTag;
  
  if ( !PoolWithQuotaTag ) 
  {
    return STATUS_NO_MEMORY
  }

  BaseString->Length = RealAdditionStringByteSize + SizeBuffer;
  BaseString->MaximumLength = RealAdditionStringByteSize + SizeBuffer;
  memmove(PoolWithQuotaTag, AdditionString, RealAdditionStringByteSize);
  memmove(BaseString->Buffer + RealAdditionStringByteSize, DataOffset, SizeBuffer);

  return 0i64;
}

Nothing special, really. But now that we’ve seen how the anti-cheat sets itself up, we can start diving a bit more into its internals.

Major functions

The major functions the driver sets up are quite interesting. The IRP_MJ_CREATE / IRP_MJ_CLOSE ones handle creating and closing handles to the device, but still regulate a few important functionalities inside the driver that we’ll take a look at soon, while the IRP_MJ_DEVICE_CONTROL one handles IRPs sent to the driver relating to enabling / disabling functionalities and gathering information stored in the driver.

IRP_MJ_CREATE

Here’s the pseudo-code for the MajorFunctionCreate create function:

NTSTATUS MajorFunctionCreate(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
  NTSTATUS StatusCode = 0;

  // SeTcbPrivilege is usually possessed by processes with the power of doing things
  // such as creating login tokens, running programs under the SYSTEM "account", impersonating
  // other users freely, etc.
  if ( !SeSinglePrivilegeCheck(RtlConvertLongToLuid(SE_TCB_PRIVILEGE), 1) ) 
  {
    StatusCode = STATUS_PRIVILEGE_NOT_HELD;
  }
  
  if ( !Irp ) 
  {
    return STATUS_UNSUCCESSFUL;
  }

  if ( StatusCode >= STATUS_SUCCESS )
  {
    // Protects the current process the request comes from if the process holds enough
    // privilege to be able to open it.
    StatusCode = AddCurrentProcessToProtectedProcessesList();
    
    // Global variable
    GlobalEqu8Process = IoGetCurrentProcess();
    // Global variable
    GlobalEqu8ProcessId = PsGetProcessId(GlobalEqu8Process);
  }

  Irp->IoStatus.Information = 0i64;
  Irp->IoStatus.Status = StatusCode;
  IofCompleteRequest(Irp, 0);

  return 0;
}

As we can see, the function simply runs a few privilege checks on the calling process, then proceeds to add the process to an internal protected processes list inside the kernel driver if enough privileges are held. The mechanism with which the protection operates will be analyzed later in the post, now let’s take a look at the MajorFunctionClose function.

The privilege checks simply seem to check for the SE_TCB_PRIVILEGE, which is a privilege that allows a process capabilities like impersonating any user account, creating new login tokens, spawning processes running under the SYSTEM “account”, etc

IRP_MJ_CLOSE

Here’s the pseudo-code for the MajorFunctionClose function:

NTSTATUS MajorFunctionClose(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
  // Global variable indicating whether or not the process protections enforced via ObRegisterCallbacks callbacks have been set up.
  ObRegisterCallbacksEnabled = 0;
  // Global variable used in many functions as a buffer for the currently calling process.
  FreeLinkedList(&CallingEprocess);
  // Global variable holding the protected processes list.
  FreeLinkedList(&ProtectedProcessesList);

  // Global variable holding a pointer to the KPROCESS structure of the Equ8 process (a PEPROCESS).
  GlobalEqu8Process = 0i64;
  
  if ( !Irp ) 
  {
    return STATUS_UNSUCCESSFUL;
  }

  Irp->IoStatus.Status = 0;
  Irp->IoStatus.Information = 0i64;
  IofCompleteRequest(Irp, 0);
  return 0;
}

As we can see, the closing function simply uninitializes a few global variables and returns. Nothing special. It’s likely called after the usermode service disables all other protections via the IRP_MJ_DEVICE_CONTROL MajorFunction and prepares to unload the driver.

IRP_MJ_DEVICE_CONTROL

This is where some of the action happens, it handles:

  • Starting an ETW trace.
  • Protecting a specific process.
  • Sending the logged ObRegisterCallbacks data from the PreOperation function to usermode.

Here’s the pseudocode for the MajorFunctionControl function:

NTSTATUS MajorFunctionControl(PDEVICE_OBJECT DeviceObject, PIRP irp)
{
  _IO_STACK_LOCATION *CurrentStackLocation;
  uint32_t v3;
  _IRP *SystemBuffer;
  NTSTATUS StatusCode;
  ULONG Options;
  struct _KPROCESS *ProtectedProcessBuffer;
  NTSTATUS StatusCodeBuffer;
  uint32_t v10;

  CurrentStackLocation = irp->Tail.Overlay.CurrentStackLocation;
  v3 = 0;
  SystemBuffer = irp->AssociatedIrp.MasterIrp;
  StatusCode = 0xC0000001;
  Options = CurrentStackLocation->Parameters.Create.Options;
  
  // Enable tracing.
  if ( CurrentStackLocation->Parameters.Read.ByteOffset.LowPart == 0x22E040 )
  {
    if ( !SystemBuffer || Options != 32 )
      goto CompleteRequest;
    StatusCodeBuffer = EtwEnableTrace_call(
                           *&SystemBuffer->Type,
                           SystemBuffer->MdlAddress,
                           HIDWORD(SystemBuffer->MdlAddress),
                           &SystemBuffer->Flags);
    StatusCode = StatusCodeBuffer;
    goto CompleteRequest;
  }
  if ( CurrentStackLocation->Parameters.Read.ByteOffset.LowPart != 0x22E044 )
  {
    // Protects a process by PID.
    if ( CurrentStackLocation->Parameters.Read.ByteOffset.LowPart == 0x22E048 )
    {
      if ( !SystemBuffer || Options != 4 ) 
      {
        goto CompleteRequest;
      }

      if ( CallbacksRegistrationHandle )
      {
        ProtectedProcessBuffer = &ProtectedProcessesList;
        StatusCodeBuffer = ProtectProcessByPID(ProtectedProcessBuffer, *&SystemBuffer->Type);
        StatusCode = STATUS_ILLEGAL_FUNCTION;
        goto CompleteRequest;
      }
    }
    else
    {
      if ( CurrentStackLocation->Parameters.Read.ByteOffset.LowPart != 0x22E04C )
      {
        if ( CurrentStackLocation->Parameters.Read.ByteOffset.LowPart == 0x22E050 && !ObRegisterCallbacksEnabled )
        {
          ObRegisterCallbacksEnabled = 1;
          StatusCode = 0;
        }

        goto CompleteRequest;
      }
 
      if ( !SystemBuffer || Options != 4 ) 
      {
        goto CompleteRequest;
      }
 
      if ( CallbacksRegistrationHandle )
      {
        ProtectedProcessBuffer = &CallingEprocess;
        StatusCodeBuffer = AddProcessToProtectedProcessesList(ProtectedProcessBuffer, *&SystemBuffer->Type);
        StatusCode = StatusCodeBuffer;
        goto CompleteRequest;
      }
 
    }
    StatusCode = STATUS_ILLEGAL_FUNCTION;
    goto CompleteRequest;
  }
  
  // Send the ob register callbacks data logged to usermode.
  if ( SystemBuffer && CurrentStackLocation->Parameters.Read.Length == 420 )
  {
    ExAcquireFastMutex(&FastMutex);
    v10 = 13 * dword_1400030F0;
    *&SystemBuffer->Type = dword_1400030F0;
    memmove(&SystemBuffer->Size + 1, &unk_140003100, v10);
    dword_1400030F0 = 0;
    v3 = v10 + 4;
    ExReleaseFastMutex(&FastMutex);
    StatusCode = STATUS_SUCCESS;
  }

CompleteRequest:
  irp->IoStatus.Information = v3;
  irp->IoStatus.Status = StatusCode;
  IofCompleteRequest(irp, 0);
  return StatusCode;
}

There’s quite a lot going on here, we can almost immediately identify a few important IOCTL codes:

  • 0x22E050: Enables the ObRegisterCallbacks process protection if it’s not enabled. (Really only flips a variable, the real initialization is done in the RealDriverEntry function in SetupHandleCallbacks which we’ll take a look at later.)
  • 0x22E048: Protects a process.
  • 0x22E040: Enables ETW tracing (Could either be used for debug purposes or cheat detection).

Every single IOCTL request seems to unilaterally send the data collected by handle callbacks if the conditions are right. (If the usermode buffer is big enough.). Due to the fact that I do not intend enabling or helping anyone in faking information send to the usermode module or fully emulating the kernel driver, I will not release the beautified pseudo-code relating to sending the information to usermode. The information collected by callbacks is stored in a structure with only 4 members, with a maximum size of 32 to store the last 32 events.

However, I do feel that it is within the scope of the analysis to provide the layout in which the protected processes are laid out in memory:

#pragma pack(1)
struct ProcessEntry
{
    LIST_ENTRY ListEntry;
    PEPROCESS Process;
    int32_t ProcessId;
};

using LPProcessEntry = ProcessEntry*;

Setting up handle callbacks

Now that we’ve taken a look at how the kernel driver communicates to usermode and the information it provides, we can start taking a look at how the handle operation callbacks are set up. The handle callbacks are functions called straight after a process tries to interact with a process inside the system. It is up to the callback to filter by process. The callback can be registered to only be called by the system on certain operations.

Here’s the pseudocode for the SetupHandleCallbacks function called in DriverEntry immediately after setting up the MajorFunction pointers:

int64_t SetupHandleCallbacks()
{
  int64_t ObUnRegisterCallbacks_buffer;
  int64_t result;
  _OB_OPERATION_REGISTRATION OperationRegistration;
  POBJECT_TYPE *OperationType;
  int64_t Unused2;
  int64_t (__fastcall *PreHandleOperationPointerBuffer)(int64_t, POB_PRE_OPERATION_INFORMATION);
  int64_t Unused;
  _OB_CALLBACK_REGISTRATION CallbackRegistration;
  _UNICODE_STRING DestinationString; 
  _UNICODE_STRING SystemRoutineName;
  _UNICODE_STRING v10;
  _UNICODE_STRING string_363705;

  SpinLock = 0i64;
  FastMutex.Owner = 0i64;
  FastMutex.Contention = 0;
  ProtectedProcessesList.ListEntry.Blink = &ProtectedProcessesList;
  ProtectedProcessesList.ListEntry.Flink = &ProtectedProcessesList;
  ProtectedProcessesList.ProcessId = &ProtectedProcessesList.Process;
  ProtectedProcessesList.Process = &ProtectedProcessesList.Process;
  FastMutex.Count = 1;
  KeInitializeEvent(&FastMutex.Event, SynchronizationEvent, 0);
  
  // Decrypts a seemingly normal string into another string.
  if ( !DecryptFunctionName(aResolvingSyms, &XorDecryptionKey, 0xFui64) ) 
  {
    return STATUS_INFO_LENGTH_MISMATCH;
  }

  RtlInitUnicodeString(&DestinationString, EtwEnableTraceEncryptedString);
  EtwEnableTrace_ptr = MmGetSystemRoutineAddress(&DestinationString);
  RtlInitUnicodeString(&SystemRoutineName, L"ObRegisterCallbacks");
  RtlInitUnicodeString(&ObUnRegisterCallbacksString, L"ObUnRegisterCallbacks");
  ObRegisterCallbacks_ptr = MmGetSystemRoutineAddress(&SystemRoutineName);
  ObUnRegisterCallbacks_ptr = MmGetSystemRoutineAddress(&ObUnRegisterCallbacksString);
  ObUnRegisterCallbacks_buffer = ObUnRegisterCallbacks_ptr;

  // Re-encrypts the decrypted string into another seemingly normal string.
  if ( !DecryptFunctionName(aResolvingSyms, &XorDecryptionKey, 0xFui64) ) 
  {
    return STATUS_INFO_LENGTH_MISMATCH;
  }

  if ( !ObRegisterCallbacks_ptr ) 
  {
    // If the ObUnRegisterCallbacks pointer is null it sets up the CreateProcessNotifyRoutine anyway? Weird.
    CreateProcessNotifyRoutineResult = PsSetCreateProcessNotifyRoutine(NotifyRoutine, 0) >= 0;
    return STATUS_SUCCESS;
  }
  
    // If the ObUnRegisterCallbacks pointer is null it sets up the CreateProcessNotifyRoutine anyway? Weird.
  if ( !ObUnRegisterCallbacks_buffer ) 
  {
    CreateProcessNotifyRoutineResult = PsSetCreateProcessNotifyRoutine(NotifyRoutine, 0) >= 0;
    return STATUS_SUCCESS;
  }

  // Zero out the structures
  memset(&CallbackRegistration, 0, sizeof(CallbackRegistration));
  memset(&OperationRegistration, 0, sizeof(OperationRegistration));
  
  OperationType = 0i64;
  Unused2 = 0i64;
  PreHandleOperationPointerBuffer = 0i64;
  Unused = 0i64;
  // Filter altitude.
  RtlInitUnicodeString(&string_363705, L"363705");
  OperationRegistration.PostOperation = 0i64;
  Unused = 0i64;
  CallbackRegistration.RegistrationContext = 0i64;
  OperationRegistration.ObjectType = PsProcessType;
  OperationType = PsThreadType;
  CallbackRegistration.OperationRegistration = &OperationRegistration;
  // The driver only checks for the creation and duplication of handles.
  OperationRegistration.Operations = 3;
  // This is the callback function that's called by the system.
  OperationRegistration.PreOperation = PreHandleOperation;
  LODWORD(Unused2) = 3;
  // This is the callback function that's called by the system.
  PreHandleOperationPointerBuffer = PreHandleOperation;
  *&CallbackRegistration.Version = 0x20100;
  CallbackRegistration.Altitude = string_363705;
  // The function pointer is then properly called with the filled out information.
  result = ObRegisterCallbacks_ptr(&CallbackRegistration, &CallbacksRegistrationHandle);
  
  if ( result >= STATUS_SUCCESS )
  {
    CreateProcessNotifyRoutineResult = PsSetCreateProcessNotifyRoutine(NotifyRoutine, 0) >= STATUS_SUCCESS;
    return 0i64;
  }

  return result;
}

This function not only setus up the callbacks, but also takes care of grabbing some pointers that are needed throughout the program, such as the EtwTraceCall pointer. The EtwTraceCall string is computed after decrypting a seemingly harmless string with the value “Resolving Syms.”. Once callbacks are set for thread and process operations, the function proceeds to set a process creation notification routine.

Function name encryption

THe kernel driver seems to contain some strings which are encrypted with a different rotation key to yield different strings, to hide the fact that the driver is, in fact, calling EtwTraceCall. It’s supposed to make the reverse engineer think the Resolving Syms. string is nothing but a debugging leftover while, in reality, it’s turned first to RtlFormatMessage and then to EtwTraceCall.

Here’s the relevant pseudo-code where the encryption/decryption routine is called:

  // The XorDecryptionKey global variable equals 0x17.
  // The aResolvingSyms string's precise value is "Resolving Syms." (in utf-16)
  // Decrypts a seemingly normal string into another string.
  if ( !DecryptFunctionName(aResolvingSyms, &XorDecryptionKey, 0xFui64) ) 
  {
    return STATUS_INFO_LENGTH_MISMATCH;
  }

  RtlInitUnicodeString(&DestinationString, EtwEnableTraceEncryptedString);
  EtwEnableTrace_ptr = MmGetSystemRoutineAddress(&DestinationString);
  RtlInitUnicodeString(&SystemRoutineName, L"ObRegisterCallbacks");
  RtlInitUnicodeString(&ObUnRegisterCallbacksString, L"ObUnRegisterCallbacks");
  ObRegisterCallbacks_ptr = MmGetSystemRoutineAddress(&SystemRoutineName);
  ObUnRegisterCallbacks_ptr = MmGetSystemRoutineAddress(&ObUnRegisterCallbacksString);
  ObUnRegisterCallbacks_buffer = ObUnRegisterCallbacks_ptr;

  // Re-encrypts the decrypted string into another seemingly normal string.
  if ( !DecryptFunctionName(aResolvingSyms, &XorDecryptionKey, 0xFui64) ) 
  {
    return STATUS_INFO_LENGTH_MISMATCH;
  }

Here’s the pseudo-code for the encryption/decryption routine, it’s just a simple XOR method:

bool DecryptFunctionName(wchar_t *message, int64_t key, uint32_t64_t size)
{
  int64_t v6;
  wchar_t *v7;
  wchar_t *v8;
  int64_t v9;
  signed int v10;
  int64_t v11;

  if ( !message || !key ) 
  {
    return 0;
  }

  v6 = 0i64;
  v7 = message + 16;
  if ( v7 )
  {
    v8 = v7;
    v9 = 128i64;
    do
    {
      if ( !*v8 )
        break;
      ++v8;
      --v9;
    }
    while ( v9 );
    v10 = v9 == 0 ? 0xC000000D : 0;
    v6 = v9 ? 128 - v9 : 0i64;
  }
  else
  {
    v10 = 0xC000000D;
  }
  if ( v10 < 0 || size > v6 + 8 )
    return 0;
  if ( size )
  {
    v11 = key - v7;
    do
    {
      *v7 ^= *(v7 + v11);
      ++v7;
      --size;
    }
    while ( size );
  }
  return 1;
}```

## Process creation routine
The notification routine for process creation simply checks whether the process was created by EQU8's launcher, and then proceeds to protect it if so, adding it to the linked list of protected processes. Otherwise, the process seems to get removed from the linked list.

Here's the pseudo-code for the NotifyRoutine:
```cpp
void NotifyRoutine(HANDLE ParentId, HANDLE ProcessId, BOOLEAN Create)
{
  int ProcessIdBuffer;
  KIRQL Irql;
  _LIST_ENTRY *ListHead;
  KIRQL IrqlBuffer;
  ProcessEntry *v7;
  _LIST_ENTRY *Blink;

  ProcessIdBuffer = ProcessId;
  // Check if it's a process creation
  if ( Create )
  {
    // Check if the parent process is Equ8's process
    if ( GlobalEqu8ProcessId && GlobalEqu8ProcessId == ParentId ) 
    {
      // Protect the process then
      ProtectProcessByPID(&ProtectedProcessesList, ProcessId);
    }
  }
  // If it's not a process creation
  else
  {
    Irql = KeAcquireSpinLockRaiseToDpc(&SpinLock);
    ListHead = ProtectedProcessesList.ListEntry.Flink;
    IrqlBuffer = Irql;
    if ( ProtectedProcessesList.ListEntry.Flink == &ProtectedProcessesList )
    {
LABEL_5:
      KeReleaseSpinLock(&SpinLock, Irql);
    }
    else
    {
      // Begin iteration of the linked list
      while ( 1 )
      {
        v7 = ListHead->Flink;
        if ( ProcessIdBuffer == LODWORD(ListHead[1].Blink) )
          break;
        ListHead = ListHead->Flink;
        if ( v7 == &ProtectedProcessesList )
          goto LABEL_5;
      }
      if ( v7->ListEntry.Blink != ListHead || (Blink = ListHead->Blink, Blink->Flink != ListHead) )
        __fastfail(3u);
      // Remove the entry
      Blink->Flink = &v7->ListEntry;
      v7->ListEntry.Blink = Blink;
      KeReleaseSpinLock(&SpinLock, IrqlBuffer);
      ObfDereferenceObject(ListHead[1].Flink);
      ExFreePoolWithTag(ListHead, 0x38555145u);
    }
  }
}

Pre-operation callback functions

Finally, the PreHandleOperation function is the function where all the magic happens. It takes care of storing the information from applications that have tried opening a handle to the game and strips handle accesses. It is the main line of the defense for EQU8 protected applications from external memory reading/writing cheats.

Here’s the pseudo-code for the PreHandleOperation function:

int64_t PreHandleOperation(int64_t RegistrationContext, POB_PRE_OPERATION_INFORMATION OperationInformation)
{
  uint32_t v2;
  PEPROCESS CurrentProcess;
  POBJECT_TYPE *ObjectType;
  struct _KPROCESS *Object;
  char ObjectTypeData;
  int AccessMaskToClear;
  POB_PRE_OPERATION_PARAMETERS Parameters;
  ACCESS_MASK OriginalDesiredAccess;
  uint32_t ProcessId;
  uint32_t CurrentProcessId;
  char v13;
  _DWORD *v14;
  char *v15;

  v2 = 0;
  if ( ObRegisterCallbacksEnabled )
  {
    if ( OperationInformation )
    {
      if ( OperationInformation->Object )
      {
        if ( OperationInformation->Parameters )
        {
          CurrentProcess = IoGetCurrentProcess();
          if ( !IsProcessProtected(&ProtectedProcessesList.Process, CurrentProcess) )
          {
            ObjectType = OperationInformation->ObjectType;
            if ( ObjectType == PsProcessType )
            {
              Object = OperationInformation->Object;
              // Object type: Process
              ObjectTypeData = 2;
              // Flags: SYNCHRONIZE | UNKNOWN
              AccessMaskToClear = 0x103601;
            }
            else
            {
              if ( ObjectType != PsThreadType ) 
              {
                return 0i64;
              }
              // Object type: Thread
              ObjectTypeData = 4;
              // Flags: SYNCHRONIZE | UNKNOWN
              AccessMaskToClear = 0x100C00;
              Object = PsGetThreadProcess(OperationInformation->Object);
            } 
            if ( OperationInformation->Operation == OB_OPERATION_HANDLE_CREATE  || OperationInformation->Operation == OB_OPERATION_HANDLE_DUPLICATE )
            {
              // For the handle to be stripped a few conditions need to be satisfied:
              // the target process needs to be a protected process
              // the calling process mustn't be the Equ8 process
              // the calling process msutn't equal IoGetCurrentProcess()
              // the desired access must equal PROCESS_VM_OPERATION
              Parameters = OperationInformation->Parameters;
              OriginalDesiredAccess = Parameters->CreateHandleInformation.OriginalDesiredAccess;
              if ( (GlobalEqu8Process != IoGetCurrentProcess() || Parameters->CreateHandleInformation.DesiredAccess == PROCESS_VM_OPERATION)
                && Object != IoGetCurrentProcess() )
              {
                if ( IsProcessProtected(&ProtectedProcessesList, Object) )
                {
                  // Strip handle accesses
                  Parameters->CreateHandleInformation.DesiredAccess &= AccessMaskToClear;
                  
                  // If the original access is different from the stripped one
                  if ( OriginalDesiredAccess != Parameters->CreateHandleInformation.DesiredAccess )
                  {
                    // Prepare filling out the (unreleased) information structure you could reverse from this pseudo-code here.
                    ProcessId = PsGetProcessId(Object);
                    CurrentProcessId = PsGetCurrentProcessId();
                    v13 = ObjectTypeData | OperationInformation->Flags & 1;

                    // Acquire the mutex to start writing to the information array.
                    ExAcquireFastMutex(&FastMutex);

                    // Checks if the index is less than 32 (Maximum size of the information array)
                    if ( NextStoredInformationIndex < 0x20 )
                    {
                      // If it's more than zero, and the information is not a duplicate, store it.
                      if ( NextStoredInformationIndex )
                      {
                        v14 = &unk_140003108;
                        while ( *(v14 - 1) != CurrentProcessId || *v14 != ProcessId || *(v14 + 4) != v13 )
                        {
                          ++v2;
                          v14 = (v14 + 13);
                          if ( v2 >= NextStoredInformationIndex )
                            goto LABEL_24;
                        }
                      }
                      else
                      {
LABEL_24:
                        // Store the information.
                        v15 = &unk_140003100 + 13 * NextStoredInformationIndex++;
                        *(v15 + 1) = CurrentProcessId;
                        *(v15 + 2) = ProcessId;
                        *v15 = OriginalDesiredAccess;
                        v15[12] = v13;
                      }
                    }

                    // Release the mutex to access the data.
                    ExReleaseFastMutex(&FastMutex);
                  }
                }
              }
            }
          }
        }
      }
    }
  }
  return 0i64;
}

The pseudo-code itself contains many comments to explain what’s going on, but for those of you who are not really into C++, here’s how the handle stripping routine works…

The routine is invoked by the OS when handle creation or duplication events happen, the routine then:

  • Checks that all pointers that it’s going to access are valid.
  • Checks if the current process is a protected process.
  • If it is, it stores some basic information from the handle opening operation, such as whether the operation is happening on a thread or a process.
  • If the operation is not happening on a thread nor a process, the callback routine simply returns.
  • Otherwise, the core of the handle stripping routine is executed: the handle stripping logic.

A handle will be stripped of its access if:

  • The target process is a protected process.
  • The calling process isn’t the Equ8 process.
  • The calling process isn’t equal to IoGetCurrentProcess().
  • The desired access equals PROCESS_VM_OPERATION.

Driver unloading

Finally, here’s how the driver unloads itself and all the procedures it calls to completely turn off its functionalities before being unloaded by the system. Everything is handled by the DriverUnload function pointer stored in the driver’s DriverObject.

Here’s the pseudo-code for it:

void DriverUnload(PDRIVER_OBJECT DriverObject)
{
  _DEVICE_OBJECT *DeviceObject;
  
  // If the notify routine is registered.
  if ( CreateProcessNotifyRoutineResult )
  {
    // Unregister it.
    PsSetCreateProcessNotifyRoutine(NotifyRoutine, 1u);
    CreateProcessNotifyRoutineResult = 0;
  }
  
  // Disable the ObRegisterCallbacks-dependent features of the driver.
  ObRegisterCallbacksEnabled = 0;

  // Free the protected processes list.
  FreeLinkedList(&ProtectedProcessesList.Process);
  FreeLinkedList(&ProtectedProcessesList);

  // Remove the handle stripping callbacks.
  if ( CallbacksRegistrationHandle ) 
  {
    ObUnRegisterCallbacks_ptr();
  }
  
  // Delete the symbolic link.
  if ( SymbolicLinkName.Buffer ) 
  {
    IoDeleteSymbolicLink(&SymbolicLinkName);
  }
    
  // Free SymbolicLinkName's and DeviceName's buffers.
  FreeStringBuffers();
  
  // Delete the device object.
  if ( DriverObject )
  {
    DeviceObject = DriverObject->DeviceObject;
    if ( DeviceObject ) 
    {
      IoDeleteDevice(DeviceObject);
    }
  }

  // The driver gets unloaded by the system after this function returns.
}

Again nothing special, simply frees its buffers, removes its callbacks, deletes its devices, symbolic links and then unloads itself.

Conclusions

I found the kernel driver of EQU8 to be lacking any form of protection from a kernel based attacker. It doesn’t contain any integrity checks nor any sort of detection against kernel based attackers.

Here’s a few things that could be done to improve upon its operation:

As for the driver itself:

  • Adding .text section integrity checks, not just one of them, but several of them using different methods and modes of operation. Mapping the driver in usermode and having the usermode module hash the .text section and keep checking it against the initially computed one would be a start, alongside simple CRC checks calculating a CRC of the driver’s different functions running inside the driver itself.
  • Making it harder for someone to tamper with the global variables, implementing some sort of encryption, shadow copies and usermode validation of the variables.
  • Checking if someone completely replaces the MajorFunctions the driver is supposed to communicate with usermode with with their own.

As for detecting kernel anomalies:

  • Making it harder for someone to completely emulate the kernel driver and its functionalities.
  • Protecting the binary using even a basic software protector would certainly help, code virtualization is strongly advised to hinder reverse engineering efforts.
  • Adding some sort of detection for vulnerable drivers' traces in the kernel, querying MmUnloadedDrivers and the PiDDBCacheTable in ntoskrnl.exe would be a start, only to then move on to the others undocumented lists inside CI.dll, such as the g_CiEaCacheLookasideList. There’s probably more lists and more undocumented ways to find out which drivers have been unloaded in the system, such as the event logs, but it’s not my job to list all of them here right now.
  • Adding heuristics to catch system threads running outside the regular drivers' address space, simply iterating the loaded modules' list and checking whether or not every single threads' start address resides in valid memory would be a start towards detecting drivers mapped with vulnerable drivers.

EQU8 is a relatively new anti-cheat, and having not really seen such a wide-spread adoption unlike BattlEye and EasyAntiCheat have seen in the last few years, the anti-cheat itself hasn’t really been subject to as much attacking and fiddling, so, partly, all this was to be expected. The protection it provides will surely get better as time goes on.