!! —-> Source Link <—- !!

Table Of Contents


Credits


  • _xeroxz & irql - Aided in collaborative EasyAntiCheat.sys research back in January which led to the idea behind this project.

Disclaimer


This is not an attack on EasyAntiCheat. EasyAntiCheat has done an outstanding job protecting games and will continue to do so for years to come. I gathered this content through private research of EasyAntiCheat’s modules and is not in any way tied to the work of public game hack publishers or other entities. I have no interest in writing cheats, and everything here is simply for educational purposes. Please do not contact me for help with any cheating-related concerns as I will not be responding to any of such requests.

It is also important to note that throughout this article, assumptions have been made regarding the internals of EasyAntiCheat. I have not reverse-engineered the anti-cheat from top to bottom, so I cannot confidently assert whether this will allow you to create undetected cheats. It’s best to assume that EasyAntiCheat already has implemented detection mechanisms. There are projects with similar attack vectors, such as modmap that accomplished a similar goal.

Introduction


EasyAntiCheat, a commercial anti-cheat solution owned by Epic Games is what currently claims (and is well known for) to be the “industry-leading” solution for game hacking prevention. For game developers, this allows for a smooth implementation of an anti-cheat into their games, preventing many forms of game manipulation. From an AHK script to a cheat hidden within the game, EasyAntiCheat has stood its ground in the anti-cheating industry, paving the way toward a more honest gaming experience.

For an attacker, a vital piece of the puzzle is to understand how the anti-cheat operates. Therefore, gaining knowledge of what happens inside the anti-cheat makes it possible to hide your tracks (or place hooks and attack). Let us look at how EasyAntiCheat makes the bridge between the kernel and the game with its set of modules. This will reveal how an overlooked design flaw in the driver can allow an attacker to execute unsigned code in any EasyAntiCheat protected game (or perhaps a game protected by other competitor services) with no restriction.

This effectively tricks the anti-cheat into protecting your memory as its own and grants it all kinds of abilities, like the creation of threads, deliberately placed hooks, so on. Having said that, EasyAntiCheat’s design comprises a series of executable files, we will examine only three primary modules in this exploit.

Before we begin, the image below shows some modules responsible for the initialization of EasyAntiCheat in a standard procedure, with a brief description of how it operates.

Note: These are not the only modules EasyAntiCheat uses, however these are the only ones necessary to understand what's to come.

x86 module


As explained in the image above, the anti-cheat injects a module labeled as EasyAntiCheat.dll. This module serves as one of the service’s primary modules in sending data to the servers for the analysis behind the scenes. Not to forget its own set of heuristic data collection routines. But how does this DLL get injected? Consider this set of functions inside the x86 module:

using LauncherCallback = VOID( __stdcall* )( INT, ULONG*, UINT );


enum EasyAntiCheatStatus 
{
        Successful = 0,
        FailedDriverHandle = 1,
        IncompatibleEasyAntiCheatVersion = 2,
        LauncherAlreadyOpen = 3,
        DebuggerDetected = 4,
        WindowsSafeMode = 5,
        WindowsSignatureEnforcement = 6,
        InsufficientMemory = 7,
        DisallowedTool = 8,
        PatchGuardDisabled = 11,
        KernelDebugging = 12,
        UnexpectedError = 13,
        PatchedBootloader = 15,
        GameRunning = 16,
};


const EasyAntiCheatStatus SetupEasyAntiCheatModule( PVOID InternalModule, SIZE_T InternalModuleSize )
{
     // The current value is 0x3C but is subject to change....
        if ( GetDriverVersion( this->DriverHandle ) != CurrentVersion )
                return EasyAntiCheatStatus::FailedDriverHandle;


    // sizeof( MapModuleStructure ) == 0x140
        SIZE_T BufferSize = InternalModuleSize + sizeof( MapModuleStructure );
        MODULE_MAP_STRUCTURE* Buffer = static_cast< MODULE_MAP_STRUCTURE* >( new UINT8[ BufferSize ] );


        // Copy the image into the heap allocation....
        // Currently Heap+0x140
        memcpy( Buffer->Image, InternalModule, InternalModuleSize );


        // Game initialization data such as the name are then copied over...
        // Do note that although this buffer is encrypted with XTEA, the module is also encrypted with its own algo...
        // The following DeviceIoControl tells the driver where to map the DLL (the game).


        XTEA_ENCRYPT( Buffer, InternalModuleSize + sizeof( MapModuleStructure ), -1 );


        SIZE_T ReturnedSize = 0;
        const BOOL Result = DeviceIoControl( this->DriverHandle, MAP_INTERNAL_MODULE, Buffer, BufferSize, &Buffer, BufferSize, &ReturnedSize, nullptr );
        if ( Result && ReturnedSize == BufferSize )
        {
                // Some processing comes here....
                return EasyAntiCheatStatus::Successful;
        }


        // Other data processing occurs and error handling....


        return EasyAntiCheatStatus::UnexpectedError;
}


