X袋奇兵 游戏逆向

前段时间在微信里接触到的一款名为 X袋奇兵 小游戏,玩了下还挺上头,不过玩了两天便觉得就有些浪费时间,于是便动起了逆向的心思,实现挂机自动操作,最后发现确实可行,这里简单记录下探索的过程。

总体流程

总的过程可以抽象为以下几部分

  • 源码定位
  • 协议分析
  • 代码翻译

下面就来一一介绍

源码定位

一开始接触到 X袋奇兵 是在微信这个封闭的环境里,要找到它的源码着实有些费劲,于是在网上找了下,发现它是有网页版的。嘿嘿,既然有网页版那一切都好办了。

开启开发者模式,打开它的网页入口,登入游戏,查看网络控制台,观察请求,可以发现有一堆的请求,不过不用慌,一般这种页游和后台会有个长链接进行消息协议交互,查看 Network Ws 一栏会发现两个 Websocket 连接,那么恭喜你找到逆向的钥匙。为什么说找到了钥匙?因为在开发者模式下任何网络请求都可以通过 initiator 找到其调用的 js 源码文件位置。

观察两个 Ws 请求会发现其中一个是消息格式是 text类型的 json, 另外一个则是二进制协议,等待一会儿会发现,json 和频道聊天是对应上的,那么也意味着二进制协议就是对应与游戏后台交互的功能协议,单击二进制连接所在的 initiator 便可以跟踪到源代码,类似如下

1
2
3
4
5
this._webSocket = new WebSocket(this._connectHost),
this.enabledBinaryProtocol && (this._webSocket.binaryType = "arraybuffer"),
this._webSocket.onopen = this.onOpen.bind(this),
this._webSocket.onclose = this.onClose.bind(this),
this._webSocket.onmessage = this.onMessage.bind(this),

此时可以把此代码文件 index.xxxx.js 的文件,通过右键保存至本地进行离线分析。

协议分析

