FPS游戏反作弊系统设计:API调用回溯

0×01 前言

目前自己在制作csgo游戏的反作弊系统。国内外有名的游戏反作弊系统有TP/NP/BE和EAC,但国内外几乎没有关于反作弊系统方面的资料(其实是完全没有…),一来是因为搞二进制安全的人特别少,二来这方面在某些公司属于”商业机密”。于是打算在freebuf上开个坑,把我的一些游戏反作弊思路展现给大家,本文尽量以白话文的方式简单易懂的把一些关键知识写出来.涉及代码量很少。希望如果后人有相关需求的可以参考一下本文。由于本文是一边制作一边写的,所以会分为几个部分来写,有些地方可能没有考虑周全,多多见谅。

0×02 游戏外挂常见注入方式

目前大部分游戏外挂不再是以前那种createremotethread + loadlibary注入方式了,因为大部分反作弊有自己的minifilter文件过滤驱动与imageloadcallback镜像加载回调做判断,大部分反作弊软件在这种过滤钩子中做这种操作:

if(!CheckFileCertificateByR3(FilePatch)){
  //把文件路径传回r3,r3判断文件数字签名是否在白名单数字签名里面(比如微软数字签名),如果是白名单文件,就放行,如果不是白名单文件,就拦截
  //不是白名单文件...拦截
  block;
}
//放行
pass;

所以,外挂是特别难通过dll直接注入到游戏里面.因此大部分外挂通过一种 无文件落地注入方式  所谓无文件落地注入方式,就是直接在游戏进程里面开辟一个内存空间,把外挂的dll的shellcode写入,之后手动修复输入表,然后解析pe文件头拿到dllmain,再通过createremotethread,apc或者hook方式让游戏执行这块内存地址,这样子外挂就注入了

具体代码如下(抄自google):

