前段时间在微信里接触到的一款名为 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 )) { } else { } } }, 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
的使用上,另一个就是在 篡改猴
的使用上,这两者为我处理问题打开了新思路和新方法。不过没想到的是逆向实现了,游戏却索然无味了,还是代码来的乐趣多。希望这篇文章可以帮助到你。