拿捏了消息接收和发送的通道,就可以顺藤摸瓜开始对通信协议进行解析。已知 _webSocket 成员变量,稍微了解下前端就会可以知道,前端的 Wensocket 类型发送函数是 send, 直接在源码里搜 _webSocket.send(,会找到两处而且都是在一个声明函数里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
e.prototype.send = function(e, t) {
var o, a;
if (void 0 === t && (t = !1),
this._states === d.SocketStates.CONNECTED)
try {
if (this.enabledBinaryProtocol) {
var i = null
, n = "JSON";
r.log("(Socket,Debug)", e.c, null === (a = null === (o = e.binaryPack) || void 0 === o ? void 0 : o.body) || void 0 === a ? void 0 : a.format, e._pb_flag),
e._pb_flag ? (i = p.BinaryMsgBuilder.buildBinaryPack(e),
n = "ProtoBuf") : i = p.BinaryMsgBuilder.buildJsonPack(e),
this._webSocket.send(i),
t || u.SocketDebugger.trace(e.c, Number(e.o), e, i.byteLength, u.SocketEventNames.REQ, "(" + n + ") 发送请求")
} else {
var s = JSON.stringify(e);
this._webSocket.send(s),
t || u.SocketDebugger.trace(e.c, Number(e.o), s, s.length, u.SocketEventNames.REQ, "(O) 发送请求")
}
} catch (e) {
r.error(e)
}
}

通过前面对 Ws 消息观察已经知道,消息内容为二进制格式,所以着重分析 enabledBinaryProtocol 中的逻辑,这里需要用到开发者模式中的 log point 故名思意为日志断点,用于打印调试信息,我们在 this._webSocket.send(i), 行对着左边的断点栏添加日志断点,内容为 i, e,再切到 Console 可以看到类似如下的调试内容

1
ArrayBuffer {c: 0, o: '497', p: {…}, _pb_flag: false}

此时我们操作游戏内容,如帮助盟友,查看雷达任务都会触发相关的发送不同的请求,可以确认此处就是发送请求的位置,接着我们需要搞清楚请求是如何被包装成二进制形式的,观察代码会发现发送的数据 i 在上一行被包装处理了,逻辑如下

1
2
3
4
e._pb_flag ? 
(i = p.BinaryMsgBuilder.buildBinaryPack(e), n = "ProtoBuf")
:
i = p.BinaryMsgBuilder.buildJsonPack(e)

这里可以看到 i 会根据事件 _pb_flag 类型不同进行编码

  • true 时使用 p.BinaryMsgBuilder.buildBinaryPack 编码
  • false 时使用 p.BinaryMsgBuilder.buildJsonPack 编码

Console 日志会发现基本所有的事件都 _pb_flag 都是 false,因此直接在源码中 buildJsonPack,会找到如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
return e.buildJsonPack = function(e) {
var t = this.getUtf8Bytes(JSON.stringify(e.p));
return c.BinaryBuffer.createPack({
head: {
c: e.c,
o: Number(e.o)
},
body: {
format: c.BinaryDataPackFormat.JSON,
bytes: t
}
})
}

这段代码逻辑也比较简单易懂,把事件 e 转成 json 变成 utf8 字节,打包进一个有 head body 两部分的数据结构,再通过 c.BinaryBuffer.createPack 编码完成,接着搜这个函数名可以找到如下代码段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
e.createPack = function(e, t) {
var a, r;
void 0 === t && (t = 1);
var n = 0;
if (!e)
return null;
(n = (null === (r = null === (a = e.body) || void 0 === a ? void 0 : a.bytes) || void 0 === r ? void 0 : r.byteLength) || 0) > 0 && (n += 1),
i.DataPackCrypt.crypto(e);
var s = n + o.BINARY_DATA_PACK_HEADER_SIZE
, l = new ArrayBuffer(s * t)
, c = new DataView(l);
if (c.setInt32(0, e.head.c || 0),
c.setInt32(4, e.head.o || 0),
c.setInt32(8, n || 0),
n > 0 && e.body.bytes) {
c.setInt8(12, e.body.format || 0);
for (var d = new DataView(e.body.bytes), u = 0; u < e.body.bytes.byteLength; u++)
c.setUint8(u + 13, d.getUint8(u))
}
return l
}

接着看代码里有一段 i.DataPackCrypt.crypto(e);,OK恭喜你离真相越来越近了,接着搜 crypto 会找到如下函数,为了图方便这里把关联函数 convert 函数也一并附上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
return e.convert = function(e, t) {
var o, a = e.head.o, r = e.body.format, s = new ArrayBuffer(4), l = 16843009 | e.head.o, c = new DataView(s);
c.setInt32(0, l);
var d = e.body.format ^ c.getUint8(0);
if (e.body.format = d,
(null === (o = e.body.bytes) || void 0 === o ? void 0 : o.byteLength) < 1)
return !1;
for (var u = new DataView(e.body.bytes), p = 0; p < e.body.bytes.byteLength; p++) {
var f = (p + 1) % 4
, h = c.getUint8(f)
, _ = u.getUint8(p);
u.setUint8(p, _ ^ h)
}
if ("(加密)" === t && e.body.format > 0 && (5643 === e.head.c || 6001 === e.head.c || 2028 === e.head.c)) {
var m = e.head.c
, y = "dbFormat:" + r + ", dbKey:" + a + ", vaFormat: " + d;
n.log("(SocketHeader) 可能的异常情况 | " + m + " | " + y),
i.trace({
id: m,
data: y,
resp: "可能的异常情况"
}, "SocketHeader")
}
return !0
}
,
e.crypto = function(t) {
if (t.alreadyEncode) {
if (e._codeWarningLog) {
e._codeWarningLog = !1;
var o = new Error;
i.trace({
resp: "意料之外的Socket消息加密请求",
id: t.head.c,
data: o.stack
}, "CodeWarning")
}
return !0
}
var a = e.convert(t, "(加密)");
return t.alreadyEncode = !0,
a
}

至此发送的消息协议 js 代码就全部得到了

代码翻译

然而有 js 代码,看不懂也白搭,在代码翻译这一块儿也耗费了我一两天时间,毕竟不是前端专家,翻译期间找了前端同事,前端同事也直摆手说看着太复杂,我寻思都到这一步了还能放弃吗?不行,在我的摸索下,终于还是让我翻译出来了。

这里直接说答案,借助 AI,把 js 代码放入让 AI 辅助转换成其他语言 有条件的推荐使用 ChatGPT,如果没有条件的也可以用百度的 文言一心,不过经测试效果不如 ChatGPT,但总的来讲还是要比自己去学一遍前端要快上不少。

由于我是用 go 来写挂机脚本所以指定 AI 翻译为 go,微调删减之后可以得到如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import (
"bytes"
"encoding/binary"
"log"
)

type Message struct {
Head struct {
Cmd int32
Order uint32
}
Body struct {
Format uint8
Bytes []byte
}
}

func (m *Message) convert() {
o := m.Head.Order
l := uint32(16843009 | o)
c := make([]byte, 4)
binary.BigEndian.PutUint32(c, l)
u := m.Body.Bytes
for p := range u {
f := (p + 1) % 4
u[p] = u[p] ^ c[f]
}
}

func (m *Message) Encode() []byte {
var n uint32
r := m.Body.Bytes
if r == nil {
n = 0
} else {
n = uint32(len(r))
}
if n > 0 {
n += 1
}
m.convert()
l := new(bytes.Buffer)
binary.Write(l, binary.BigEndian, m.Head.Cmd)
binary.Write(l, binary.BigEndian, m.Head.Order)
binary.Write(l, binary.BigEndian, n)
binary.Write(l, binary.BigEndian, m.Body.Format)
for _, v := range m.Body.Bytes {
binary.Write(l, binary.BigEndian, v)
}
return l.Bytes()
}

上述代码包含了 buildJsonPack 里打包的代码,可以完美模拟页面的请求操作。

OK,模拟请求已经可以做到了,那么响应呢,钥匙还在 _webSocket,在上面源码定位章节里,可以找到 this._webSocket.onmessage = this.onMessage.bind(this), 这意味着我们搜 onMessage 即可找到消息接收的处理,类似如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
e.prototype.onMessage = function(e) {
if (!d.SocketCore.TEST_BLOCK_RECEIVE)
if (e.data instanceof ArrayBuffer)
this._delegate && this._delegate.queue && this._delegate.queue.add(e.data);
else {
var t = e.data
, o = null;
try {
var a = e.receiveTime || (new Date).getTime();
o = JSON.parse(t),
f.ProtoBufBiz.instance.isDisable(o.c) && p.BinaryMsgBuilder.opJsonPack(o),
o.c != this._ignoreMsgCode && this._delegate && this._delegate.onReceive && this._delegate.onReceive(o, t.length, a),
this._delegate && this._delegate.onMessage && this._delegate.onMessage.call(this._delegate.target, o, a)
} catch (e) {
r.error(e)
}
}
}

关于这部分功能的解析大家可以仿照上面发送的流程分析,这里就不再赘述了

移花接木

协议分析了,代码也翻译了,就来到了同样重要的一步,如何实现脚本功能?这里我采用服务代理的方式,把自己变成一个 websocket 服务接收转发网页的请求,同时针对服务推送的消息做自动化处理,中间加一些定时操作,实现完全的挂机处理的能力。嗯,这部分大家按自己需求自行实现。

本节主要讲如何侵入页面改写接入的服务器,毕竟网页是别人的,你要做一些小动作肯定是要动页面的,这里提供两个思路:

  • 编写 http 代理服务,在请求响应间改写关键数据, 这种适合多人共享的情况下,其他人只用访问篡改后的页面就行
  • 篡改猴 篡改 ajax 的服务器连接的响应,这种简单轻便,适合自娱自乐

很明显我只是自娱自乐,所以我用了第二种方式,安装 篡改猴 插件,新建一个脚本,下面是一个针对 ajax 请求拦截处理的代码,理论上适用其他站点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(function (open) {
XMLHttpRequest.prototype.open = function () {
this.addEventListener("readystatechange", function () {
if (this.readyState === XMLHttpRequest.DONE) {
const status = this.status;
if (status === 0 || (status >= 200 && status < 400)) {
// The request has been completed successfully
// 侵入代码
} else {
// Oh no! There has been an error with the request!
}
}
}, false);
open.apply(this, arguments);
};
})(XMLHttpRequest.prototype.open);

在侵入代码部分我的逻辑如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const u = new URL(this.responseURL)
if (u.pathname === "/appServerListServlet") {
console.log(this);
const reslut = JSON.parse(this.responseText)
console.log("will connect url", reslut.url)
const url = new URL(reslut.url)
const n = "ws://127.0.0.1:8080" + url.pathname
reslut.wssLinesCN = reslut.wssLinesCN.replace(reslut.url, n)
reslut.appUrl = n
reslut.url = n
Object.defineProperty(this, 'response', { writable: true });
Object.defineProperty(this, 'responseText', { writable: true });
this.response = this.responseText = JSON.stringify(reslut);
}

这里的逻辑主要是拦截 /appServerListServlet的响应内容, 并替换其中的连接地址为本机地址,OK自此一套完整的链路就打通了。

写在最后

整个研究分析的过程大概是三四天吧,出于好奇与兴趣研究了下,没想到也有不小的收获,一个是在 AI 的使用上,另一个就是在 篡改猴 的使用上,这两者为我处理问题打开了新思路和新方法。不过没想到的是逆向实现了,游戏却索然无味了,还是代码来的乐趣多。希望这篇文章可以帮助到你。