//以下代码来自与谷歌搜索
void InjectorDLLByManualMap(const char* filepath, HANDLE hProcess)
{
    LPVOID lpBuffer;
    HANDLE hFile;
    DWORD dwLength;
    DWORD dwBytesRead;
    DWORD dwThreadId;
    ULONG_PTR lpReflectiveLoader;
    LPVOID lpRemoteDllBuffer;
    //打开文件
    hFile = CreateFileA(filepath, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    //得到文件大小
    dwLength = GetFileSize(hFile, NULL);
    lpBuffer = HeapAlloc(GetProcessHeap(), 0, dwLength);
    //读入文件
    ReadFile(hFile, lpBuffer, dwLength, &dwBytesRead, NULL);
    //修复导入表
    dwReflectiveLoaderOffset = GetReflectiveLoaderOffset(lpBuffer);
    //给游戏进程分配一段内存空间
    lpRemoteDllBuffer = VirtualAllocEx(hProcess, NULL, dwLength, MEM_RESERVE|MEM_COMMIT, PAGE_EXECUTE_READWRITE);
    //写入文件shellcode到分配的内存空间
    WriteProcessMemory(hProcess, lpRemoteDllBuffer, lpBuffer, dwLength, NULL)
    lpReflectiveLoader = (ULONG_PTR)lpRemoteDllBuffer + dwReflectiveLoaderOffset;
    //启动进程
    CreateRemoteThread(hProcess, NULL, 1024*1024, (LPTHREAD_START_ROUTINE)lpReflectiveLoader, NULL, NULL, &dwThreadId)
}

其特点是:内存标志为PAGE_EXECUTE_READWRITE,MEM_PRIVATE,无文件,无模块,不会触发minifilter和imageloadcallbacks,无法通过正常方式枚举到外挂模块,隐蔽性非常高.

0×03 检测内存加载外挂

之前的方法看起来非常的”无敌”实际上也是可以对抗的,因为其特征也非常明显:

内存属性为MEM_PRIVATE,内存标志为PAGE_EXECUTE_READWRITE.大小会很大.

所以检测方法也有几个:

1.暴力搜索PE头,大部分这种内存加载的dll都有pe头.一个内存属性为mem_private居然还有pe头,就说明是外挂了.目前大部分反作弊都有这个机制

外挂反制: 抹掉pe头.不止pe头,还可以抹掉一切pe特征.

2.createthreadcallbacks得到线程地址,判断线程地址是否在一个内存属性的mem_private的内存里面.如果是,说明就是外挂了.

外挂反制:不创建线程,使用hook方启动外挂.

3.api调用回溯.顾名思义,外挂总要调用一些api地址的,我们可以通过回溯是谁调用了api地址,然后判断这个调用地方内存属性是不是mem_private.有两种方法,一个是hook所有关键api,在hook部位用_returnaddres()得到调用地址(其实是读ESP/RSP寄存器)第二种通过int3断点触发异常,使用异常处理函数处理这个异常,判断调用者.

外挂反制: 第一种内联hook方式,直接写跳转跳过hook,比如你hook的时候:

 jmp 你的hook地址
 push ebp
 push eax
 call xxxx;

外挂可以直接从push ebp调用,不再调用你jmp ,就可以绕过

第二种外挂反制目前没有特别的能反制的地方.除非外挂自己构造api函数调用更底层的api.当然我们可以混淆原底层api的地址(无限套娃),具体以后在说.

0×04 实现调用回溯

为了实现调用回溯,我们需要实现如下步骤:

1. 设置异常处理程序去捕获异常,代码如下:

AddVectoredExceptionHandler

2. 拷贝原API地址到自己的内存区域,然后填充原API地址为int,代码如下:

LPVOID pHOOKAdress;
	pHOOKAdress = Megrez_GetProAdress(pszModuleName, pszProcName);
	vecInt3HookedAdress.push_back((DWORD)pHOOKAdress);		//用于检测
	if (pHOOKAdress == 0)
	{
		return 0;
	}
	DWORD dProSize = 0;
	LPBYTE pTemp = (LPBYTE)pHOOKAdress;
	BYTE bTemp = 0;
	for (dProSize = 0; ; )
	{
		bTemp = *pTemp++;
		dProSize++;
		if (bTemp == 0xcc)
		{
			break;
		}
	}
	DWORD dFileSize = dProSize - 1;
	PVOID pNewAddr = VirtualAlloc(NULL, dFileSize, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
	if (pNewAddr == NULL)
	{
		return 0;
	}
	Megrez_SetMemoryAttr(pHOOKAdress, dProSize);
	memcpy(pNewAddr, pHOOKAdress, dProSize - 1);
	memset(pHOOKAdress, 0xcc, 1);
	memset((PBYTE)pHOOKAdress + 1, 0xc3, 1);
	memset((PBYTE)pHOOKAdress + 2, 0x90, dProSize - 1  -2);
	memset((PBYTE)pHOOKAdress + 2 + dProSize - 1 - 2 - 1, 0xcc, 1);
	//memset((PBYTE)pHOOKAdress + 2 + dProSize - 3 - 2 , 0xcc, 2);
	mapAdress.insert(pair<DWORD, DWORD>((DWORD)pHOOKAdress, (DWORD)pNewAddr));
	Megrez_SetMemoryAttr(pHOOKAdress, dProSize);
	Megrez_SetMemoryAttr(pNewAddr, dFileSize);

这样子原api函数就会变成int3 当调用时候就回触发int3异常 然后被我们的异常处理捕获

3. 查询异常位置内存信息,如果是meme_private者调用的代码,则报告给服务端,代码如下(记住,x32位下保存调用者地址的是esp,x64位下保存调用者地址的是rsp,):

size_t sizeQuery = VirtualQuery((PVOID)caller_function, lpBuffer, sizeof(MEMORY_BASIC_INFORMATION));
	bool non_commit = lpBuffer->State != MEM_COMMIT;
	bool foreign_image = lpBuffer->Type != MEM_IMAGE && lpBuffer->RegionSize > 0x2000;
	bool spoof = *(PWORD)caller_function == 0x23FF; // jmp qword ptr [rbx],这是为了防止被欺骗
	return sizeQuery || non_commit || foreign_image || spoof; //返回

处理完异常后,我们要跳到原来的保存的api内存里面正常调用(设置eip保存的内存地址)

ExceptionInfo->ContextRecord->Eip = mapAdress[(DWORD)ExceptionInfo->ExceptionRecord->ExceptionAddress];
#ifdef DEBUG
        WCHAR _buf[256] = { 0 };
        swprintf_s(_buf, 256, L"eIP:0x%08X\n", ExceptionInfo->ContextRecord->Eip);
        OutputDebugStringW(_buf);
#endif
        //已经处理了异常要再调用下一个异常处理来处理此异常
        return EXCEPTION_CONTINUE_EXECUTION;
    }
    //调用下一个处理器
    return EXCEPTION_CONTINUE_SEARCH;

可以看到,这样子就得到了api调用者的信息,从而做出判断.

(部分代码参考了BE)

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章