// The exported name of this function is called "a" inside the x86 package but I have chosen a more fit name for reference.
__declspec( dllexport ) UINT InitEasyAntiCheat( LauncherCallback CallOnStatus , PVOID SharedMemoryBuffer, UINT Num )
{
        // 
        // Sends EasyAntiCheat.sys through an open shared memory buffer "Global\EasyAntiCheatBin"
        // This code is chopped off due to its irrelevance
        // ...
        //


        const EasyAntiCheatStatus Status = SetupEasyAntiCheatModule( InternalModule, sizeof InternalModule /* Some arguments are redacted as they are irrelevant */ );
        switch ( Status )
        {
                case EasyAntiCheatStatus::Successful:
                {
                        SetEventStatus("Easy Anti-Cheat successfully loaded in-game");
                        LoadEvent("launcher_error.success_loaded");
                        break;
                }


                // Handles error codes and generates an error log...
        }


        // ...
}

As we can see from the following set of code, EasyAntiCheat sends EasyAntiCheat.dll through an XTEA encrypted buffer to the driver along with other necessary information such as the GameID, Process name, etc.

Doesn’t this look abusable to you? Because it sure does to me. At first glance, you’ll notice that they also encrypted the module with its own algo as the first few bytes are A7 ED 96 0C 0F.... instead of the expected Windows PE header format. Considering the driver module also seems to follow the same format, reversing EasyAntiCheat.exe will allow us to locate the decryption. It is currently as follows:

Image Encryption


VOID DecryptModule( PVOID ModuleBase, ULONG ModuleSize )
{
        if ( !ModuleSize ) 
                return;


        UINT8* Module = static_cast< UINT8* >( ModuleBase );
        ULONG DecryptionSize = ModuleSize - 2;


        while ( DecryptionSize )
        {
                Module[ DecryptionSize ] += -3 * DecryptionSize - Module[ DecryptionSize + 1];
                --DecryptionSize;
        }


        Module[ 0 ] -= Module[ 1 ];
        return;
}

Thus Inversely,

VOID EncryptModule( PVOID ModuleBase, ULONG ModuleSize )
{
        UINT8* Module = static_cast< UINT8* >( ModuleBase );
        ULONG Iteration = 0;


        Module[ ModuleSize - 1 ] += 3 - 3 * ModuleSize;


        while ( Iteration < ModuleSize )
        {
                Module[ Iteration ] -= -3 * Iteration - Module[ Iteration + 1];
                ++Iteration;
        }


        return;
}

Given this code, one can easily decrypt the module, and manipulate it in ways they see fit. For example, you may choose to inject an older version of this module that potentially allows a user to avoid whatever content is added into the EasyAntiCheat.dll module. Or even modify its contents to map his own image instead. However, it’s best to stay away from assumptions. As not much information is disclosed in this module, Our new point of focus should be peeking at EasyAntiCheat.sys to understand what happens when the module is delivered.

EasyAntiCheat.sys


Once EasyAntiCheat.sys receives the module it decrypts the XTEA buffer, then decrypts the encrypted PE image. Afterward, it prepares to manual map by switching context to the protected game (using KeStackAttachProcess) before running the following code.

Manual Mapping


The following code is used to map an image into the game:

BOOLEAN MapSections( PVOID ModuleBase, PVOID ImageBuffer, PIMAGE_NT_HEADERS NtHeaders )
{
        if ( !ModuleBase || !ImageBuffer )
            return FALSE;


        UINT8* MappedModule = static_cast< UINT8* >( ModuleBase );
        UINT8* ModuleBuffer = static_cast< UINT8* >( ImageBuffer );
        ULONG SectionCount = NtHeaders->FileHeader.NumberOfSections;


        const PIMAGE_SECTION_HEADER SectionHeaders = IMAGE_FIRST_SECTION( NtHeaders );
        const ULONG PEHeaderSize = SectionHeaders->VirtualAddress;


        // Copy the PE header information.....
        memcpy( ModuleBase, ImageBuffer, PEHeaderSize );


        while( SectionCount )
        {
                const PIMAGE_SECTION_HEADER SectionHeader = &SectionHeaders[ SectionCount ];
                if ( SectionHeader->SizeOfRawData )
                        memcpy( &MappedModule[ SectionHeader->VirtualAddress ], &ModuleBuffer[ SectionHeader->PointerToRawData ], SectionHeader->SizeOfRawData );


                --SectionCount;
        }


        return TRUE;
}


