wx小程序游戏加解密与通信协议的逆向分析
发表于更新于
字数总计:5.7k阅读时长:29分钟 上海
后端后端wx小程序游戏加解密与通信协议的逆向分析
xukun前言
本文章仅为技术研究与学习交流目的,所有功能均基于对游戏协议的分析与探索。研究者应遵守以下原则:
- 合法合规:禁止用于任何商业用途或游戏作弊行为;
- 自我责任:产生的任何风险由使用者自行承担;
- 尊重版权:不得侵犯游戏厂商合法权益。
⚠️ 说明:本文仅用于技术研究与学习,所有操作均在个人实验环境下进行,不涉及生产环境的攻击与绕过。
一、研究背景
小游戏作为典型的小程序形态,用户操作背后会与服务端进行频繁的数据交互。例如登录、数据同步、对战逻辑等。这些交互大多通过 HTTPS 接口 或 WebSocket 长连接 完成。研究通信机制的过程,不仅能帮助开发者理解小程序的架构模式,还能为日常的性能调优、安全防护提供启发。
二、研究工具与准备
工具:
准备工作:
抓包工具上安装抓包证书,确保能够捕获 HTTPS 流量。

获取小程序安装包,得到反编译后的源码中的game.js
核心逻辑文件。

三、抓包与接口分析
通过抓包工具,可以看到小程序启动时会调用一系列接口:
- 登录接口:验证用户身份。
- 资源清单接口:拉取配置与用户数据。
- 用户认证接口:获取用户认证凭证。
- 长连接建立:进入主场景后,通过 WebSocket 与服务器保持实时通信。


从以下几个方面对接口进行分析:
- 请求路径与参数结构
- 响应数据格式(JSON / 二进制)
- 加密或校验机制(如签名、token)
最终我们得到:
登录
https://comb-platform.hortorgames.com/comb-login-server/api/v1/login
Request
params:
Key |
Value |
gameId |
xyzwprod |
gameTp |
minigame |
system |
android |
version |
1.91.1-wx |
deviceUniqueId |
ck42mn8i |
loginTag |
code |
cryptVersion |
1.1.0 |
Body:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| { "mac" : "02:00:00:00:00:00", "tp" : "app-we", "isScan" : true, "gameId" : "xyzwapp", "channel" : "AppStore", "version" : "0.21.0", "idfa" : "00000000-0000-0000-0000-000000000000", "distinctId" : "6AC8F932-2FF9-401E-863F-9CD0582F6C12", "packageName" : "com.hortor.games.xyzw", "code" : "011Scmml2vs3Tf4nQ1ol2Jx0iG3Scmm4", "gameTp" : "app", "sysInfo" : "{\n \"system\" : \"iOS\",\n \"brand\" : \"Apple\",\n \"model\" : \"iPhone12,8\",\n \"hortorSdkVersion\" : \"1.7.11\"\n}", "deviceUniqueId" : "6AC8F932-2FF9-401E-863F-9CD0582F6C12" }
|
Response
body:
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
| { "meta": { "errCode": 0, "errMsg": "success" }, "data": { "combUser": { "encryptCombUser": "wd1XL1mWJJslCa/BG7fW2LOdlr/sRRedLUUznwWDhJo7wm3+3hosWAWJHEVC/WWEDWVw7zJESttmpi7WFEt73GMNST9IW+Q8onY33dUHfuassNnFMUIDPWrg04h6wmNbwoaHCfjNsvNfwkD/5htLIx3boWGtpOGWOJCo00ne/L4YhYF6kNiLp65DOKuANCqnWcL+hf5vGjlNmzri5rljw47epLHnsjmOzqzmMsZQqLqETJB+EhXIRw7jknqY8LBYkoNCRVkwIV2ulAU6gviozvoI1/P5qYYuytHxIwg8NruobJMeGC7tGiQ2IsTsy17y7vsFhq9JFoV6n/iOmDd6Tx2RvxIJokQJ0riVke+ct/K/GFgfYZkkH6Pey3mLGhYbd2J1BxLXS0sVHtkG3DmxyaqiH5kQzCgQFUnKLbd9sDdkFD+8yQwkK45TBYQRpajqko8oq2Vy/70jyDw44/GIKRoWmtwqfbnnfXsUHX2keYxG6RovlA3Leu4viEce1htI327WwNCR7DwIHgkYFSrVZDh9vBUy74VPKrDRX48eWBHeFFY0SGqGZttzShk3O7wTZgmL1IF55agc3z4791sajRdXyq3wTovEGmjFMFA+0EL3UQ6k1G0hpGe2ezsu9hZ8TzyrArRfh65WspJ7hl3KNgRuc1rjHHB8zy1MNRrB9oBCcshsUqtCyUIk5/DUEX5ksOIAzZuXul3+5wFcPEm+9A==", "timestamp": 1756705976, "sign": "8af9d1c32350add03d97729494e1265d" }, "combSdkInfo": { "isAuth": false, "hasUserInfo": false, "userId": "oIRDe5RxjCnd2dPuWJdtAq2RT7gM", "uniqueId": "8fef25ebc1f408e8428179343b5ac377", "channel": "hortor", "origChannel": "toutiaov2", "h_shareCode": "60c0f4e3c7e5a428b9fe4f43c5c8b4c8", "sex": 0, "name": "", "isNewUser": false, "createdAt": 1672053699, "alias": "xyzwprod_entry" }, "envCombSdkInfo": { "isNewUser": false, "isRealName": true, "birthday": "20020325", "channel": "AppStore", "h_shareCode": "54857f3f7af43c80b5b5b18799cf4893", "loginTp": "app-we", "alias": "xyzw_wx", "uniqueId": "05bdfb76b1cfb2940d759e6676f795ac" } } }
|
清单
https://xxz-xyzw.hortorgames.com/login/manifest
Request
params:
Body:
1 2 3 4
| { "platform": "hortor", "version": "1.37.2-wx" }
|
用户认证
https://xxz-xyzw.hortorgames.com/login/authuser
Request
params:
加密后的Body:

