责声明
这不是对 EasyAntiCheat 的攻击。EasyAntiCheat 在保护游戏方面做得非常出色,并将在未来几年继续这样做。我通过对 EasyAntiCheat 模块的私人研究收集了这些内容,与公共游戏黑客发行商或其他实体的工作没有任何关联。我对写秘籍没有兴趣,这里的一切都只是为了教育目的。请不要联系我寻求任何与作弊相关的问题的帮助,因为我不会回应任何此类请求。
同样重要的是要注意,在整篇文章中,对 EasyAntiCheat 的内部结构进行了假设。我没有从上到下对反作弊进行逆向工程,所以我不能自信地断言这是否会允许你创建未被发现的作弊。最好假设 EasyAntiCheat 已经实现了检测机制。有一些具有类似攻击向量的项目,例如实现了类似目标的modmap。
介绍
EasyAntiCheat 是Epic Games拥有的商业反作弊解决方案,目前声称(并且众所周知)是游戏黑客预防的“行业领先”解决方案。对于游戏开发者来说,这允许在他们的游戏中顺利实施反作弊,防止多种形式的游戏操纵。从 AHK 脚本到隐藏在游戏中的作弊,EasyAntiCheat 已经在反作弊行业站稳了脚跟,为更诚实的游戏体验铺平了道路。
对于攻击者来说,一个重要的难题是了解反作弊的运作方式。因此,了解反作弊内部发生的事情可以隐藏您的踪迹(或放置钩子和攻击)。让我们看看 EasyAntiCheat 如何通过其模块集在内核和游戏之间架起桥梁。这将揭示驱动程序中一个被忽视的设计缺陷如何允许攻击者在任何受 EasyAntiCheat 保护的游戏(或可能受其他竞争对手服务保护的游戏)中不受限制地执行未签名代码。
这有效地诱使反作弊程序保护您的记忆作为自己的记忆,并赋予它各种能力,例如创建线程、故意放置钩子等。话虽如此,EasyAntiCheat 的设计包含一系列可执行文件,我们将仅检查此漏洞利用中的三个主要模块。
在我们开始之前,下图显示了一些标准程序中负责 EasyAntiCheat 初始化的模块,并简要说明了它的操作方式。
注意:这些不是 EasyAntiCheat 使用的唯一模块,但是这些是了解即将发生的事情所必需的唯一模块。
x86 模块
如上图所述,反作弊程序注入了一个标记为 EasyAntiCheat.dll 的模块。该模块作为服务的主要模块之一,用于将数据发送到服务器进行后台分析。不要忘记它自己的一组启发式数据收集例程。但是这个 DLL 是如何被注入的呢?考虑 x86 模块中的这组函数:
代码语言:txt复制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 "GlobalEasyAntiCheatBin"
// 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...
}
// ...
}
从下面这组代码可以看出,EasyAntiCheat 通过 XTEA 加密缓冲区将 EasyAntiCheat.dll 连同其他必要信息(如 GameID、进程名称等)发送给驱动程序。
这在你看来是不是很可恶?因为它确实对我有用。乍一看,您会注意到他们还使用自己的算法对模块进行了加密,因为前几个字节A7 ED 96 0C 0F....
不是预期的 Windows PE 标头格式。考虑到驱动模块似乎也遵循相同的格式,反转 EasyAntiCheat.exe 将允许我们定位解密。目前情况如下:
图像加密
代码语言:txt复制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;
}
因此反过来,
代码语言:txt复制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;
}
鉴于此代码,人们可以轻松解密模块,并以他们认为合适的方式对其进行操作。例如,您可以选择注入此模块的旧版本,这可能允许用户避免将任何内容添加到 EasyAntiCheat.dll 模块中。或者甚至修改其内容以映射他自己的图像。但是,最好远离假设。由于本模块中没有透露太多信息,我们的新重点应该是查看 EasyAntiCheat.sys 以了解模块交付时会发生什么。
EasyAntiCheat.sys
一旦 EasyAntiCheat.sys 收到模块,它就会解密 XTEA 缓冲区,然后解密加密的 PE 映像。之后,KeStackAttachProcess
在运行以下代码之前,它通过将上下文切换到受保护的游戏(使用)来准备手动映射。
手动映射
以下代码用于将图像映射到游戏中:
代码语言:txt复制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;
}