BOOLEAN MapImage( PVOID ImageBase, SIZE_T ImageSize, PVOID* MappedBase, SIZE_T* MappedSize, PVOID* MappedEntryPoint, /* x86 only */ OPTIONAL ULONG* ExceptionDirectory, /* x86 only */ OPTIONAL ULONG* ExceptionDirectorySize )
{
        if ( !ImageBase || !ImageSize || !MappedBase || !MappedSize || !MappedEntryPoint )
                return FALSE;


        *MappedBase = nullptr;
        *MappedSize = 0;
        *MappedEntryPoint = nullptr;


        if ( ExceptionDirectory && ExceptionDirectorySize )
        {
                // These parameters are only used to resolve the exception directory if the DllHost module is being mapped into Dllhost.exe....
                *ExceptionDirectory = 0;
                *ExceptionDirectorySize = 0;
        }


        ImageType ModuleType;
        const PIMAGE_NT_HEADERS NtHeaders = RtlImageNtHeader( ImageBase );
        if ( NtHeaders->FileHeader.Machine == IMAGE_FILE_MACHINE_AMD64 )
        {
                ModuleType = ImageType::Image64;
        } 
        else if ( NtHeaders->FileHeader.Machine == IMAGE_FILE_MACHINE_I386 )
        {
                ModuleType = ImageType::Image86;
        }


        PVOID MemBuffer = ExAllocatePool( ImageSize );
        if ( MemBuffer )
        {
                // This will be used to effectively "hide" the module within the process...
                const ULONG RandomSizeStart = RandomSeed( 4, 16 ) << 12UL;
                const ULONG RandomSizeEnd = RandomSeed( 4, 16 ) << 12UL;


                memcpy( MemBuffer, ImageBase, ImageSize );


                ULONG64 SizeOfImage = NtHeaders->OptionalHeader.SizeOfImage + ( RandomSizeEnd + RandomSizeStart );


                BOOLEAN VirtualApiResult = 
                        NT_SUCCESS( NtAllocateVirtualMemory( NtCurrentProcess(), MappedBase, 0, &SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE );


                if ( VirtualApiResult )
                {
                        ULONG OldProtect = 0;
                        VirtualApiResult = NT_SUCCESS( NtProtectVirtualMemory( NtCurrentProcess(), MappedBase, SizeOfImage, PAGE_EXECUTE_READWRITE, &OldProtect ) );
                        if ( VirtualApiResult )
                        {
                                // This region is used to throw people off from the module.
                                RandomizeRegion( *MappedBase, RandomSizeStart );
                                VirtualApiResult = NT_SUCCESS( NtProtectVirtualMemory( NtCurrentProcess(), MappedBase, RandomSizeStart, PAGE_READWRITE, &OldProtect ) );


                                if ( VirtualApiResult )
                                {
                                        PVOID ModuleEnd = static_cast< UINT8* >( *MappedBase ) + ( SizeOfImage - RandomSizeEnd );
                                        RandomizeRegion( ModuleEnd,  RandomSizeEnd );
                                        VirtualApiResult = NT_SUCCESS( NtProtectVirtualMemory( NtCurrentProcess(), &ModuleEnd, RandomSizeEnd, PAGE_READONLY, &OldProtect ) );


                                        if ( VirtualApiResult )
                                        {
                                                PVOID RealModule = static_cast< UINT8* >( *MappedBase ) + RandomSizeStart;
                                                ResolveRelocations( RealModule, MemBuffer, ModuleType, NtHeaders );
                                                NtHeaders->OptionalHeader.ImageBase = RealModule;


                                                if ( MapSections( RealModule, MemBuffer, NtHeaders ))
                                                {
                                                        // Applies the correct memory attributes for each section (.text = RX, .data = RW, .rdata = R, etc)
                                                        CorrectSectionProtection( RealModule, NtHeaders );


                            *MappedBase = RealModule;
                                                        *MappedSize = NtHeaders->OptionalHeader.SizeOfImage;
                                                        *MappedEntryPoint = static_cast< UINT8* >( RealModule ) + NtHeaders->OptionalHeader.AddressOfEntryPoint;


                                                        if ( ExceptionDirectory && ExceptionDirectorySize )
                                                        {
                                                                *ExceptionDirectory = NtHeaders->OptionalHeader.DataDirectory[ IMAGE_DIRECTORY_ENTRY_EXCEPTION ].VirtualAddress;
                                                                *ExceptionDirectorySize = NtHeaders->OptionalHeader.DataDirectory[ IMAGE_DIRECTORY_ENTRY_EXCEPTION ].Size;
                                                        }
                                                }
                                        }
                                }
                        }
                }
        }


        if ( MemBuffer )
        {
                ExFreePool( MemBuffer );
                MemBuffer = nullptr;
        }


        return *MappedEntryPoint != NULL;
}

If you couldn’t tell from the code, this is nothing more than a standard manual mapper. It attempts to hide by allocating extra memory around its memory in the hope a reverser does not see that this is, in fact, dynamic code! You should also note that as long as a section contains raw data, we can map its contents into the game. This means an attacker could intentionally append an extra section (or perhaps hijack an existing section) and EasyAntiCheat.sys carelessly maps this code with no form of validation.

Code Execution


Getting code execution is quite simple. EAC uses APC delivery to execute shellcode in user-mode that gets mapped by the following function:

PVOID MapShellcode(ModuleMapInstance* Instance)
{
        SIZE_T ShellcodeSize = PAGE_SIZE; // 0x1000
        PVOID ShellcodeBase = nullptr;


        BOOLEAN VirtualApiResult = 
                NT_SUCCESS( NtAllocateVirtualMemory( NtCurrentProcess(), &ShellcodeBase, 0, &ShellcodeSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE );


        if ( !VirtualApiResult || !ShellcodeBase )
                return nullptr;


        if ( Instance->ImageType == ImageType::Image64 )
        {
                UINT8 ShellcodeBuffer[] =
                {
                        0x48, 0x83, 0xEC, 0x28,        // SUB RSP, 0x28
                        0x4D, 0x31, 0xC0, // XOR R8, R8
                        0x48, 0x31, 0xD2, // XOR RDX, RDX
                        0x48, 0xFF, 0xC2, // INC RDX
                        0x48, 0xB8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // MOV RAX, 0
                        0xFF, 0xD0,        // CALL RAX
                        0x48, 0x83, 0xC4, 0x28, // ADD RSP, 0x28
                        0xC3 // RETN
                };


                memcpy( &ShellcodeBuffer[15], Instance->DllEntryPoint, sizeof( Instance->DllEntryPoint ) );
                memcpy( ShellcodeBase, ShellcodeBuffer, sizeof( ShellcodeBuffer ) );
        }
        else
        {
                UINT8 ShellcodeBuffer[] =
                {
                        0x6A, 0x00,        // PUSH 0
                        0x6A, 0x01,        // PUSH 1
                        0xFF, 0x74, 0xE4, 0x0C, // PUSH [RSP+0xC]
                        0xB8, 0x00, 0x00, 0x00, 0x00, // MOV EAX, 0
                        0xFF, 0xD0,        // CALL EAX
                        0xC2, 0x04, 0x00 // RETN 4
                };


                memcpy( &ShellcodeBuffer[9], Instance->DllEntryPoint, sizeof( Instance->DllEntryPoint ) / 2 );
                memcpy( ShellcodeBase, ShellcodeBuffer, sizeof( ShellcodeBuffer ) );
        }


        return ShellcodeBase;
}

Once the EP of this module has been executed, its header subsequently gets erased, ensuring reverse-engineers do not have access to it. Instead, EasyAntiCheat.dll encodes specific data such as the HANDLE to the EasyAntiCheat.sys driver inside this unused space. There are many more functionalities to this manual mapper, such as resolving the IAT of the module. As this information is not prerequisite to understanding this section, we can skip the introductory content.

A quick look at EasyAntiCheat.dll


Before we get to the exploit, let’s have a look at the actual EasyAntiCheat.dll module to see what implications hijacking this payload could have. As we all know, manual mapping is a popular code injection mechanism shared amongst cheat developers. To ensure EasyAntiCheat is not collecting detection data from within a legitimate region of memory, it has built an internal whitelist system of system modules, as well as the manual mapped image range. We can see an example of how this is used in the function below:

BOOLEAN IsInValidMemory( EACGlobal* GlobalContext, ULONG64 VirtualAddress )
{
        if ( !VirtualAddress )
                return FALSE;


        ModuleListEntry* ModuleList = &GlobalContext->ModuleList;
        RtlEnterCriticalSection( ModuleList ); // Wait until the list is available....
        ModuleListEntry* CurrentEntry = ModuleList->Flink;
        for ( i = ModuleList->Flink; CurrentEntry != i; CurrentEntry = CurrentEntry->Flink; )
        {
                if ( CurrentEntry->Unk0 && CurrentEntry->Unk1 && 
                        VirtualAddress >= CurrentEntry->ImageBase && VirtualAddress < CurrentEntry->ImageBase + CurrentEntry->SizeOfImage )
                {
                        break;
                }
        }


        RtlLeaveCriticalSection(ModuleList);
        InternalModuleBase = GlobalContext->MappedImageBase;


        // If it landed inside a legit module or within EasyAntiCheat.dll, return TRUE.
        if ( i != ModuleList || VirtualAddress >= StartAddress && VirtualAddress < GlobalContext->MappedImageSize + StartAddress )
                return TRUE;


        // Other regions like dynamically allocated shellcode below....
        return FALSE;
}

This function is executed regularly inside EasyAntiCheat.dll to determine if an address lives within legitimate memory. As you could tell, if the address lands inside the internal module, it returns TRUE. The many things EAC protects the game against (illegal thread creation, inline hooks, etc) are all circumvented via mapping your image inside EasyAntiCheat.dll. Fatal, right?

Note: EAC does not always use this function, and fairly frequently has inlined checks to detect whether an address exists within its memory.

Exploitation


Now that we understand how the image is mapped into a process, we can develop our own payload to hijack the user-mode execution to append our image to EAC’s existing image. The layout of this exploit looks something like this:

In further detail, you will need to inject a DLL into eac_launcher.exe that does the following:

  1. Pattern scan for the SetupEasyAntiCheatModule function recursively.
  2. Once we find a hit, hook the function and pull the existing image.
  3. Decrypt the image using DecryptModule, then modify an existing section to map your new code.
  4. Change the Section attributes to contain PAGE_EXECUTE_READWRITE attributes.
  5. Update the ImageSize parameter (and SizeOfImage in the IMAGE_OPTIONAL_HEADER structure) and call EncryptModule to repackage the module.
  6. Patch the DllEntryPoint of the original to perform a REL32 JMP to your DllEntryPoint.
  7. Once we invoke the EP, restore these patches and call EasyAntiCheat.dll’s entry point.
  8. Done!

To avoid dealing with x86 calling conventions, I decided it was best to place a int3 instruction to cause an interrupt once the function is executed. I then handled this using a VEH (Vectored Exception Handler) to execute our hook procedure and lastly, restore the original opcode with the modified parameters.

One should also note that you must append your PE header information to EasyAntiCheat.dll’s header. This is because information such as relocations and import data will not be resolved and thus requiring another form of workaround to properly load your module or expect a crash. To keep things simple, I have avoided resolving these entirely. If you so wish, you can map your PE header and read it out and solve everything inside your entry point.

You should also be aware EasyAntiCheat.dll has integrity checks running inside EasyAntiCheat.sys; so do not try patching unwritable sections without a bypass! PS: This additionally implies you can intentionally create multiple sections in the binary and forcibly make the driver protect specific code sections for you.

Demo


The following video is a demonstration to show this technique in action, displaying logs inside DbgView.exe by calling OutputDebugStringA inside the game.

Conclusion


EasyAntiCheat.sys has unintentionally created an ideal condition for code execution in the game that allows you to dynamically run code inside the process from user-mode, and allowing you to hook and execute any code with no conflict from the anti-cheat. It is possible to even pair this project with a Secure Boot + HVCI (Hypervisor Code Integrity) enabled machine. Applied further, it is possible to turn this project into a local process injection exploit for games protected by alternative solutions such as BattlEye. Of course, there are ways for complete detection & prevention of this.

For clarity’s sake, some approaches to prevent this exploit inside EasyAntiCheat games include:

  • embed the usermode dll inside of the driver and inject it straight into the game… not sure why this isnt already being done?
  • Signing the EasyAntiCheat.dll module and validating the signature inside EasyAntiCheat.sys
  • Checking the section headers to ensure each one only has the correct amount of privileges
  • Protect eac_launcher.exe once the service runs to prevent hooks from being placed.
  • Monitor the DLL’s execution and profile it to ensure it detects certain outliers who are hijacking this module.

Without a doubt, there are many more things EasyAntiCheat can implement (if they have not already) to prevent this type of attack. Although EasyAntiCheat has done a great job in recent years catching up with kernel exploits and even recent hypervisor technology, it’s also a good idea to look back at old design models and make sure they work as intended without caveats.