通过发包函数来定位明文喊话 call 及背包物品使用 call
网络游戏中的功能都需要客户端通过发包函数来和服务器进行交互才能实现,因此通过截获发包函数就能够截获游戏中的所有功能函数。
要定位发包函数,那我们首先要来看看,Windows 系统下提供了哪些发包函数,一共有三个:
send 发包函数:
int WSAAPI send(
[in] SOCKET s, // 标识连接的套接字描述符
[in] const char *buf, // 指向要传输数据的缓冲区的指针
[in] int len, // 缓冲区的长度(以字节为单位)
[in] int flags // 指定调用方式的标志
);
如果用
send
无法在调试器中定位,可使用ws2_32.send
WSASend 发包函数:
int WSAAPI WSASend(
[in] SOCKET s, // 标识连接的套接字描述符
[in] LPWSABUF lpBuffers, // 一个指向 WSABUF 数组结构的指针,每个 WSABUF 结构包含指向缓冲区的指针和缓冲区的长度
[in] DWORD dwBufferCount, // 在 lpBuffers 数组中 WSABUF 结构的数量
[out] LPDWORD lpNumberOfBytesSent, // 如果I/O操作立即完成,则指向该调用发送的字节数的指针
[in] DWORD dwFlags, // 用于修改 WSASend 函数行为的标志
[in] LPWSAOVERLAPPED lpOverlapped, // 是否是同步通信
[in] LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine // 发送操作完成后调用的完成例程的指针(和同步相关的参数)
);
sendto 发包函数:
int WSAAPI sendto(
[in] SOCKET s, // 标识连接的套接字描述符
[in] const char *buf, // 指向要传输数据的缓冲区的指针
[in] int len, // 缓冲区的长度(以字节为单位)
[in] int flags, // 指定调用方式的标志
[in] const sockaddr *to, // 一个指向 sockaddr 结构,包含目标套接字的地址
[in] int tolen // to 参数指向的地址的大小(以字节为单位)
);
该发包函数游戏中使用的最少。
这三个发包函数都能直接在调试器中进行定位,当然游戏开发者为了提升游戏被逆向的难度,可能会通过一些手法对这三个发包函数进行操作,当然也有一些对应的解决办法:
1、对发包函数进行隐藏。比如在其他模块中重写发包函数,不直接调用上面三个常用的发包函数。
解决办法:
- 对更加底层的发包函数
WSPSend
进行下断。 - 如果只是单纯将这个三个发包函数的汇编代码复制到其他模块进行调用,那么我们可以截取这三个发包函数的特征码到对应的模块中进行搜索,重新实现的发包函数可能在原来的
ws2_32
模块中,也可能在游戏自己的某个模块中。
2、对发包函数进行加密
解决办法:
- 解密该发包函数
3、通过额外的线程来专门处理发包,即线程发包
解决办法:
- 如果用全局变量作为线程发包的缓存,则发包函数的参数
buf
的值不会发生变化,直接通过硬件写入断点
即可跳出发包循环线程。 - 如果
buf
的值一直发生变化,说明发包函数不断从某个共享的内存区域
或缓存队列
中读取封包信息,这种情况我们就需要追溯封包信息的来源,找出这个共享的内存区域
或缓存队列
,然后对其下硬件写入断点
,找到写入封包信息的线程,从而跳出发包循环线程。
上面对发包函数进行了一些介绍,现在大部分网游都采取两种发包模型:游戏逻辑主线程发包
和线程发包
(通过开启独立的发包线程进行发包)。
顾名思义,游戏逻辑主线程发包
是指在游戏逻辑线程中直接调用发包函数,当我们在游戏中调用某个功能的时候,由游戏逻辑线程自己调用发包函数,这种发包模型最容易被逆向出功能 call,我们只需要在发包函数处下断,然后一直按 ctrl+F9
返回即可定位到功能 call 的位置,当然现在这种发包模型已经很少被使用了。
而线程发包
这种模型将发包线程和游戏逻辑线程分离,单独开启一个或几个线程一直循环,从一块固定的内存地址或封包缓存队列中读取封包信息进行发送,这种发包模型中,当我们在 send 处下断,一直按 ctrl+F9
进行返回的时候,会发现返回了几层后游戏就直接开始运行了,而且调用堆栈一直不变,无法跳出发包线程,示意图如下:
那如何区分游戏逻辑主线程发包
和线程发包
呢?线程发包
主要有以下特点:
- 对
buf
下断后会从0
号游戏逻辑主线程断到其他线程的代码中。 - 在发包函数处断下后一直按
ctrl+F9
返回,一般返回几层后程序就直接跑起来了,说明该线程一直处于循环之中。 - 无论调用什么功能,线程发包的调用堆栈都是不变的,因此可以通过调用堆栈识别。
- 由于发包线程处于不断循环之中,因此对发包线程进行下断后会断的比较频繁。
- 当我们跳出发包线程后,可以看线程窗口中是否已经切换到游戏逻辑主线程(一般是
0
号线程)。
一、笑傲江湖寻找喊话 call(x32)
1 通过游戏逻辑线程寻找喊话功能 call
这款游戏是通过 send
来进行发包的,因此我们对 send 下断,然后在游戏中进行喊话断下:
我们可以看到这里包长为 1,说明这是一个心跳包(可以通过条件断点过滤心跳包干扰),其实这款游戏调用发包函数前会发送一个心跳包,这个心跳包是由游戏逻辑主线程发送的,而真正的发包线程不是这个,但是凑巧的该游戏是通过心跳包也可以追溯到相应的功能 call。
在断下后我们依次按 ctrl+F9
,将返回做顺序标记(1、2、3...),同时对栈进行观察,看有没有出现喊话内容明文,对栈帧中出现明文的 call 重点关注。
一般顺序标记做到 7-10 即可。
由于这个返回路线是喊话 call 定位出来的,通常靠近发包函数的前几个 call 是通用的,也就是所有功能 call 都会经过,我们需要对这几个进行过滤,我们依次在前几个返回的 call 处下断点,然后再游戏中进行除了喊话以外的其他动作,只要断下就说明是公用的 call。
通过上述方法,我们排除了前三个返回的 call,可以直接从第四个返回 call 开始分析,但是为了拓展练习,我们还是从第一层开始分析:
00CD03FC | A1 68A3D601 | mov eax,dword ptr ds:[1D6A368] |
00CD0401 | 53 | push ebx |
00CD0402 | 6A 01 | push 1 |
00CD0404 | 68 7E6B3201 | push xajh.1326B7E |
00CD0409 | 50 | push eax |
00CD040A | E8 C3783600 | call <JMP.&send> | 1 send 下断后第一层返回call,这是一个跳转到发包函数的 call,肯定不是功能 call
00CD040F | EB 07 | jmp xajh.CD0418 |
继续返回第二层:
00CD0497 | 51 | push ecx |
00CD0498 | 52 | push edx |
00CD0499 | 8BC8 | mov ecx,eax |
00CD049B | E8 D0FEFFFF | call xajh.CD0370 | 2 通过观察第二层call返回的栈,没有发现明文内容
继续返回第三层:
00CD050F | 52 | push edx |
00CD0510 | 8B5424 0C | mov edx,dword ptr ss:[esp+C] |
00CD0514 | 52 | push edx |
00CD0515 | 50 | push eax |
00CD0516 | E8 35FFFFFF | call xajh.CD0450 | 3 第三层返回call
00CD051B | 5E | pop esi |
00CD051C | C2 0800 | ret 8 |
第三层返回 call 的栈中发现了明文,但是经测试该call只有三个参数,而明文在第四个位置,说明这是本层函数体的局部变量,是由上一层的函数体传进来的,执行 call xajh.CD0450 前的堆栈情况如下:
0019DA54 00000002
0019DA58 0019DA94
0019DA5C 00000000
0019DA60 576FCA74 L"1111111111111"
0019DA64 00CFD286 返回到 xajh.00CFD286 自 xajh.00CD04D0
继续返回第四层:
00CFD271 | 6A 00 | push 0 |
00CFD273 | 8D4424 28 | lea eax,dword ptr ss:[esp+28] |
00CFD277 | 50 | push eax |
00CFD278 | 8BCF | mov ecx,edi |
00CFD27A | 899424 90000000 | mov dword ptr ss:[esp+90],edx |
00CFD281 | E8 4A32FDFF | call xajh.CD04D0 | 4
经测试第四层返回的 call 就两个参数,而这两个参数不包含明文(推测此处可能是加密后的包内容)。
继续往上返回第五层:
00958A29 | 51 | push ecx |
00958A2A | 8B4C24 38 | mov ecx,dword ptr ss:[esp+38] |
00958A2E | 52 | push edx |
00958A2F | 8B5424 18 | mov edx,dword ptr ss:[esp+18] |
00958A33 | 52 | push edx |
00958A34 | 51 | push ecx | ecx:"柚cO8磡X"
00958A35 | 8BC8 | mov ecx,eax |
00958A37 | E8 04463A00 | call xajh.CFD040 | 5 第五层的第二个参数看到了喊话明文
执行 call xajh.CFD040 前的堆栈情况如下:
$ ==> 00000000
$+4 58DB9E3C L"11111111111111111"
$+8 00000000
$+C 00000000
经过测试,ecx
是保持不变的,第二个参数是喊话内容,其他参数为0,接下来我们开辟一块内存空间,填入喊话内容:
0B1B0000:D9 8F 2F 66 00 4E 61 67 4B 6D D5 8B ED 8B E5 53 00 00 这是一条测试语句
然后通过注入汇编代码来测试一下这个功能call是否能够正常执行:
push 0x0
push 0x0
push 0x0B1B0000
push 0x0
mov ecx, 0x258DAF68
call 0xCFD040
可以看到执行成功的结果如下:
接下来为了能够正常的通过程序调用这个 call,我们还需要对 ecx
进行回溯,找到基址将其固定下来:
00958A35 | 8BC8 | mov ecx,eax | 1
009589FA | E8 4164F7FF | call xajh.8CEE40 | 2 eax来源于这个 call
004AE440 | A1 D8825201 | mov eax,dword ptr ds:[15282D8] |
004AE445 | 85C0 | test eax,eax |
004AE447 | 74 04 | je xajh.4AE44D |
004AE449 | 8B40 2C | mov eax,dword ptr ds:[eax+2C] | 3 追踪 eax
004AE44C | C3 | ret |
004AE440 | A1 D8825201 | mov eax,dword ptr ds:[15282D8] | 4 追踪到基址
004AE445 | 85C0 | test eax,eax |
004AE447 | 74 04 | je xajh.4AE44D |
004AE449 | 8B40 2C | mov eax,dword ptr ds:[eax+2C] | 3 追踪 eax
004AE44C | C3 | ret |
很容易就找到了 ecx
的基址为:[[15282D8]+2C]。
2 跳出循环发包线程寻找喊话功能 call
首先在 send
处下断点,喊话断下后的堆栈窗口如下:
06ABFE6C 00D0C156 返回到 xajh.00D0C156 自 ???
06ABFE70 00000CC8
06ABFE74 105B4820
06ABFE78 0000003C
06ABFE7C 00000000
在 105B4820 处下四个字节的硬件写入断点(此时还处在 0
号游戏逻辑主线程中),在游戏中喊话,然后我们发现断在了 4
号线程中,一直 ctrl+F9
返回,没有发现我们之前做的返回标记,返回几层后游戏直接运行起来了,由此可以断定该游戏采用的独立线程发包模型。
我们继续来看 buf
参数,buf
参数为 105B4820,每次游戏重启后这个地址会发生变化,说明不是一个全局变量(经过初始化的全局变量存放在 .data 段中),而是一块临时开辟的空间(可以转到内存布局看到 105B4820 处于临时分配的用户空间中),因此 buf 中的封包内容必定来自于其他内存,我们需要对 buf 中的封包内容进行溯源。
说是溯源,但我们的目的不是要找基址,而是要看 buf 里面的封包内容是来自一个全局变量还是一个封包队列,而游戏逻辑主线程又将封包写入该全局变量或封包队列,这个全局变量或封包队列有一个特点,那就是其地址是不变的,但是里面包含的内容是不断变化的,我们根据这个特点来对其进行定位。
所以我们首先对 buf 写一个硬件写入断点,看是 buf 中的内容是哪里复制过来的,跳过心跳包,发送喊话后,可以看到断在了系统空间:
跳出系统空间:
004932F0 | 8B2D 04862201 | mov ebp,dword ptr ds:[<&memmove>] |
004932F6 | 2BD7 | sub edx,edi |
004932F8 | 52 | push edx |
004932F9 | 8D041F | lea eax,dword ptr ds:[edi+ebx] |
004932FC | 57 | push edi |
004932FD | 50 | push eax |
004932FE | FFD5 | call ebp |
00493300 | 8B4C24 24 | mov ecx,dword ptr ss:[esp+24] |
00493304 | 53 | push ebx |
00493305 | 51 | push ecx |
00493306 | 57 | push edi | 1 buf 地址 edi==107D4F50
00493307 | FFD5 | call ebp |
00493309 | 83C4 18 | add esp,18 | |
可以通过上面代码看到,调用 memmove
函数对 buf 进行了赋值,buf 来源于 ecx
,我们继续追踪 ecx
:
004932FE | FFD5 | call ebp |
00493300 | 8B4C24 24 | mov ecx,dword ptr ss:[esp+24] | 2 ecx 为上一个函数的第二个参数,且 ecx 不断变动
00493304 | 53 | push ebx |
00493305 | 51 | push ecx |
00493306 | 57 | push edi | 1 buf 地址 edi==107D4F50
00493307 | FFD5 | call ebp |
00493309 | 83C4 18 | add esp,18 | |
ecx
为上一个函数的第二个参数,继续往上找 ecx
:
00D089EC | 50 | push eax | 3
00D089DD | 56 | push esi |
00D089DE | FFD0 | call eax |
00D089E0 | 8B46 04 | mov eax,dword ptr ds:[esi+4] | 4 eax 不断变动,而 esi 保持不变
00D089E3 | 8B4E 08 | mov ecx,dword ptr ds:[esi+8] |
00D089E6 | 8B57 20 | mov edx,dword ptr ds:[edi+20] |
00D089E9 | 2BC8 | sub ecx,eax |
00D089EB | 51 | push ecx | 封包大小
00D089EC | 50 | push eax | 3 src
00D089ED | 52 | push edx | dest == buf
00D089EE | 8BCB | mov ecx,ebx |
00D089F0 | E8 CBA878FF | call xajh.4932C0 |
由于 esi
不变,因此 esi
有可能是全局变量或封包缓存的地址,我们对其下硬件写入断点,然后喊话,
在下面代码处断下:
00493A39 | C706 20E22201 | mov dword ptr ds:[esi],xajh.122E220 | 喊话后触发写入断点
00493A3F | 8946 04 | mov dword ptr ds:[esi+4],eax | [esi+4]:"Actx "
00493A42 | 8946 08 | mov dword ptr ds:[esi+8],eax |
00493A45 | 8946 0C | mov dword ptr ds:[esi+C],eax |
此时我们通过线程
窗口发现已经切换到 0 号游戏逻辑主线程,我们按 ctrl+F9
,返回:
00CCF63C | 51 | push ecx |
00CCF63D | 8BCF | mov ecx,edi |
00CCF63F | E8 EC437CFF | call xajh.493A30 | 1
00CD03E4 | 8D5424 10 | lea edx,dword ptr ss:[esp+10] |
00CD03E8 | 52 | push edx |
00CD03E9 | 8D8E B8000000 | lea ecx,dword ptr ds:[esi+B8] |
00CD03EF | E8 FCF1FFFF | call xajh.CCF5F0 | 2
00CD048F | 8B4C24 28 | mov ecx,dword ptr ss:[esp+28] |
00CD0493 | 8B5424 24 | mov edx,dword ptr ss:[esp+24] |
00CD0497 | 51 | push ecx |
00CD0498 | 52 | push edx |
00CD0499 | 8BC8 | mov ecx,eax |
00CD049B | E8 D0FEFFFF | call xajh.CD0370 | 3
00CD050B | 8B5424 0C | mov edx,dword ptr ss:[esp+C] |
00CD050F | 52 | push edx |
00CD0510 | 8B5424 0C | mov edx,dword ptr ss:[esp+C] |
00CD0514 | 52 | push edx |
00CD0515 | 50 | push eax |
00CD0516 | E8 35FFFFFF | call xajh.CD0450 | 4
00CFD271 | 6A 00 | push 0 |
00CFD273 | 8D4424 28 | lea eax,dword ptr ss:[esp+28] |
00CFD277 | 50 | push eax |
00CFD278 | 8BCF | mov ecx,edi |
00CFD27A | 899424 90000000 | mov dword ptr ss:[esp+90],edx |
00CFD281 | E8 4A32FDFF | call xajh.CD04D0 | 5 此处只有喊话会断下
00958A29 | 51 | push ecx |
00958A2A | 8B4C24 38 | mov ecx,dword ptr ss:[esp+38] |
00958A2E | 52 | push edx |
00958A2F | 8B5424 18 | mov edx,dword ptr ss:[esp+18] | [esp+18]:"◤鑖"
00958A33 | 52 | push edx |
00958A34 | 51 | push ecx | ecx:&"0揵"
00958A35 | 8BC8 | mov ecx,eax |
00958A37 | E8 04463A00 | call xajh.CFD040 | 6 参数为明文
六次后就可以看到喊话明文 call 了,此时已经成功跳出线程发包循环:
我们在将
esi
的值转到内存布局,可以看到esi
的值在.rdata
段,说明是写入 PE 文件的基址,而游戏逻辑主线程往 esi+4 写入封包缓存,[esi+8]-[esi+4] 即为封包的长度。
继续分析可以知道,前面四层返回是公用的 call,而第五层的返回 call 只有喊话会断下,而该 call 只有两个参数且非明文,猜测对封包进行了加密操作。
2.1 继续寻找其他 call,以物品使用 call 为例
跳出线程循环后再进行其他功能 call 的寻找就非常容易了,我们对跳出线程循环后返回到的 call 依次进行标记为 1、2、3...,直到第 6 层返回 call 为喊话明文返回 call,然后我们就可以在跳出线程循环后返回的第 1 层 call 处下断点来替代 send 处下断点来寻找其他功能 call。
接下来演示继续寻找使用背包物品 call,我们在第 1 层返回 call 处下一个断点,然后点击背包中的药品,断下后依次返回,我们发现第 1、2、3、4、5 层返回都是和喊话 call 重合的返回 call,说明这是所有功能 call 都会经过的路线,我们过滤掉前五层返回 call,然后对第 6 层返回 call 下断:
00CD187A | 894424 38 | mov dword ptr ss:[esp+38],eax |
00CD187E | E8 4DECFFFF | call xajh.CD04D0 | 6.2 在此处下断来替代 send
00CD1883 | 8B5424 2C | mov edx,dword ptr ss:[esp+2C] |
在游戏中移动角色会断下,说明该层不是使用物品功能 call,继续返回第 7 层 call:
00CC771F | 6A 05 | push 5 |
00CC7721 | 52 | push edx | edx==0019E640
00CC7722 | 66:C74424 08 110 | mov word ptr ss:[esp+8],11 |
00CC7729 | 884424 0A | mov byte ptr ss:[esp+A],al |
00CC772D | 66:894C24 0B | mov word ptr ss:[esp+B],cx |
00CC7732 | E8 096D7EFF | call xajh.4AE440 |
00CC7737 | 8BC8 | mov ecx,eax |
00CC7739 | E8 02A00000 | call xajh.CD1740 | 7.2
断下后我们看到这个 call 只有两个参数,第一个参数为 0019E640
,第二个参数固定为 5
,由于我们使用的是背包中的物品,按照经验来说应该至少会有这个物品在背包中的位置,因此这个不是最佳的 call,继续返回第 8 层:
00558164 | 8B8C24 B4000000 | mov ecx,dword ptr ss:[esp+B4] |
0055816B | 8B9424 B0000000 | mov edx,dword ptr ss:[esp+B0] |
00558172 | 51 | push ecx | ecx 为物品在背包中的位置
00558173 | 52 | push edx | edx 固定为 2
00558174 | E8 97F57600 | call xajh.CC7710 | 8.2
该层确实是使用背包中药品的 call,但是当我们点击背包中的装备进行切换时并不会断下,推测这是一个内存层 call,继续往上返回第 9 层:
0054656E | 51 | push ecx |
0054656F | 52 | push edx |
00546570 | 50 | push eax |
00546571 | 8BCE | mov ecx,esi |
00546573 | E8 98180100 | call xajh.557E10 | 9.2
第 9 层和第 8 层一样只会在使用药品时断下,但是传入的 ecx
寄存器的值可以关注一下,不同药品的值不一样,但是值又很接近,推测应该是药品的代码,那么在另一个同层级的 call 里面应该还有一个装备使用 call,且传入的 ecx
为装备的代码,我们继续返回第 10 层:
0074C190 | 8B16 | mov edx,dword ptr ds:[esi] |
0074C192 | 8B52 20 | mov edx,dword ptr ds:[edx+20] | edx 来源于 [edx+20]
0074C195 | 50 | push eax |
0074C196 | 8B4424 30 | mov eax,dword ptr ss:[esp+30] |
0074C19A | 51 | push ecx |
0074C19B | 50 | push eax |
0074C19C | 8BCE | mov ecx,esi |
0074C19E | FFD2 | call edx | 10.2
第十层调用的是一个虚函数,进一步证实了我们刚刚的推测,这个 call 是根据不同类型的物品种类来调用不同物品的使用 call,我们可以结合不同物品的使用 call 对虚函数 edx
进行分析,找到所有的物品调用 call,但是虚函数不太方便调用,我们继续往上返回第 11 层:
00A5D5CA | 8B4424 18 | mov eax,dword ptr ss:[esp+18] | call 里面没有用到 eax,无用寄存器
00A5D5CE | 8B88 841A0000 | mov ecx,dword ptr ds:[eax+1A84] | ecx==4E9E52A0
00A5D5D4 | 6A 01 | push 1 |
00A5D5D6 | 53 | push ebx | 物品在背包中的位置
00A5D5D7 | 57 | push edi | edi 固定为 2
00A5D5D8 | E8 73E9CEFF | call xajh.74BF50 | 11.2 使用背包物品 call
到这里我们就分析出了背包中所有物品使用的通用 call,我们注入汇编代码进行测试:
push 0x1
push 0x3
push 0x2
mov ecx, 0x4E9E52A0
call 0x74BF50
结果显示能够正常进行装备更换和药品使用,因此我们只需要追溯 ecx
的基址:
00A5D5CA | 8B4424 18 | mov eax,dword ptr ss:[esp+18] | 2 局部变量 [esp+18]==53026AE0
00A5D5CE | 8B88 841A0000 | mov ecx,dword ptr ds:[eax+1A84] | 1 eax==4B1CB890
这里的 [esp+18]
为一个局部变量,我们需要往上看是哪行汇编代码对其进行了写入,我们在 00A5D5CA
地址以及函数头部处下两个断点夹住这个监测区域,然后使用背包药品,当程序在函数头部断下时,我们对 [esp+18]==53026AE0
下一个硬件断点,然后在以下代码处断下:
注:以这种方式监测是哪句汇编代码向
[esp+18]
写入了值,我们要保证esp+18
这个栈地址每次运行都保持不变。
00A5BA80 | E8 7B29A5FF | call xajh.4AE400 |
00A5BA85 | 894424 18 | mov dword ptr ss:[esp+18],eax | 3 eax 的值来源于上面这个 call
至此溯源完成,ecx
==[call xajh.4AE400+1A84]。
注入汇编代码测试:
push 0x1
push 0x3
push 0x2
call 0x4AE400
add eax, 0x1A84
mov ecx, [eax]
call 0x74BF50
经测试能够正常使用调用物品 call。