FPS游戏反作弊系统设计:特征码扫描与启发扫描
一、前言
本文将介绍特征码扫描与启发式扫描。特征码和启发扫描其实大家都在杀毒软件里面听过,网上也有很多关于特征码和启发扫描实现的轮子,但无一例的是,这些基本都是对新人不太友好的,或者功能太”重的”(有些甚至用了神经网络….新人入门一脸懵逼)。本文会尽量以简单易懂的形式介绍.希望能帮助后人设计轮子时候有个参考。
之前的文章: FPS游戏反作弊系统设计: API调用回溯
二、特征码扫描与启发扫描技术实现思路
在了解特征码扫描之前你需要了解所有程序最终都是一堆机器码组成的汇编指令.拖进od或者ida或者x64dbg你会看到这样子:
无论如何 ,程序都会这样子.因此 所为特征码扫描 我们只需要 提取其中的一段或者多段机器码,然后存入数据库,当扫描时候,从数据库提取机器码出来,遍历这个程序做匹配即可(说起来容易)
对于启发扫描,我们需要观察程序行为.如果一个程序同时设置开机启动/创建服务/网络通讯/写入文件这些行为,说明很有可能是病毒,但是也可能是正常软件(所为存在误报就是这样子),那么怎么监视程序行为呢?大部分杀毒软件通过自己的虚拟机,沙盒,去让程序运行,同时剥离掉程序的sleep这种休眠函数.执行个20秒10秒,再把进行的行为进行个判断.再或者通过API调用关系进行处理(可以去vxjump看看里面的深入文章如果对这种技术感兴趣的).当然我这边是反外挂的,没有做那么高端的(因为大部分外挂会有登陆窗口,所以虚拟机类启发其实对这种外挂很无能为力),我这边使用的玩具级别的启发扫描.
三、实现特征码扫描
在此之前,我是取内存特征码,因为内存特征码能无视一些加密外壳.但内存特征码有坏处是有些内存地址是随机的.下次开机就不是这个地址了.比如:
mov eax,1212(随机地址)
call eax
如果我们取mov eax 12 12 call eax 这个作为特征码,那么下次启动,匹配的可能是mov eax 88 88 call eax ,1212会发生改变!所以我们需要通配符号 ?? :
mov eax ?? ?? call eax 这样,无论1212变成啥 我们都可以正确匹配到
再来过一次流程:
1. 读内存中的文件 2.找到代码段,其他区段我们不扫描. 3.随机取三段位置,得到其特征码 4.对于特征码的随机变化部位加通配符 5.匹配特征码.
直接上代码
得到base地址,这里是因为计划连内存模块也扫描的,但是想了想没必要,就只扫主程序了
HMODULE Megrez_GetBase(HANDLE hProcess) { HMODULE hModule[100] = { 0 }; DWORD dwRet = 0; BOOL bRet = ::EnumProcessModulesEx(hProcess, (HMODULE*)(hModule), sizeof(hModule), &dwRet, LIST_MODULES_ALL); if (FALSE == bRet) { #ifdef DEBUG WCHAR _buf[256] = { 0 }; swprintf_s(_buf, 256, L"特征码扫描枚举模块失败"); OutputDebugStringW(_buf); #endif ::CloseHandle(hProcess); return NULL; } // 获取第一个模块加载基址 HMODULE pProcessImageBase = hModule[0]; return pProcessImageBase; }
得到代码段:
DWORD Megrez_GetCodeSegAttr(HANDLE hProcess, HMODULE hBase, OUT PDWORD pSizeofCode) { PBYTE pSection = (PBYTE)hBase; SIZE_T dReadNum; DWORD dPE = NULL; ReadProcessMemory(hProcess, (PBYTE)hBase + offsetof(_IMAGE_DOS_HEADER, e_lfanew), &dPE, 4, &dReadNum); pSection += dPE; pSection += 4; pSection += sizeof(IMAGE_FILE_HEADER); DWORD dBaseOfCode, dSizeOfCode; ReadProcessMemory(hProcess, (PBYTE)pSection + offsetof(_IMAGE_OPTIONAL_HEADER, SizeOfCode), &dSizeOfCode, 4, &dReadNum); ReadProcessMemory(hProcess, (PBYTE)pSection + offsetof(_IMAGE_OPTIONAL_HEADER, BaseOfCode), &dBaseOfCode, 4, &dReadNum); *pSizeofCode = dSizeOfCode; return dBaseOfCode; }
得到随机三段特征码:
void Megrez_GetSigCode(HANDLE hProcess, PBYTE pTEXTInMemory, DWORD dSizeOfText, OUT std::vector& vecSigCode) { std::random_device rd; std::default_random_engine e(rd()); SIZE_T Temp; DWORD dSigRVA[3] = { 0 }; dSigRVA[0] = e() % (dSizeOfText - 25); dSigRVA[1] = e() % (dSizeOfText - 25); dSigRVA[2] = e() % (dSizeOfText - 25); /*for (int i = 0; i < 3; i++) { printf("%p ", dSigRVA[i]); } printf("\n");*/ for (int i = 0; i < 3; i++) { PBYTE pTemp = (PBYTE)malloc(100); PBYTE pTempTemp = pTemp; memset(pTemp, 0, 100); ReadProcessMemory(hProcess, pTEXTInMemory + dSigRVA[i], pTemp, 25, &Temp); std::string Temp; for (int j = 0; j < 20; j++) { char subStr[3] = { 0 }; sprintf(subStr, "%02x", *pTemp); Temp += subStr; Temp += ' '; pTemp++; } PBYTE SigCode = (PBYTE)malloc(100); memset(SigCode, 0, 100); memcpy(SigCode, Temp.c_str(), Temp.length() + 1); vecSigCode.push_back(SigCode); free(pTempTemp); } }
匹配特征码:
DWORD Megrez_StringMatching(HANDLE hProcess, std::vector vec, PBYTE pTEXTInMemory, DWORD dSizeOfText) { std::string A((char*)vec[0]); const char* pat1 = A.c_str(); DWORD firstMatch1 = 0; DWORD dCodeEnd1 = 0; PBYTE pMemory = (PBYTE)malloc(dSizeOfText); memset(pMemory, 0, dSizeOfText); SIZE_T dReadSize; ReadProcessMemory(hProcess, pTEXTInMemory, pMemory, dSizeOfText, &dReadSize); for (PBYTE pCur = pMemory; pCur < pMemory + dSizeOfText; pCur++) { if (dCodeEnd1 == 0) { if (!*pat1)//我的字符串结束 { dCodeEnd1 = 1; } if (dCodeEnd1 == 0) { if (*(PBYTE)pat1 == '?' || *pCur == getByte(pat1))// 匹配上了 { if (!firstMatch1) firstMatch1 = 1; if (!pat1[2]) { dCodeEnd1 = 1; } if (dCodeEnd1 == 0) { if (*(PWORD)pat1 == '\?\?' || *(PBYTE)pat1 != '\?') pat1 += 3; else pat1 += 2; //one ? } } else { pat1 = A.c_str(); // firstMatch1 = 0; } } } //省略一些特殊xxoo技巧 //..... } free(pMemory); return firstMatch1 & firstMatch2 & firstMatch3; }
就这样.恭喜你实现了一个价值五十万的杀毒软件(doge
四、实现启发扫描
其实,搞杀毒软件的那一套虚拟机启发扫描对外挂不实用因为外挂都有登陆注册,虚拟机无法判断登录后的结果.因此这边我使用一个玩具级别的启发扫描那就扫导入表.
来复习一边导入表: 程序要调用的一些api 会提前写在程序导入表里面,扫描导入表意味着,可以判断程序调用了什么api,判断程序调用了扫描api,就知道程序的行为
所以流程如下:
1. 读入内存文件 2.解析内存文件导入表里面的api给这些api分类,高危,中危,低危. 3.扫描导入表,高危类api(比如读写进程,dll注入)加30分,中危20,低危10分 4.当分数大于某个特定的值,可以说这个东西疑似外挂.上传给云端.
直接上代码:
读入内存文件:
HMODULE CHeuristicScan::Megrez_GetBase(HANDLE hProcess) { HMODULE hModule[100] = { 0 }; DWORD dwRet = 0; BOOL bRet = ::EnumProcessModulesEx(hProcess, (HMODULE*)(hModule), sizeof(hModule), &dwRet, LIST_MODULES_ALL); if (FALSE == bRet) { ::CloseHandle(hProcess); return NULL; } // 获取第一个模块加载基址 HMODULE pProcessImageBase = hModule[0]; return pProcessImageBase; }
解析导入表,给api分级:
PBYTE CHeuristicScan::Megrez_GetScetionBaseAndSize(DWORD RVA, PDWORD pSize) { SIZE_T sReadNum; for (int i = 0; i image_file_header.NumberOfSections; i++) { DWORD dVirtualSize, dVirtualAddress; ReadProcessMemory(this->hProcess, this->pFirstSectionTable + offsetof(IMAGE_SECTION_HEADER, Misc.VirtualSize ) + sizeof(IMAGE_SECTION_HEADER) * i, &dVirtualSize, 4, &sReadNum); ReadProcessMemory(this->hProcess, this->pFirstSectionTable + offsetof(IMAGE_SECTION_HEADER, VirtualAddress) + sizeof(IMAGE_SECTION_HEADER) * i, &dVirtualAddress, 4, &sReadNum); if (RVA >= dVirtualAddress && RVA pImageBase + dVirtualAddress; } } return NULL; } DWORD CHeuristicScan::Scan() { DWORD dScore = 0; SIZE_T dReadNum; DWORD dCodeSecSize; PBYTE pSectionAddr = this->Megrez_GetScetionBaseAndSize(this->image_data_directory.VirtualAddress, &dCodeSecSize); this->pSectionBase = (PBYTE)malloc(dCodeSecSize); ReadProcessMemory(this->hProcess, pSectionAddr, this->pSectionBase, dCodeSecSize, &dReadNum); PBYTE pSectionBaseTemp = this->pSectionBase; DWORD dOffset = Megrez_GetOffsetOfSectoin(this->pImageBase, pSectionAddr, this->image_data_directory.VirtualAddress); unordered_set hashsetProcNameHTemp(hashsetProcNameH); unordered_set hashsetProcNameMTemp(hashsetProcNameM); unordered_set hashsetProcNameLTemp(hashsetProcNameL); //遍历 for (int i = 0; *(PDWORD)(this->pSectionBase + dOffset) != 0 ; i++ , dOffset += sizeof(IMAGE_IMPORT_DESCRIPTOR)) { DWORD dINTOff = Megrez_GetOffsetOfSectoin(this->pImageBase, pSectionAddr, *(PDWORD)(this->pSectionBase + dOffset)); // printf("%s\n", this->pSectionBase + Megrez_GetOffsetOfSectoin(this->pImageBase, pSectionAddr, *(PDWORD)(this->pSectionBase + dOffset + 12))); for (int j = 0; *(PDWORD)(this->pSectionBase + dINTOff) != 0; j++ , this->isX64 ? dINTOff += 8: dINTOff += 4) { if (this->isX64) { if ((*(unsigned long long*)(this->pSectionBase + dINTOff) & 0x8000000000000000) == 0x8000000000000000) { continue; } else { DWORD dStringOff = Megrez_GetOffsetOfSectoin(this->pImageBase, pSectionAddr, *(PDWORD)(this->pSectionBase + dINTOff)) + 2; auto iter = hashsetProcNameHTemp.find((char*)(this->pSectionBase + dStringOff)); if (iter != hashsetProcNameHTemp.end()) { //高危api printf("30:%s\n", (char*)(this->pSectionBase + dStringOff)); dScore += 30; Remove((char*)(this->pSectionBase + dStringOff), hashsetProcNameHTemp); } iter = hashsetProcNameMTemp.find((char*)(this->pSectionBase + dStringOff)); if (iter != hashsetProcNameMTemp.end()) { //中危api printf("10:%s\n", (char*)(this->pSectionBase + dStringOff)); dScore += 10; Remove((char*)(this->pSectionBase + dStringOff), hashsetProcNameMTemp); } iter = hashsetProcNameLTemp.find((char*)(this->pSectionBase + dStringOff)); if (iter != hashsetProcNameLTemp.end()) { //低危 printf("5:%s\n", (char*)(this->pSectionBase + dStringOff)); dScore += 5; Remove((char*)(this->pSectionBase + dStringOff), hashsetProcNameLTemp); } } } else { DWORD dStringOff = Megrez_GetOffsetOfSectoin(this->pImageBase, pSectionAddr, *(PDWORD)(this->pSectionBase + dINTOff)) + 2; //..省略,跟上面一样 } } } if (dScore >= 大于某个值) { this->bIsGameTool = TRUE; //上报 //.... } return dScore; }
运行结果:
可以看到,可以成功识别游戏外挂,dll注入器.
恭喜你,完成了一个价值100w的启发式扫描引擎(doge
五、扩展与反思
事实上,这玩意也不是什么新奇事物了.对抗方法有很多,特征扫描最常见的就是加个虚拟化壳动态虚拟化.这样子特征扫描就gg了,但是对于反外挂来说.没有什么正常程序会加虚拟化壳,所以遇到非正常程序 比如不是c++ c# vc+6.0 deiph的程序一律上报云端然后做判断处理.
对抗启本文发式扫描的方法最简单的是动态调用,但动态调用也可以直接通过内存扫描call函数地址来判断行为.
攻防无止境.还得多多学习
*本文作者:huoji120,转载请注明来自FreeBuf.COM