Response
加密后的body:

Websocket部分:
连接参数:

发送报文:

响应报文:

通过初步分析,可以得知:登录的encryptCombUser部分以及用户凭证接口的Request、Response都进行了加密,Websocket的请求参数中用到了加密后的roleToken字段,而且发送和响应报文的字节头都以7078开头。
综上,后面分析可以重点从以上几个部分入手。
四、协议逆向思路及分析
小程序往往会对通信数据进行加密或签名。常见的处理方式包括:
- 对称加密(AES 等)保护敏感字段
- 签名校验(MD5/HMAC)防止参数篡改
- 混淆处理(Base64 / Protobuf 等)提高逆向门槛
分析时,可以在反编译后的 game.js
中搜索加解密相关的关键字(如 crypto
、encrypt
、decode
等),并结合抓包内容逐步推断逻辑。
首先,从抓包的登录接口的Response中我们知道encryptCombUser字段是经过加密的。
在game.js
中按 CRTL+F 全文搜索encrypt
关键字

从这段代码中定义了4种加密方式的封装方法,分别是lx、x、xtm以及XXTEA:
1 2 3 4 5 6
| n.Encoding = void 0, (Te = n.Encoding || (n.Encoding = {})).LX = "lx", Te.X = "x", Te.XTM = "xtm", Te.NULL = ""; var Ie = globalThis.XXTEA && new globalThis.XXTEA;
|
1.XXTEA 加密
1
| var Ie = globalThis.XXTEA && new globalThis.XXTEA;
|
- 如果全局环境里存在
XXTEA
(一种常见的对称加密算法),就新建一个实例 Ie
。
- 用于后面
"xtm"
方式的加解密。
2. 加解密函数封装
1 2 3 4 5 6
| function we(e, t) { var n = o.encode(e, !1); return (n = t.encrypt(n)).buffer.byteLength === n.length ? n.buffer : n.buffer.slice(0, n.length) }
|
we
:加密函数
- 先用
o.encode
对数据 e
进行编码;
- 用传入的
t.encrypt
加密;
- 返回 ArrayBuffer。
1 2 3 4
| function Oe(e, t) { var n = t.decrypt(new Uint8Array(e)); return new be(o.decode(n)) }
|
Oe
:解密函数
- 先用
t.decrypt
解密数据;
- 再用
o.decode
还原;
- 最终返回一个
be
对象。
3. 注册加解密方法
1 2 3 4 5
| var Re, Me = new Map;
function Le(e, t) { Me.set(e, t) }
|
Me
是一个 Map
,用来存放不同类型(lx/x/xtm)的加解密算法。
Le
用来注册。
1 2 3 4 5 6 7 8 9 10
| (Re = Le)("lx", {encrypt: E, decrypt: y}), Re("x", {encrypt: R, decrypt: M}), Re("xtm", { encrypt: function (e) { return Ie ? Ie.encryptMod({data: e.buffer, length: e.length}) : e }, decrypt: function (e) { return Ie ? Ie.decryptMod({data: e.buffer, length: e.length}) : e } });
|
"lx"
使用 {encrypt: E, decrypt: y}
;
"x"
使用 {encrypt: R, decrypt: M}
;
"xtm"
使用 XXTEA
的 encryptMod/decryptMod
;
- 不同算法通过
Me
注册。
4. 默认加解密
1 2 3 4 5 6 7 8 9 10 11
| var Ne, De = { encrypt: function (e) { return e.slice() }, decrypt: function (e) { return e.length > 4 && 112 == e[0] && 108 == e[1] ? e = Pe("lx").decrypt(e) : e.length > 4 && 112 == e[0] && 120 == e[1] ? e = Pe("x").decrypt(e) : e.length > 3 && 112 == e[0] && 116 == e[1] && (e = Pe("xtm").decrypt(e)), e } };
|
- 这个方法是一个通用的解密入口。
encrypt
:直接复制数据,不做加密。
decrypt
:根据数据开头的前两个字节判断该用哪种算法:
112,108
→ "lx"
112,120
→ "x"
112,116
→ "xtm"
Pe("xxx")
是一个查找函数,从 Me
中取出对应的加解密方法。
接着根据处理逻辑,跳转到et方法:
这个方法定义了完整的通信框架核心实现,主要功能:
- 数据编码/解码(bon 编码)
- 压缩(lz4)
- 加密(lx / x / xtm / XXTEA / xor 等)
- HTTP 和 WebSocket 通信封装
- 状态机管理(连接、心跳、重连、错误处理)
加密部分
we(e, t)
:对消息 e
进行编码后,用 t.encrypt
加密,返回 ArrayBuffer
。
Oe(e, t)
:对 ArrayBuffer
进行解密解码。
registerEncryptor / getEncryptor
:注册不同加密算法:
"lx"
→ LZ4 压缩 + 随机异或编码
"x"
→ 简单异或编码
"xtm"
→ XXTEA 加密
""
→ 不加密(默认)
通信封装
- HTTP 请求 (
HttpRequest
, HttpDelegate
)
- 封装了
XMLHttpRequest
,统一处理超时、错误、headers。
sendAsync
支持二进制发送和 ArrayBuffer
接收。
- WebSocket (
WebSocketClient
, WebSocketDelegate
, WebSocketDelegateAutoRecover
)
- 通过状态机维护连接状态(Idle, Connecting, Running, Reconnecting, Error)。
- 心跳机制 (
heartbeatInterval
/heartbeatTimeout
) 保持连接活跃。
- 自动重连逻辑。
消息处理
ReceiveMsgImpl (be)
:封装服务端返回的消息,提供:
cmd
、seq
、resp
、ack
等属性;
getData()
自动把 body
解码成对象。
defaultSender
:全局默认发送器,提供:
send
(WebSocket)
sendAsync
(WebSocket + Promise 回调)
sendHttpAsync
(HTTP)
sendAuto
:自动根据参数选择 HTTP 或 WS 发送。
五、代码编写
从js中得到了协议的相关信息后,我们将加解密用到的部分转为熟悉的Java语言实现。
首先是LZ4压缩工具,最开始我在maven仓库中引入了lz4的pom依赖。
1 2 3 4 5
| <dependency> <groupId>org.lz4</groupId> <artifactId>lz4-java</artifactId> <version>1.8.0</version> </dependency>
|

