游戏测试 抓包工具实现 H5 游戏的 websocket 协议抓包、解析和发包

碧晓寒枫 · 2023年05月16日 · 8416 次阅读

  最近公司新开了一个 H5 的项目,也需要进行游戏的协议测试,因此要我把之前的工具增加一点功能,需要能够支持 H5 协议,由于之前并没有怎么接触过 H5 的 websocket 协议,简单的了解也仅限于能用 websocket 库的 create_connection 方法与服务器创建 websocket 链接。
  借鉴之前的抓包工具思路,写一个中转代理,一开始的想法是用 websocketserver 来写,创建一个 websocket 服务,监听来自客户端的链接,当接收到客户端的链接之后,再通过 websocket 的 create_connection 方法与服务器创建链接,然后再发挥中转作用,做客户端与服务器之间的桥梁。但后来发现网上并没有太多 websocketserver 的例子,仅有的几个例子,基本都是做聊天的,不是很合适。
  在一开始的想法基础上,试着用 socket 去抓了一下客户端发过来的包,意外的发现,python 的 socket 库是可以接受和发送所有字节流的,跟 HTTP 或者是 websocket,或者是 TCP 没有太大的关系,因此又回到了原来使用 socket 写 agent 中转代理的思路。
  创建一个 socket 实例,绑定一个监听端口,等待客户端发过来的链接请求,当收到客户端发过来的链接请求后,与服务器创建一个 socket 链接,搭建好消息桥梁,之后持续从客户端处接收协议,当收到协议内容后,转发给服务器,然后从服务器处接收协议,当收到返回的协议后,再转发给客户端。一开始什么操作都不做,纯转发,发现游戏是可以正常登录的,客户端和服务器之间的协议内容,也可以正常获取到,那么第一步就实现了。
  既然要实现抓包和发包的功能,那么仅仅实现转发功能,是肯定没办法满足需求的,接下来就要开始第二步:协议的解析和伪造。

  websocket 链接创建之前,三次握手之后还需要额外的发送一次 HTTP 请求,然后才会建立 TCP 信息通道,通过打印抓包的字节流数据,内容如下:
  发送的:

b'GET / HTTP/1.1\r\nHost: 127.0.0.1:10000\r\nConnection: Upgrade\r\nPragma: no-cache\r\nCache-Control: no-cache\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36\r\nUpgrade: websocket\r\nOrigin: http://192.168.80.128\r\nSec-WebSocket-Version: 13\r\nAccept-Encoding: gzip, deflate, br\r\nAccept-Language: zh-CN,zh;q=0.9\r\nSec-WebSocket-Key: HfqclrQqE8qmFS7wD2Oc4g==\r\nSec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n\r\n'

  接收的:

b'HTTP/1.1 101 Switching Protocols\r\nconnection: Upgrade\r\ndate: Tue, 16 May 2023 03:25:37 GMT\r\nsec-websocket-accept: zL9D8bicq14M2ZJV9GVJ/g/KjS4=\r\nserver: Cowboy\r\nupgrade: websocket\r\n\r\n'

  我并不关心这些内容是用来做啥的,因为跟游戏的协议内容没有半毛钱关系,因此看到 HTTP 协议内容,无脑转发就好了,我们真正关心的是游戏协议相关的字节流内容,接下来我们看看游戏协议是怎么收发的。
  首先打开浏览器的开发者工具,选择网络选项,然后启动游戏连接服务器,我们会看到浏览器和服务器之间的 websocket 协议内容

  看到这里一阵狂喜!这正是我想要的字节流内容,根据我们游戏的协议进行解析,这个游戏协议的编号是 2af8 对应的十进制,也就是 11000 号登录协议,感觉一切跟原来写的工具一毛一样,我是不是只要将原来抓包工具的代码 ctrl+c,然后 Ctrl+V,喀喀两下就解决了?结果现实给我泼了一盆冷水。
  我用 python 的 socket 抓了一下客户端发给服务器的协议内容,结果如下:

b'\x82\xc9\x98\x08\x99\xe9\xb2\xf0\x99\x8d\xfb\x1f\x98\xf8~>p\xe9\x9e9\xa8\xd8\xa99\xa8\xe9\x999\x99\xe8\xae\x08\x98\xdf\x98\x08\x99\xe9\x98\x08\x99\xe9\x98\x08\x99\xe9\xb8i\xad\xda\xafn\xac\xdd\xfcm\xae\xdf\xfa=\xfa\x88\xfai\xa9\xdb\xa8:\xad\xd0\xf9=\xac\xda\xae<\xa8\xdb\xab'

  这就是开发者工具中抓到的那条协议,只不过开发者工具中是根据算法还原了的,而我抓到的协议内容,是加密过的。。。由于我的工具并不只是抓包,还需要改包和伪造包,来达到模拟客户端发包的目的,因此接下来的关键就是协议内容的还原和再加密,还原的目的是将协议内容明文化,以便使用人员对其中的字段进行修改,再加密的目的是生成跟客户端一样的加密方式,使服务器能够验证通过。

  通过翻阅 websocket 库的源码以及在网上找一些参考资料,大概了解了它的加密机制,翻看源码的过程就不在这里赘述了,网上的资料主要参考了武(沛齐)老师的帖子,接下来就分析一下它的解密构成,以及如何将加密的字节流还原成我想要的:
  首先从一个字节流中读取 2 位,分别为 b1 和 b2(上面例子中分别对应\x82 和\xc9),然后做一些相应的运算,如下:

