本文为看雪论坛优秀文章
看雪论坛作者ID:小迪xiaodi
原文链接:游戏安全之游戏Call检测的对抗与防护
目录:0x00 前言0x01 前情回顾0x02 Call的基础知识0x03 Call常见防护手段 {①标志位检测②call链③堆栈检测④线程环境检测⑤其他防护和检测}0x04 总结
0x00:前言
——————————————————————————————————————————————————————————————————
相信大家很多人做逆向都是从“游戏外挂”这种东西开始的,这个技术一旦被恶意利用,便会带来极大的危害。因此,一提及该技术便会遭来异样的眼光
基于此,我们要做的便是“做好自己”,去抛开利益,回归技术的本质,最重要的东西是逆向这个领域给我们带来的乐趣,对事物的理解,人生成长的经历,这个过程才是我们最为宝贵且不可估量的财富。
0x01:前情回顾
——————————————————————————————————————————————————————————————————
在这之前,写了一篇关于游戏攻防的文章:
网络游戏安全之实战某游戏厂商FPS游戏CRC检测的对抗与防护: /thread-253552.htm
这篇文章便有提及到在游戏安全的对抗中,诞生的许多对抗游戏外挂作弊的方法,其中便有Call检测被提及到
根据自身总结的一些经验结合编程知识,我们通过这篇文章来系统的讲解一下常见的游戏外挂Call检测攻防对抗
0x02:步入今天的正题——Call
——————————————————————————————————————————————————————————————————首先,我们今天要讲的是游戏的Call检测,所以为了能让下面的内容让大家理解,我们先来准备一下Call检测的基础知识吧:
1.Call是什么:
call是汇编指令,该指令是计算机转移到调用的子程序 一般来说,执行一条CALL指令相当于执行一条PUSH指令加一条JMP指令 call指令是调用子程序,后面紧跟的应该是子程序名或者过程名
2.Call的格式:
3.Call在反汇编逆向中的体现:
通过查阅资料,发现对于Call的资料比较松散且言语晦涩遮掩,仅仅存在于汇编方面,在此我们将通过C语言编程让大家对Call有一个清晰的认识:
①编写如下C语言代码:
代码需要注意的地方:我们编写了一个add函数,但是并没有在main函数中调用,只是在main函数中进行了赋值变量的操作
②去除优化编译生成: 去除优化是为了方便调试
③编译运行程序,附加到OD定位调试分析:
附加进程,切换到主模块内存利用字符串技巧定位到关键函数处:通过观察,我们发现了我们在C语言代码中编写的字符串,双击它双击便进到函数内部,我们发现了我们在代码中调用的Printf以及MessageBox函数,并推测:
0042F170 55 push ebp ; void add()0042F171 8BEC mov ebp, esp0042F173 81EC C0000000 sub esp, 0C00042F179 53 push ebx
推测该位置为我们add函数的函数头,因此,0x0042F170 为我们函数的起始地址随后进行远程调用Call测试,我们使用的测试工具为代码注入器,使用方式如图所示:附加进程,然后键入call 0042F170,点击右侧的注入远程代码按钮测试结果:我们远程的调用了别人的Call
因此,我们可以总结一下,
Call就是我们平时正向编程中所写的“函数”正向编程中我们自己调用为“合法调用”,外部调用通常为“非法调用”,如外挂调用游戏的攻击Call,实现自动攻击打怪脚本,以及我们远程调用自己的add函数,实现了加法计算,以及弹出MessageBox,这种外部程序进行的调用通常都是非法调用。
0x03:Call常见防护手段
——————————————————————————————————————————————————————————————————
了解完call的基本知识后,我们看一下游戏基于call的一些防护手段,讲的可能不全,还望各位带佬补充~
一.标志位检测
标志位检测是通过判断游戏程序中某个关键的变量或标志位的值来进行非法调用检测的,可以写如下代码实验:
#include <stdio.h> #include <Windows.h> int a, b; void add(){ //利用变量a进行校验 a = a + b; printf("这里是函数addn"); } //游戏攻击Call内层-实现攻击 void Attack_2(){ if (a == 1) printf("检测到非法调用!n"); } //游戏攻击Call外层-实现攻击 void Attack_1(){ add(); Attack_2(); printf("这里是函数Attack_1n"); } int main(){ a = 1, b = 2; getchar(); return 0; }大致的判断流程如下,如果你不经过特定的步骤去调用函数,其中的标志位,例如变量a将会与校验值进行判断,如果不吻合将提示非法操作:
我们现在从逆向的角度去看,编译生成程序,丢进Ollydbg工具分析:
根据字符串技巧,我们首先定位到attack1函数位置,并记录其Call调用地址为:0x0042F090:通过反汇编与C语言代码的逻辑关系,我们通过分析找到attack2函数的地址为0x0042F100,并观察到判断标志位的蛛丝马迹:
此时此刻,我们对attack1函数和attack函数分别进行远程调用,其中attack1:0x0042F090 attack2: 0042F100
调用attack1,一切正常,因为是合法的流程调用:尝试不经过attack1,直接调用内部的attack2,显示非法调用,因为跳过了前面的步骤:
总结一下,该防护手法较简单,仅仅通过简单的变量去实现一个标志位的校验,因此,过掉也十分简单,如图:
1.可以直接更改关键的跳转 2.也可以更改标志位寄存器 3.还可以更改标志位的内存地址 4.如果有CRC校验需要过掉该处代码的CRC检测 5.还可以直接调用最外层的Call,通过C代码可看出:最外层的Call为最安全无检测的Call 6.以及多种技术的交合使用....
——————————————————————————————————————————————————————————————————二.Call链call链可能大家没怎么听说过,因为这并不是一个比较广泛的方法,最早由加壳软件“ZProtect”的作者想到的一种专门应对Call指令的加密方法,现在一些游戏也采用了该技术。call链的思想在于,在一个正常的PE程序中可以找到很多的Call指令,用Ollydbg操作如图所示:
我们前面讲过执行一条CALL指令相当于执行一条PUSH指令加一条JMP指令,那么这里的PUSH其实就是Call指令在调用子程序时会将Call指令后面的地址压入栈顶,因此我们就可以同时抽取许多不同的Call指令,让其相互调用,最后根据压入栈的返回地址在事先保存的原始Call指令的目标地址表中找到Call指令的原始目标地址,从而进入这个目标地址。
该处的实验可以参考看雪大佬《软件保护及分析技术——原理与实践》一书:
构建如下代码:
这是一个Cll链,当所有的Call指令被执行完之后,栈中的数据如图所示:
根据入栈的顺序和数量,可以找出最初调用的Call指令,然后转入最初目标地址。当代码中有许多Call指令经过这样的处理之后,会对静态分析工具(例如IDA)产生巨大的困扰。
对于该技术的利弊众说纷纭,有人觉得不错于是在Call链的基础上增添Stub技术,加大anti能力,有人觉得不好,降低了效率还浪费了精力,精力有限,鄙人并未对该技术做深层次的测试,更多的知识还是请参考《软件保护及分析技术——原理与实践》
关于对抗的方式:
由于该方法没有做代码的混淆,仅仅是通过复杂的调用使其产生Call链来增加IDA等静态分析的能力,因此,在此基础之上,如果时间充裕,可以尝试一下或者采用动态分析。
——————————————————————————————————————————————————————————————————三.堆栈检测
假设有一款游戏,我们找到了其攻击Call,通过攻击Call我们便可以实现对怪物的攻击,函数结构简单表示如下:
攻击Call( 怪物对象 , 技能威力)
{
xxxxxxx
}当我们在进行正常的攻击时,正常的流程如下:
鼠标点击怪物 -> 函数A -> 攻击Call -> 组包 -> 发包如果我们正常的点击怪物,进行攻击,执行的是正常的流程,Call的返回地址必然存在于游戏模块中,如果非游戏模块调用,则会检测,为此,我们可以自写代码去测试一下:
#include <stdio.h> #include <Windows.h> int a, b; void add(){ //利用变量a进行校验 a = a + b; //printf("这里是函数addn"); } //游戏攻击Call内层-实现攻击 void Attack_2(){ MessageBox(NULL, "我是Attack2", "cap", MB_OK); } //游戏攻击Call外层-实现攻击 void Attack_1(){ add(); Attack_2(); MessageBox(NULL, "合法调用MessageBox", "cap", MB_OK); //每次更新校验值 a = 1; } int main(){ a = 1, b = 2; Attack_1(); getchar(); return 0; }大家发现,我们现在在main函数就开始调用Attack1函数,然后我们把程序直接拖进OD已知:0042F080 /> 55 push ebp ; attack10042F110 55 push ebp ; attack2我们直接在attack2函数地址下断,然后在OD中运行程序,按照程序自己去执行,堆栈是这个样子的,红色圈的部分:
如果我们外部用代码注入器调用观察一下,堆栈又会是什么样子呢?
由此可见,外挂如果由外部调用Call,在堆栈的数据中将显露无疑,与正常调用有天壤之别!
因此我们可以为我们的C语言的程序加入堆栈检测,在此,我们拿函数的“返回地址”为例在讲之前我们要先了解一下栈的结构,熟悉函数调用堆栈,先观察,用实践出真知:正常调用观察:
继续F8,出Call
非法调用观察,发现 ebp+4的位置为我们的返回地址 :
继续F8,出Call, 发现ebp+4的位置也为我们的返回地址
这是为什么呢?因为这跟栈的结构相关,详细参考:/p/ae36dcb29ad4至少我们通过实践得知,ebp + 4地址中的数据为我们的返回地址
接下来,我们配合内联汇编对我们之前的代码进行改进:改进后,我们对attack2函数加入了返回地址的读取,读取后,我们便可以通过读取“返回地址”是否为“游戏模块地址”:因为程序仅有一个模块,所以,我们就通过PE文件结构获取基地址以及模块大小范围,其和为返回地址的校验合法区间
#include <stdio.h> #include <Windows.h> int ret1, ret2; DWORD addr_Module, size_Module; //游戏攻击Call内层-实现攻击 void Attack_2(){ __asm { mov ret1, ebp mov eax,[ebp+4] mov ret2,eax } printf("ebp:0x%x [ebp+4]: 0x%xn 模块起始地址:0x%x 模块大小范围:%x n", ret1,ret2,addr_Module,size_Module); if (ret2 < addr_Module || ret2 > (addr_Module + size_Module)) MessageBox(NULL, "非法调用", "cap", MB_OK); else MessageBox(NULL, "合法调用", "cap", MB_OK); } //游戏攻击Call外层-实现攻击 void Attack_1(){ Attack_2(); MessageBox(NULL, "合法调用MessageBox", "cap", MB_OK); } int main(){ DWORD hModule = GetModuleHandle(NULL); IMAGE_DOS_HEADER *dosHeader = (IMAGE_DOS_HEADER *)hModule; IMAGE_NT_HEADERS *ntHeaders = addr_Module = (IMAGE_NT_HEADERS *)((DWORD)hModule + dosHeader->e_lfanew); DWORD dwImageSize = size_Module = ntHeaders->OptionalHeader.SizeOfImage; printf("模块基地址:0x%x 模块地址取值大小:%x", ntHeaders,dwImageSize); getchar(); return 0; }由代码可知,我们对attack2函数,通过校验返回地址,对是否由外部调用进行了测试
校验原理:
1.通过PE文件结构编程计算出“模块地址”和“模块大小”,那么 (模块地址) ~ (模块地址 + 模块大小)范围内的 内存地址 均为“合法区间”
2.判断调用的“返回地址”这个 内存地址 是否存在于这个“合法区间内”
3.判断方法为①小于 模块地址 ②大于 模块地址 + 模块大小
4.判断出结果,进行封号180天的操作测试结果:
绕过方式:
既然堆栈中的数据作了校验,那我们就选择伪造堆栈数据即可,其方法就是观察寄存器,hook数据,然后写入合法的数据 当然了,如果大家能够找到关键的跳转,也可以更改跳转,甚至nop,不过一般后面的代码会存在心跳数据包,改了小心追封哦对于该种堆栈检测,是非常灵活的,我们甚至还可以对之前的寄存器进行保存,压入堆栈,进行检测,而非单单检测返回地址总之,攻防无绝对,没有绝对的安全
——————————————————————————————————————————————————————————————————四.线程环境检测1.动手实践了解线程和PEB的知识如果有朋友跟着实验进行测试了,心细的朋友便会注意到我们在合法调用和非法调用Call时线程的问题:如果让程序自己调用attack2(),该函数位置下断,那么线程情况是这样的:
同时点击T,查看进程的线程情况:
如果我们外部非法调用,是这样的:
同时点击T,查看进程的线程情况,这条多出来的线程清晰可见:
跳转到该线程地址,该地址直接进行了attack2这个Call的调用:
这些都是我们通过肉眼能够观察到的,但是我们现在不能仅仅拘泥于表象,还要钻进去看原理。关于线程的知识:进程的实现往往要通过线程去配合,微软也为我们提供了获取当前线程的API函数:GetCurrentThreadId(),但是其知识简单的介绍一下使用的方法,对如何实现的并未提及。
为此,我们可以通过Ollydbg去查看该函数,通过汇编了解其实现原理,CTRL + G跳转到函数位置:
766E2B18 > 64:A1 18000000 mov eax,dword ptr fs:[0x18] ; GetCurrentThreadId
766E2B1E 8B40 24 mov eax,dword ptr ds:[eax+0x24]
766E2B21 C3 retn
在这里,我们可以看到FS这个段寄存器,查看地址为0x7efd0000,我们在数据窗口中查看,发现0x24的位置便是我们的线程ID:
也就是说, GetCurrentThreadId函数是通过FS段寄存器来实现获取线程的ID的,而FS段寄存器指向的是当前的TEB结构
关于PEB结构:
PEB(Process Environment Block,进程环境块)存放进程信息,每个进程都有自己的PEB信息。位于用户地址空间。在Win 2000下,进程环境块的地址对于每个进程来说是固定的,在0x7FFDF000处,这是用户地址空间,所以程序能够直接访问。准确的PEB地址应从系统 的EPROCESS结构的0x1b0偏移处获得,但由于EPROCESS在系统地址空间,访问这个结构需要有ring0的权限。还可以通过TEB结构的偏 移0x30处获得PEB的位置,FS段寄存器指向当前的TEB结构:
关于这里更多的知识请参考:Windows核心编程_FS段寄存器: /bjbz_cxy/article/details/80762663一张图带你了解TEB&PEB结构: /archives/3488.htmlPEB和TEB资料整合: /Viwilla/p/5109966.html
2.动手写代码实现提取线程信息,校验调用的线程是否合法根据之前汇编代码的观察,我们可以构造如下函数获取调用时的线程ID
//获取线程ID ULONG CallFlt_GetCurrentThreadId() { ULONG ThreadId; __asm mov eax,fs:[0x24] __asm mov ThreadId,eax return ThreadId; }程序中调用结果:
思路:
1.程序运行时会有很多线程,我们将线程信息记录在表A中
2.根据表A中的内容为依据,在每次调用attack2函数时,检测当前调用线程是否为合法线程
3.是合法线程就通过,不是就封号180
//定义结构体保存线程信息 typedef struct Call_ThreadInfo{ DLIST_ENTRY ListEntry; ULONG ThreadId;//线程ID }CALL_FLT_ENTRY,*PCALL_FLT_ENTRY; typedef struct Call_TABLE { DLIST_ENTRY ListEntryHead; //链表头 ULONG EntryCount; //链表元素个数 CRITICAL_SECTION TableLock;//保存上次的搜索结果 PCALL_FLT_ENTRY LastHit; //保存上次搜索的结果 }CALL_FLT_TABLE,*PCALL_FLT_TABLE; //获取线程ID ULONG CallFlt_GetCurrentThreadId() { ULONG ThreadId; __asm mov eax, fs:[0x24] __asm mov ThreadId, eax return ThreadId; } PCALL_FLT_ENTRY CallFlt_SearchCallFltTable(PCALL_FLT_TABLE CallFltTable, ULONG ThreadId) { PCALL_FLT_ENTRY CallFltEntry; assert(CallFltTable != NULL); if (CallFltTable->LastHit != NULL && //判断上次查找结构是否匹配 CallFltTable->LastHit->ThreadId == ThreadId) return CallFltTable->LastHit; //遍历双向链表 CallFltEntry = (PCALL_FLT_ENTRY)CallFltTable->ListEntryHead->next; while (CallFltEntry != (PCALL_FLT_ENTRY)(&CallFltTable->ListEntryHead)){ if (CallFltEntry->ThreadId == ThreadId) return CallFltEntry; CallFltEntry = (PCALL_FLT_ENTRY)(CallFltEntry->ListEntry->next); } return NULL; } BOOL CallFlt_IsMyThread(PCALL_FLT_TABLE CallFltTable) { ULONG Thread; PCALL_FLT_ENTRY CallFltEntry; assert(CallFltTable != NULL); Thread = CallFlt_GetCurrentThreadId();//获取当前线程ID CallFltEntry = CallFlt_SearchCallFltTable(CallFltTable, Thread); if (CallFltEntry != NULL) //合法线程,返回1,说明是正常调用 return 1; else //非法线程,返回0,说明是非法调用 return 0; }
除了遍历所有线程判断外,还可以绑定主线程,也就是仅判断主线程是否与调用时的线程相等,在这里大家可以展开想象,不做赘述
绕过方法1:
1.GetCurrentThreadId函数位置下断,观察eax,eax中即为调用的线程id 2.当远程调用时hook此处位置,设置线程id为正常id,或者直接ret
绕过方法2:
1.依然是GetCurrentThreadId位置下断 2.回溯找到关键跳转,hook伪造返回值或者直接暴力nop
——————————————————————————————————————————————————————————————————五.其他防护1.心跳包防护:call里面有时会存在封包的发送,如果非法调用,服务器则接收不到消息,则非法调用封号180,跟标志位检测有着异曲同工之妙2.基于代码的防护:代码变形,VM, 代码乱序, 代码寄生, 花指令, 代码抽取( 还有一种代码移位技术并未列出,因为它是比较容易被还原的技术 )代码变的不像样,在逆向回溯call的时候无法进行分析,自然无法调用3.等待带佬补充...0x04:总结
——————————————————————————————————————————————————————————————————在游戏防护与检测中,我们其实一直在与数据打交道,数据也就是我们思考问题的起点:对于攻击的人来说,
曾子曰:“吾日三省吾身——检测的是什么数据?调用什么API能够得到这个数据?它会采用什么方式检测这个数据”对于防护的人来说,
鲁迅说过:“哪些数据是关键的?如何加大该数据的检测?如何抓攻击的人?”其实攻防无绝对,难防的还是人心,人心向善,一切都会很美好~更多知识点有待其他带佬能够补充~
参考资料:《软件保护及分析技术——原理与实践》 ——章立春(看雪论坛id: netsowell )《加密与解密-第四版》——段钢《Windows核心编程_FS段寄存器》/bjbz_cxy/article/details/80762663《一张图带你了解TEB&PEB结构》/archives/3488.html《PEB和TEB资料整合》/Viwilla/p/5109966.html《当前常用的加壳技术》 /thread-47190.htm