然后发现此依赖中的压缩方法与Js中的压缩方法有区别,会报错,所以我把js中的压缩方法提取出来重构了一版,如下:
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 52 53 54 55 56 57
| import net.jpountz.lz4.LZ4FrameInputStream; import net.jpountz.lz4.LZ4FrameOutputStream;
import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException;
public class LZ4Utils { public static byte[] decompressLZ4(byte[] data) { if (data == null || data.length == 0) { return new byte[0]; }
try (ByteArrayInputStream bais = new ByteArrayInputStream(data); LZ4FrameInputStream lz4Input = new LZ4FrameInputStream(bais)) {
ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] buffer = new byte[512]; int read; while ((read = lz4Input.read(buffer)) != -1) { baos.write(buffer, 0, read); } return baos.toByteArray();
} catch (Exception e) { try { byte[] data2 = new byte[data.length]; CustomLz4Decompressor.decompressFrame(data, data2); return data2; } catch (Exception ex) { System.err.println(ex); return data; } } }
public static byte[] compressLZ4(byte[] data) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (LZ4FrameOutputStream lz4Out = new LZ4FrameOutputStream(baos)) { lz4Out.write(data); } return baos.toByteArray(); } }
|
压缩模块准备好了之后接下来编写Decoder/Encoder模块:
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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map;
public class GlobalDecoder {
private DataReader dr;
private List<String> strArr = new ArrayList<>();
public GlobalDecoder(byte[] data) { this.dr = new DataReader(data); this.strArr.clear(); }
public Object decode() throws IOException { int typeCode = dr.readUInt8();
switch (typeCode) { case 1: return dr.readInt32(); case 2: return dr.readInt64(); case 3: return dr.readFloat32(); case 4: return dr.readFloat64(); case 5: String str = dr.readUTF(); strArr.add(str); return str; case 6: return dr.readUInt8() == 1; case 7: int len7 = dr.read7BitInt(); return dr.readBytes(len7); case 8: int mapSize = dr.read7BitInt(); Map<String, Object> map = new HashMap<>(); for (int i = 0; i < mapSize; i++) { Object k = decode(); Object v = decode(); map.put(String.valueOf(k), v); } return map; case 9: int arrLen = dr.read7BitInt(); List<Object> list = new ArrayList<>(); for (int i = 0; i < arrLen; i++) { list.add(decode()); } return list; case 10: long timestamp = dr.readInt64(); return new java.util.Date(timestamp); case 99: int index = dr.read7BitInt(); if (index < 0 || index >= strArr.size()) return ""; return strArr.get(index); default: return null; } } }
|
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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153
| import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.util.*;
public class GlobalEncoder { private final DataWriter dw = new DataWriter(); private final Map<String, Integer> strMap = new HashMap<>();
public void reset() { dw.reset(); strMap.clear(); }
public byte[] getBytes(boolean copy) { byte[] arr = dw.toByteArray(); return copy ? Arrays.copyOf(arr, arr.length) : arr; }
public void encode(Object val) throws IOException { if (val == null) { encodeNull(); return; }
if (val instanceof Number) { encodeNumber((Number) val); } else if (val instanceof Boolean) { encodeBoolean((Boolean) val); } else if (val instanceof String) { encodeString((String) val); } else if (val instanceof byte[]) { encodeBinary((byte[]) val); } else if (val instanceof List<?>) { writeUInt8(9); List<?> list = (List<?>) val; dw.write7BitInt(list.size()); for (Object o : list) { encode(o); } } else if (val instanceof Map<?, ?>) { writeUInt8(8); Map<?, ?> map = (Map<?, ?>) val; dw.write7BitInt(map.size()); for (Map.Entry<?, ?> entry : map.entrySet()) { encode(entry.getKey().toString()); encode(entry.getValue()); } } else if (val instanceof Date) { encodeDateTime((Date) val); } else { encodePojo(val); } }
private void writeUInt8(int v) throws IOException { dw.writeUInt8(v); }
public void encodeNull() throws IOException { writeUInt8(0); }
public void encodeBoolean(Boolean b) throws IOException { writeUInt8(6); writeUInt8(b ? 1 : 0); }
public void encodeString(String s) throws IOException { if (strMap.containsKey(s)) { writeUInt8(99); dw.write7BitInt(strMap.get(s)); } else { writeUInt8(5); dw.writeUTF(s); strMap.put(s, strMap.size()); } }
public void encodeNumber(Number n) throws IOException { if (n instanceof Integer || n instanceof Short || n instanceof Byte) { encodeInt(n.intValue()); } else if (n instanceof Long) { encodeLong(n.longValue()); } else if (n instanceof Float) { encodeFloat(n.floatValue()); } else if (n instanceof Double) { encodeDouble(n.doubleValue()); } else { encodeDouble(n.doubleValue()); } }
public void encodeInt(int val) throws IOException { writeUInt8(1); dw.writeInt32(val); }
public void encodeLong(long val) throws IOException { writeUInt8(2); dw.writeInt64(val); }
public void encodeFloat(float val) throws IOException { writeUInt8(3); dw.writeFloat32(val); }
public void encodeDouble(double val) throws IOException { writeUInt8(4); dw.writeFloat64(val); }
public void encodeBinary(byte[] val) throws IOException { writeUInt8(7); dw.write7BitInt(val.length); dw.writeUint8Array(val, 0, val.length); }
public void encodeDateTime(Date date) throws IOException { writeUInt8(10); dw.writeInt64(date.getTime()); }
private void encodePojo(Object obj) throws IOException { Map<String, Object> map = pojoToMap(obj); writeUInt8(8); dw.write7BitInt(map.size()); for (Map.Entry<String, Object> entry : map.entrySet()) { encode(entry.getKey()); encode(entry.getValue()); } }
private static Map<String, Object> pojoToMap(Object obj) { Map<String, Object> result = new LinkedHashMap<>(); Class<?> clazz = obj.getClass(); for (Field field : clazz.getDeclaredFields()) { if (Modifier.isStatic(field.getModifiers())) continue; if (!Modifier.isPublic(field.getModifiers())) field.setAccessible(true); try { result.put(field.getName(), field.get(obj)); } catch (Exception ignore) {} } return result; } }
|
接着是3中加密算法:
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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178
| import java.io.IOException; import java.util.Arrays; import java.util.Random;
import static com.ikun.xyzw.config.LZ4Utils.compressLZ4; import static com.ikun.xyzw.config.LZ4Utils.decompressLZ4;
public class Crypto { private static final Random RANDOM = new Random();
public static byte[] decryptLX(byte[] data) { if (data.length < 4) { return data; } if (data[0] != 112 || data[1] != 108) { return data; }
int key = (((data[2] >> 6) & 1) << 7) | (((data[2] >> 4) & 1) << 6) | (((data[2] >> 2) & 1) << 5) | (((data[2] >> 0) & 1) << 4) | (((data[3] >> 6) & 1) << 3) | (((data[3] >> 4) & 1) << 2) | (((data[3] >> 2) & 1) << 1) | (((data[3] >> 0) & 1) << 0);
byte[] result = Arrays.copyOf(data, data.length);
int n = Math.min(result.length, 100); for (int i = 2; i < n; i++) { result[i] ^= key; }
result[0] = 0x04; result[1] = 0x22; result[2] = 0x4D; result[3] = 0x18;
return decompressLZ4(result); }
public static byte[] decryptX(byte[] data) { if (data == null || data.length < 4) { return data; }
if (data[0] != 112 || data[1] != 120) { return data; }
int key = (((data[2] >> 6) & 1) << 7) | (((data[2] >> 4) & 1) << 6) | (((data[2] >> 2) & 1) << 5) | (((data[2] >> 0) & 1) << 4) | (((data[3] >> 6) & 1) << 3) | (((data[3] >> 4) & 1) << 2) | (((data[3] >> 2) & 1) << 1) | (((data[3] >> 0) & 1) << 0);
byte[] result = Arrays.copyOf(data, data.length);
for (int i = result.length - 1; i >= 4; i--) { result[i] ^= key; }
return Arrays.copyOfRange(result, 4, result.length); }
public static byte[] encryptX(byte[] data) { int randomId = RANDOM.nextInt();
byte[] result = new byte[data.length + 4]; result[0] = (byte)(randomId & 0xFF); result[1] = (byte)((randomId >> 8) & 0xFF); result[2] = (byte)((randomId >> 16) & 0xFF); result[3] = (byte)((randomId >> 24) & 0xFF);
System.arraycopy(data, 0, result, 4, data.length);
int key = 2 + RANDOM.nextInt(248);
for (int i = result.length - 1; i >= 0; i--) { result[i] ^= key; }
result[0] = 112; result[1] = 120;
result[2] = (byte)((170 & result[2]) | ((key >> 7 & 1) << 6) | ((key >> 6 & 1) << 4) | ((key >> 5 & 1) << 2) | ((key >> 4 & 1) << 0));
result[3] = (byte)((170 & result[3]) | ((key >> 3 & 1) << 6) | ((key >> 2 & 1) << 4) | ((key >> 1 & 1) << 2) | ((key >> 0 & 1) << 0));
return result; }
public static byte[] encryptLX(byte[] data) throws IOException { if (data == null || data.length == 0) { return new byte[0]; }
byte[] compressed = compressLZ4(data);
int key = 2 + RANDOM.nextInt(248);
int n = Math.min(compressed.length, 100); for (int i = 0; i < n; i++) { compressed[i] ^= key; }
if (compressed.length >= 4) { compressed[0] = 112; compressed[1] = 108;
compressed[2] = (byte)((170 & compressed[2]) | (((key >> 7) & 1) << 6) | (((key >> 6) & 1) << 4) | (((key >> 5) & 1) << 2) | (((key >> 4) & 1) << 0));
compressed[3] = (byte)((170 & compressed[3]) | (((key >> 3) & 1) << 6) | (((key >> 2) & 1) << 4) | (((key >> 1) & 1) << 2) | (((key >> 0) & 1) << 0)); }
return compressed; }
}
|
字节流的读写工具:
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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
| import java.io.EOFException; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Arrays;
public class DataReader { private byte[] data; private int position;
public DataReader(byte[] data) { this.data = data; this.position = 0; }
private boolean validate(int length) { return position + length <= data.length; } public int readUInt8() throws IOException { if (!validate(1)) throw new EOFException("read eof"); return data[position++] & 0xFF; }
public short readInt16() throws IOException { if (!validate(2)) throw new EOFException("read eof"); int b0 = data[position++] & 0xFF; int b1 = data[position++] & 0xFF; return (short) (b0 | (b1 << 8)); }
public int readInt32() throws IOException { if (!validate(4)) throw new EOFException("read eof"); int b0 = data[position++] & 0xFF; int b1 = data[position++] & 0xFF; int b2 = data[position++] & 0xFF; int b3 = data[position++] & 0xFF; return (b0) | (b1 << 8) | (b2 << 16) | (b3 << 24); }
public long readInt64() throws IOException { if (!validate(8)) throw new EOFException("read eof"); long b0 = readInt32() & 0xFFFFFFFFL; long b1 = readInt32() & 0xFFFFFFFFL; return b0 + (b1 << 32); }
public float readFloat32() throws IOException { int bits = readInt32(); return Float.intBitsToFloat(bits); }
public double readFloat64() throws IOException { long b0 = readInt64(); return Double.longBitsToDouble(b0); }
public int read7BitInt() throws IOException { int result = 0; int shift = 0;
while (true) { if (shift >= 35) throw new IOException("7-bit int too long"); int b = readUInt8(); result |= (b & 0x7F) << shift; if ((b & 0x80) == 0) break; shift += 7; } return result; }
public String readUTF() throws IOException { int length = read7BitInt(); return readUTFBytes(length); }
public String readUTFBytes(int length) throws IOException { if (length == 0) return ""; if (!validate(length)) throw new EOFException("read eof"); String s = new String(data, position, length, StandardCharsets.UTF_8); position += length; return s; }
public byte[] readUint8Array(int length, boolean copy) throws IOException { if (!validate(length)) throw new EOFException("read eof"); byte[] result; if (copy) { result = Arrays.copyOfRange(data, position, position + length); } else { result = Arrays.copyOfRange(data, position, position + length); } position += length; return result; }
public byte[] readBytes(int size) throws IOException { return readUint8Array(size, true); } }
|
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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
| import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets;
public class DataWriter { private final ByteArrayOutputStream out = new ByteArrayOutputStream();
public byte[] toByteArray() { return out.toByteArray(); }
public void writeUInt8(int v) throws IOException { out.write(v & 0xFF); }
public void writeInt8(int v) throws IOException { writeUInt8(v); }
public void writeInt16(int v) throws IOException { out.write(v & 0xFF); out.write((v >> 8) & 0xFF); }
public void writeInt32(int v) throws IOException { out.write(v & 0xFF); out.write((v >> 8) & 0xFF); out.write((v >> 16) & 0xFF); out.write((v >> 24) & 0xFF); }
public void writeInt64(long v) throws IOException { writeInt32((int) (v & 0xFFFFFFFFL)); writeInt32((int) ((v >> 32) & 0xFFFFFFFFL)); }
public void writeFloat32(float v) throws IOException { writeInt32(Float.floatToIntBits(v)); }
public void writeFloat64(double v) throws IOException { writeInt64(Double.doubleToLongBits(v)); }
public void write7BitInt(int value) throws IOException { while ((value & 0xFFFFFF80) != 0L) { out.write((value & 0x7F) | 0x80); value >>>= 7; } out.write(value & 0x7F); }
public void writeUTF(String s) throws IOException { byte[] bytes = s.getBytes(StandardCharsets.UTF_8); write7BitInt(bytes.length); out.write(bytes); }
public void writeUint8Array(byte[] data, int off, int len) throws IOException { out.write(data, off, len); }
public void reset() { out.reset(); } }
|
小游戏实时对战、状态同步等场景通常依赖 WebSocket 进行通讯,具体步骤:
- 连接建立:抓取
ws://
或 wss://
的连接请求,观察握手数据。
- 消息格式:拆解每一条消息,区分心跳包、业务逻辑包。
- 数据解析:对照源码逻辑,理解消息的编码方式(JSON、二进制、序列化协议)。
Websocket部分代码:
配置类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer {
private final RoleWebSocketHandler roleWebSocketHandler;
public WebSocketConfig(RoleWebSocketHandler roleWebSocketHandler) { this.roleWebSocketHandler = roleWebSocketHandler; }
@Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry .addHandler(roleWebSocketHandler, "/ws/connect") .setAllowedOrigins("*"); } }
|
实现类(参考):

注意:Websocket的连接时需要遵循游戏当中的心跳机制,否则会导致通信中断。
心跳机制(服务器每5秒发一次_sys/ack,需要维护从0开始的ack,断开后重置):
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| Map<String, Object> hb = new HashMap<>(); hb.put("ack", ack); hb.put("cmd", "_sys/ack"); hb.put("time", timestamp); byte[] hbData = encodeAndEncryptX(hb); scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() -> { if (session.isOpen()) { session.getAsyncRemote().sendBinary(ByteBuffer.wrap(hbData)); } else { scheduler.shutdown(); } }, 0, 5, TimeUnit.SECONDS);
|
编写测试类,将抓包的请求体储存为bin文件:
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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135
| import com.google.gson.Gson;
import java.nio.file.Files; import java.nio.file.Paths; import java.util.Map;
import static com.ikun.xyzw.crypto.Crypto.encryptLX; import static com.ikun.xyzw.crypto.Crypto.encryptX; import static com.ikun.xyzw.decoer.Decoder.decode; import static com.ikun.xyzw.encoder.Encoder.encode;
public class LZ4Decode { static Gson gson = new Gson();
public static void main(String[] args) throws Exception {
byte[] data = Files.readAllBytes(Paths.get("src/main/java/com/ikun/cron/data/test.bin"));
data = encodeReplaceSeq(data, 2);
Object result = decryptLXAndDecode(data); System.out.println(gson.toJson(result)); String resultBody = gson.toJson(result); Map<String, Object> resultMap = (Map<String, Object>)result; Object body = resultMap.get("body"); if (body instanceof byte[]) { Map<String, Object> bodyDecoded = (Map<String, Object>)decode((byte[])body); resultMap.put("body", bodyDecoded); System.out.println(result); } String response = AuthUserTool.authUser(resultBody); System.out.println("AuthUser 接口返回:" + response);
}
public static byte[] encodeReplaceSeq(byte[] data, int seq) { Object result; try { result = decryptLXAndDecode(data); if (result == null) { return null; } } catch (Exception e) { return null; } Map<String, Object> resultMap = (Map<String, Object>)result;
resultMap.put("seq", seq);
try { return encodeAndEncryptLX(resultMap); } catch (Exception e) { return null; } }
public static Object decryptAndDecode(byte[] data, String method) throws Exception { if (data == null || data.length == 0) { throw new IllegalArgumentException("empty data"); }
byte[] decrypted; switch (method) { case "LX": decrypted = Crypto.decryptLX(data); break; case "X": decrypted = Crypto.decryptX(data); break; default: decrypted = data; break; }
return decode(decrypted); }
public static byte[] encodeAndEncrypt(Object value, String method) throws Exception { byte[] data = encode(value, true);
switch (method) { case "LX": return encryptLX(data); case "X": return encryptX(data); default: return data; } }
public static Object decryptLXAndDecode(byte[] data) throws Exception { return decryptAndDecode(data, "LX"); }
public static Object decryptXAndDecode(byte[] data) throws Exception { return decryptAndDecode(data, "X"); }
public static byte[] encodeAndEncryptX(Map<String, Object> value) throws Exception { return encodeAndEncrypt(value, "X"); }
public static byte[] encodeAndEncryptLX(Map<String, Object> value) throws Exception { return encodeAndEncrypt(value, "LX"); }
}
|
这里提供一个python代码的16进制HEX转bin文件实现:
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
| import re
def hex_to_bin(hex_string: str, output_path: str): tokens = hex_string.split() data = bytearray()
for t in tokens: t = re.sub(r'[^0-9A-Fa-f]', '', t) if not t: continue if len(t) == 1: t = '0' + t if len(t) != 2 or not re.fullmatch(r'[0-9A-Fa-f]{2}', t): raise ValueError(f"无效的 hex token: {t}") data.append(int(t, 16))
with open(output_path, 'wb') as f: f.write(data) print(f"已生成二进制文件: {output_path}")
if __name__ == '__main__': hex_str = """ 7078bf507471797618150f0815121f08352849584a3d3f443a454f4e514e3a3a4551484c4d3951444a4f3a51453f384c49444e3a4a3f4d4e79770c1d1f171d1b19321d111979691f13115214130e08130e521b1d11190f520405060b79781f131819795c4c4d4d2f1f1111104e0a0f4f281a48122d4d13104e36044c153b4f2f1f111148797a1b1d1119280c797f1d0c0c797f111d1f796d4c4e464c4c464c4c464c4c464c4c464c4c797a150f2f1f1d127a7d797a1b1d11193518797b0405060b1d0c0c797b0a190e0f151312797a4c524e4d524c797b0f050f35121a13791a07765c5c5e0f050f0819115e5c465c5e15332f5e50765c5c5e1e0e1d12185e5c465c5e3d0c0c10195e50765c5c5e11131819105e5c465c5e152c141312194d4e50445e50765c5c5e14130e08130e2f18172a190e0f1513125e5c465c5e4d524b524d4d5e7601797e080c797a1d0c0c510b19797b1f141d1212191079743d0c0c2f08130e19797815181a1d79584c4c4c4c4c4c4c4c514c4c4c4c514c4c4c4c514c4c4c4c514c4c4c4c4c4c4c4c4c4c4c4c797218190a151f192912150d091935181f7d """ hex_to_bin(hex_str, "output.bin")
|
登录连接效果:

转换后的JSON串:
“resp”:”ack”: 2, “time”: 1756713779261,”cmd”: “Arena_GetAreaTargetResp”,”body”: {..},正好对应js中的编码格式。
控制台输出的json数据即为使用Websocket解密后的服务器响应信息,我们看到的游戏中的功能交互按钮就是用来将json串解析到页面展示出来的。
六、总结
研究过程虽然复杂,但收获颇丰。未来还可以继续研究小程序的反外挂机制以及安全防护策略。