b1, b2 = send_data[:2]
opcode = b1 & 0x0f
masked = b2 & 0x80
payload_length = b2 & 0x7f
print(opcode)
print(masked)
print(payload_length)

  运行结果为:opcode=2,masked=128,payload_length=73,其中 opcode 为数据类型,2 为字节流类型,masked 我忘记是啥意思了,payload_length 是字节流长度,对比从开发者工具中抓到的长度,是一致的。

  然后再读取 4 位是 mask key,后面所有的字节流都是通过这 4 个 key 来加密的(源码中这 4 个 key 是通过 os.urandom 方法生成的),这 4 个 key 生成之后,将字节流中的每个字节按照顺序分别与这 4 个 key 做运算,生成新的字节流,就是我们抓到的这个了。
接下来我们来实现将抓到的字节流还原为原始字节流的方法:

def ws_send_data_reduction(data):
    """
    发送协议还原
    将客户端发送的加密后的web socket字节流解析为原始字节流
    :param data: 传入原始数据除去长度的部分,即data[2:4+length]
    :return:
    """
    masks = data[:4]
    message_bytes = bytearray()
    for message_byte in data[4:]:
        message_byte ^= masks[len(message_bytes) % 4]
        message_bytes.append(message_byte)
    return bytes(message_bytes)

  将之前抓到的字节流去除前 2 位,然后传入这个方法,会得到还原后的结果:

data = b'\x82\xc9\x98\x08\x99\xe9\xb2\xf0\x99\x8d\xfb\x1f\x98\xf8~>p\xe9\x9e9\xa8\xd8\xa99\xa8\xe9\x999\x99\xe8\xae\x08\x98\xdf\x98\x08\x99\xe9\x98\x08\x99\xe9\x98\x08\x99\xe9\xb8i\xad\xda\xafn\xac\xdd\xfcm\xae\xdf\xfa=\xfa\x88\xfai\xa9\xdb\xa8:\xad\xd0\xf9=\xac\xda\xae<\xa8\xdb\xab'
print(ws_send_data_reduction(data[2:]))

  运行结果如下:

b'*\xf8\x00dc\x17\x01\x11\xe66\xe9\x00\x06111111\x00\x011\x00\x016\x00\x016\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 a437f54de76b5caba020249a55364123'

  与开发者工具中抓到的数据是一致的,解析完成。

  接下来看加密,还是参考源码和武老师的博客,编写一个加密方法,这个方法传入原始未加密的字节流,输出加密之后的可以被服务器验证的字节流。

def make_ws_send_data(data):
    """
    根据算法生成一个ws包
    :param data:
    :return:
    """
    ws_data = b'\x82'
    length = len(data)
    if length < 126:
        ws_data += chr(128 | length).encode('latin-1')
    elif length < 65535:
        ws_data += chr(128 | 0x7e).encode('latin-1')
        ws_data += struct.pack("!H", length)
    else:
        ws_data += chr(128 | 0x7f).encode('latin-1')
        ws_data += struct.pack("!Q", length)
    mask_key = os.urandom(4)
    return ws_data + mask_key + mask(array.array("B", mask_key), array.array("B", data))

  在封包长度不同时,ws 协议有不同的处理方式,当封包长度小于 126 时,直接写入封包长度,当封包长度介于 126 和 65535 之间时,写入 126,同时将封包长度转成 16 位 H,追加写入到加密后的封包中,当封包长度大于 65535 时,写入 127,同时将封包长度转成 64 位 Q,追加写入到加密的封包中,这样在解密的时候,会首先判断封包长度 payload_length,当 payload_length<126 时,直接读取指定长度,当 payload_length=126 时,读取 2 个字节,解析出实际封包的长度,当 payload_length=127 时,读取 8 个字节,解析出实际封包的长度,接下来我们验证一下:

data = b'*\xf8\x00dc\x17\x01\x11\xe66\xe9\x00\x06111111\x00\x011\x00\x016\x00\x016\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 a437f54de76b5caba020249a55364123'
ws_data = make_ws_send_data(data)
print(ws_data)
print(ws_send_data_reduction(ws_data[2:]))

  运行结果如下:

b'\x82\xc9\x93\x9a.\x89\xb9b.\xed\xf0\x8d/\x98u\xac\xc7\x89\x95\xab\x1f\xb8\xa2\xab\x1f\x89\x92\xab.\x88\xa5\x9a/\xbf\x93\x9a.\x89\x93\x9a.\x89\x93\x9a.\x89\xb3\xfb\x1a\xba\xa4\xfc\x1b\xbd\xf7\xff\x19\xbf\xf1\xafM\xe8\xf1\xfb\x1e\xbb\xa3\xa8\x1a\xb0\xf2\xaf\x1b\xba\xa5\xae\x1f\xbb\xa0'
b'*\xf8\x00dc\x17\x01\x11\xe66\xe9\x00\x06111111\x00\x011\x00\x016\x00\x016\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 a437f54de76b5caba020249a55364123'

  通过运行我们发现,原始字节流在通过 make_ws_send_data 方法后,生成了一个跟我们抓到的内容基本一致的封包,并且这个封包可以通过我们之前写的还原方法还原为原始封包,说明方法是没有问题的。

  接下来只要按照之前的思路来写这个工具就可以了,具体可以参考之前的帖子(https://testerhome.com/topics/33501),只不过在获取到客户端发给服务器的协议的时候,多了一步还原操作和修改之后的重新加密操作。

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册