游戏外挂按制造难度总共分为下面三类:
1、模拟式:通过调用 Windows API 来控制鼠标键盘等,使游戏中的人物进行流动或攻击。优点是实现较为简单,周期短,涉及技术面小。缺点是功能不多,较为单一。按键精灵就是其中的代表。
2、内存式:通过修改游戏的关键数据和代码或者非法调用游戏内部的 call,来实现一系列功能。相对第一种功能大大增加,再加上以内存数据为依托,能达到更广泛和精准的控制。这种外挂可以快速提升你对内存地址的理解和运用,是编程技术提升的好帮手。难点在于定位需要的功能 call 和追踪数据。
3、封包式:基于客户端和服务器的数据包通信,通过给服务器发送或者拦截封包,来实现游戏功能。这类外挂的缺点是涉及技术面比前两者更为广泛,开发周期长。优点是所实现的功能强大到难以想象,而且可以无视绝大部分游戏检测,足以弥补对时间上的消耗。
封包式功能的实现步骤
1、定位到游戏的发包函数
2、通过发包函数定位到明文发包函数
3、通过明文发包函数定位到封包加密函数
4、复制整个封包加密函数到自己的dll
5、组包调用游戏功能
整个过程看似简单,实则困难重重,下面就通过一个例子来复现整个过程。
这里用来进行分析的游戏是幻想神域 链接如下:
链接:https://pan.baidu.com/s/1kGAzv8hTV3EylXKWchpmKA 提取码:evc8
自己搭建的一个私服,无论游戏有没有更新都可以跟着步骤操作,随时复现。按照文件中的视频教程搭建即可。
定位发包函数
三大发包函数
在网络游戏中,客户端和服务器的通信基于一系列的数据包。每个数据包都类似于一条指令,客户端和服务器在这个系列指令中完成指定动作。
客户端要与服务器进行通信,必须调用下面的三个发包函数发送数据包
代码语言:javascript复制send();
sendto();
WSASend();
那么我们只要在这三个函数下断点,然后进行堆栈回溯分析,就能准确定位关键的函数调用链。在这条链上,快速排查出需要的功能 call。
不过,发包函数在下断点的时候,可能会碰到下面两个棘手的问题:
1、明明对 send() 函数下断了,缺断不下来
2、由于游戏中存在一个发包线程,所以即使断下 send() 函数,也无法回溯出有用的逻辑
幻想神域就是第二种情况,属于线程发包。
重写发包函数
对于第一个问题,是因为游戏设计者知道发包函数的重要性,重写了一份发包函数。对于这种情况有两种解决方案
1、寻找 send() 函数内调用的底层函数,对底层函数下断。
send sendto 和 WSASend 在底层都会调用一个函数叫 WSPSend,F7 进入 send 函数,第三个调用的 call 就 WSPSend 函数。
2、搜索 send 函数的特征,定位到重写的 send 函数
线程发包
接下来解决第二个问题,游戏单独起了一个线程进行发包
线程发包的形态和特点
1、发包函数断的很频繁
2、任何功能在发包函数断下,调用堆栈都是一样的
由于线程发包是在游戏内部用一个死循环不断的发送数据包,其中包括心跳包,所以会出现发包函数断的很频繁的问题,这就导致无法在我们想要的时机断下,不利于调试。我们需要先解决频繁断下的问题。
条件断点筛出心跳包
幻想神域这个游戏的发包函数的 WSASend(),首先来了解一下这个函数参数的含义
代码语言:javascript复制int WSAAPI WSASend(
SOCKET s,
LPWSABUF lpBuffers,
DWORD dwBufferCount,
LPDWORD lpNumberOfBytesSent,
DWORD dwFlags,
LPWSAOVERLAPPED lpOverlapped,
LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
唯一有用的参数: lpBuffers: 指向 WSABUF 结构体的指针,存储的是包长和包内容
代码语言:javascript复制typedef struct _WSABUF {
ULONG len; //包长
CHAR *buf; //包内容
} WSABUF, *LPWSABUF;
接着打开游戏,用 OD 附加,在 WSASend 函数下断,程序断下
查看一下第二个参数lpBuffers,数据包长度为1E,我们可以以数据包长度为限制条件在这个地方下条件断点,条件如下:
[[esp 8]]!=1E
如果有多个心跳包可以用与的方式进行过滤
[[esp 8]]!=11&&[[esp 8]]!=7
通过条件断点的方式,就可以解决发包函数频繁断下的问题。
线程发包的传参方式
游戏想要单独开一个线程进行发包,必然要用一个地址作为参数传递给发包线程。
第一个线程将发包内容写入地址,第二个线程从这个地址中读取发包内容。这个地址会有两种形式,一种是不变的,从正向代码的角度看就是用全局变量传递,伪代码如下:
代码语言:javascript复制LPVOID g_addr=0;
functions()
{
//给地址赋值
g_addr=xxxxx;
//创建线程发包
CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)send, g_addr, 0, 0);
}
另外一种是动态变化的,从正向代码的角度看就是用堆空间传递,伪代码如下:
代码语言:javascript复制functions()
{
//申请堆空间
wchar_t* lpaddr=new wchar_t[sizof(buff)];
//将包内容拷贝到堆空间
memcpy(lpaddr,buff,sizof(buff))
//创建线程发包
CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)send, lpaddr, 0, 0);
}
跳出线程发包的核心思路就是要找到是谁将发包内容写入。也就是找到上面的 memcpy 的位置。
在 WSASend 函数下断,查看一下 pBuffers 的地址。这个地方的地址是一直变化的,应该是用的堆空间的方式来传递参数。
如果这个地址是不变的,说明是用的全局变量来传递参数。不变的情况下直接在这个地址下写入断点就能跳出发包线程了。
现在这个地址是每次都动态变化的,所以我们需要往上追到这个地址的来源,然后对地址的来源下写入断点,跳出发包线程。
跳出线程发包
首先需要找到包的来源,在 WSASend 函数下断。
eax 是 pBuffers 的结构体地址,而 eax 来源于 [esp 0x28]
经过这一个 push,堆栈地址发生改变,包长 = esp 24
包地址 = esp 28
,而 esp 24
来自 eax,那么 eax 就等于包长
再经过上面几个 push,包地址 = esp 18
,继续追 esp 18
而 esp 18
来自 ecx,包地址 = ecx,继续追 ecx
ecx 的值来自 [edx esi]
,edx 的值断下后为 0,那么包地址就等于 esi,继续追 esi
esi 来自 [ebx 4]
,而 ebx 来自 [edi 2888]
,那么包地址就等于 [[edi 2888] 4]
在这个地方下个断点,可以发现 edi 的地址是不变的。这个时候就没有必要往上追了。
接着我们在 [[edi 2888] 4]
的地址下硬件写入断点,找到往这个地址写入数据的地方
断点断下以后,eax = [edi 2888]
,是被写入数据的地址,包内容 = [eax 4]
我们需要判断这个地方是在发包线程内还是线程外。
判断的方法是对比 WSASend 和找到地址的调用堆栈。
我们发现两个调用堆栈的地址是相同的,说明还没有跳出发包线程。需要继续追 eax 的来源然后下写入断点。
eax 来自 [ebx 8]
,ebx 来自 edx,而 edx 的地址是不变的,包内容 = [[edx] 8] 4]
,直接在 edx 下写入断点
断到了第二次断下的位置
这个时候再查看调用堆栈,返回地址都是游戏主模块,明显这次我们跳出了线程发包函数
定位加密封包内容
接着我们需要在这个函数内找到加密的封包内容,之前的包内容偏移如下:
包内容= [[edx] 8] 4]
对比之前追的偏移表达式,这个地方就是将 ebp 写入到 [eax]
,[eax]
其实就相当于包内容表达式的 [edx]
,所以
加密的封包内容就等于 [ebp 8] 4]
那么怎么验证这个地方就是加密的封包内容呢?直接对比 WSASend 的 pBuffers 和 [ebp 8] 4]
的数据内容
这两个地方是一致的,那么说明 [ebp 8] 4]
就是加密的封包内容。接下来测试一下能不能通过跳出的发包线程找到游戏的喊话 call。在第二次断下的位置下断点
然后在游戏内喊话,断下以后在堆栈中的返回地址,我们找到了当前的喊话内容,说明这个 call 就是喊话 call
线程发包总结
1、对于重写发包函数的问题,只需要在三个发包函数的底层函数下断或者搜索内层的特征码即可找到游戏重写的发包函数
2、对于线程发包的问题,需要找到数据包的地址来源,然后对基地址下写入断点。重复这个过程,即可跳出线程发包函数。
定位明文发包函数
定位到了加密的封包位置以后,我们再来找明文发包 call。
在游戏内进行喊话,内容为三个 1
在加密的封包内容处下断点,喊话让游戏断下,并且在堆栈中找到第一个返回地址
分析这个 call 的相关参数, esi 是一个结构体指针
0
的位置指向的是一个虚函数表
4
的位置里面有我们的喊话内容 3 个 1,这个可能就是我们要的明文发包函数了
为了进一步确认,把这个地方的内容修改为 222,F9 运行
喊话的内容也修改成了 222,说明这个就是我们要的明文发包 call。
定位封包加密 call
我们在加密封包处下断点,第一层返回地址找到了明文发包函数,那么封包的加密 call 肯定就在中间。
在明文发包函数下个断点,F7 进入函数并单步跟踪,上面所有的跳转都执行了,上面 4 个 call 没有执行的机会
然后在单步不过这个 call 的时候,喊话的内容被加密了。这个有可能就是加密 call。
为了进一步确认,将断点断到加密封包内容处,查看 [[ebp 8] 4]
地址处的值,和之前的内容一致,说明这个 call 就是我们要的封包加密 call
封包加密 call 参数分析
首先来看 eax,eax 地址指向的值每次都是变化的,对于加密函数来说,为了让密文每次都变得不一样,一个有效的方法就是让秘钥变的随机。这个 eax 加密 call 的秘钥
eax 往上追可以追出一个偏移表达式,这里省略追秘钥的过程,直接给出表达式
[[[00f84ba4] 4] 0xC 8] 54
edi 是一个数值,可能是包长
我们在 WSASend 函数下断,查看一下,和上面的 edi 是一样的。那么 edi 就是包长 -2
。
封包分为两部分:前两个字节是包的头部,头部往后才是封包数据。
这个参数的含义其实就是要加密的内容长度,-2
是因为要减掉封包头部的长度。
ebp 和 ebx 可以用同样的方法论证得出是包地址 2
。也就是要加密的数据地址, 2
是为了不加密封包头。
到此,封包加密 call 的参数就分析完成了
复制封包加密函数
到这里,只剩下最后一步,将封包加密函数整个复制到自己的 dll 代码中并修改,就能彻底脱离游戏代码了。修改后的代码如下:
代码语言:javascript复制__declspec(naked) void Encrypt(DWORD key,DWORD len,DWORD addr1,DWORD addr2)
{
__asm
{
push ebp
push ebx
push esi
push edi
mov edi, dword ptr [esp 0x14]
mov edx, dword ptr [esp 0x18]
mov esi, dword ptr [esp 0x1C]
mov ebp, dword ptr [esp 0x20]
xor eax, eax
xor ebx, ebx
cmp edx, 0
je Label1
mov al, byte ptr [edi]
mov bl, byte ptr [edi 4]
add edi, 8
lea ecx, dword ptr [esi edx]
sub ebp, esi
mov dword ptr [esp 0x18], ecx
inc al
cmp dword ptr [edi 0x100], -1
je Label2
mov ecx, dword ptr [edi eax*4]
and edx, 0xFFFFFFFC
je Label3
lea edx, dword ptr [esi edx-4]
mov dword ptr [esp 0x1C], edx
mov dword ptr [esp 0x20], ebp
Label4:
add bl, cl
mov edx, dword ptr [edi ebx*4]
mov dword ptr [edi ebx*4], ecx
mov dword ptr [edi eax*4], edx
add edx, ecx
inc al
and edx, 0x0FF
mov ecx, dword ptr [edi eax*4]
mov ebp, dword ptr [edi edx*4]
add bl, cl
mov edx, dword ptr [edi ebx*4]
mov dword ptr [edi ebx*4], ecx
mov dword ptr [edi eax*4], edx
add edx, ecx
inc al
and edx, 0x0FF
ror ebp, 8
mov ecx, dword ptr [edi eax*4]
or ebp, dword ptr [edi edx*4]
add bl, cl
mov edx, dword ptr [edi ebx*4]
mov dword ptr [edi ebx*4], ecx
mov dword ptr [edi eax*4], edx
add edx, ecx
inc al
and edx, 0x0FF
ror ebp, 8
mov ecx, dword ptr [edi eax*4]
or ebp, dword ptr [edi edx*4]
add bl, cl
mov edx, dword ptr [edi ebx*4]
mov dword ptr [edi ebx*4], ecx
mov dword ptr [edi eax*4], edx
add edx, ecx
inc al
and edx, 0x0FF
ror ebp, 8
mov ecx, dword ptr [esp 0x20]
or ebp, dword ptr [edi edx*4]
ror ebp, 8
xor ebp, dword ptr [esi]
cmp esi, dword ptr [esp 0x1C]
mov dword ptr [ecx esi], ebp
lea esi, dword ptr [esi 4]
mov ecx, dword ptr [edi eax*4]
jb Label4
cmp esi, dword ptr [esp 0x18]
je Label5
mov ebp, dword ptr [esp 0x20]
Label3:
add bl, cl
mov edx, dword ptr [edi ebx*4]
mov dword ptr [edi ebx*4], ecx
mov dword ptr [edi eax*4], edx
add edx, ecx
inc al
and edx, 0x0FF
mov edx, dword ptr [edi edx*4]
xor dl, byte ptr [esi]
lea esi, dword ptr [esi 1]
mov ecx, dword ptr [edi eax*4]
cmp esi, dword ptr [esp 0x18]
mov byte ptr [ebp esi-1], dl
jb Label3
jmp Label5
Label2:
movzx ecx, byte ptr [edi eax]
Label6:
add bl, cl
movzx edx, byte ptr [edi ebx]
mov byte ptr [edi ebx], cl
mov byte ptr [edi eax], dl
add dl, cl
movzx edx, byte ptr [edi edx]
add al, 1
xor dl, byte ptr [esi]
lea esi, dword ptr [esi 1]
movzx ecx, byte ptr [edi eax]
cmp esi, dword ptr [esp 0x18]
mov byte ptr [ebp esi-1], dl
jb Label6
Label5:
dec al
mov byte ptr [edi-4], bl
mov byte ptr [edi-8], al
Label1:
pop edi
pop esi
pop ebx
pop ebp
retn
}
}
调用函数实现功能
接着我们在代码中调用加密函数,然后发送封包来实现喊话功能。
这里是直接用的组装好的喊话分包,至于分包要如何分析,如何组装,这个我们后面再讨论。示例代码如下:
代码语言:javascript复制void :SendAnnounce()
{
byte a[100] = {0x11,0x00,0x7E,0x00,0x00,0x00,0x00,0x02,0x00,0x31,0x31,0xFF,0xFF,0xFF,0xFF,0x00,0x00,0x00,0x00,0x60,0xA8,0x6C};
DWORD datalen = 0x13;
DWORD data = (DWORD)a;
DWORD addr = data 2;
DWORD addrlen = datalen - 2;
DWORD key = 0;
__asm
{
mov ecx,0x00f84ba4
mov ecx,[ecx]
mov ecx,[ecx]
mov ecx,[ecx 0x4]
mov ecx,[ecx 0x14]
mov ecx,[ecx]
lea ecx,[ecx 0x54]
mov key,ecx
}
Encrypt(key,addrlen,addr,addr);
HWND hWnd =FindWindowA("Lapis Network Class",0);
DWORD A = GetWindowLongW(hWnd,-21);
DWORD S =*(DWORD*)(A 0x38);
send(S,(const char*)data,datalen,0);
}
到此,我们已经一步步完整复现了从内存挂到封包挂的进化过程。实现效果如图: