wx小程序游戏加解密与通信协议的逆向分析

前言

本文章仅为技术研究与学习交流目的,所有功能均基于对游戏协议的分析与探索。研究者应遵守以下原则:

  1. 合法合规:禁止用于任何商业用途或游戏作弊行为;
  2. 自我责任:产生的任何风险由使用者自行承担;
  3. 尊重版权:不得侵犯游戏厂商合法权益。

⚠️ 说明:本文仅用于技术研究与学习,所有操作均在个人实验环境下进行,不涉及生产环境的攻击与绕过。

一、研究背景

小游戏作为典型的小程序形态,用户操作背后会与服务端进行频繁的数据交互。例如登录、数据同步、对战逻辑等。这些交互大多通过 HTTPS 接口WebSocket 长连接 完成。研究通信机制的过程,不仅能帮助开发者理解小程序的架构模式,还能为日常的性能调优、安全防护提供启发。

二、研究工具与准备

工具:

准备工作:

  1. 抓包工具上安装抓包证书,确保能够捕获 HTTPS 流量。

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

    image-20250901135014072

    三、抓包与接口分析

    通过抓包工具,可以看到小程序启动时会调用一系列接口:

    • 登录接口:验证用户身份。
    • 资源清单接口:拉取配置与用户数据。
    • 用户认证接口:获取用户认证凭证。
    • 长连接建立:进入主场景后,通过 WebSocket 与服务器保持实时通信。

    image-20250901135458741

    image-20250901135520003

    从以下几个方面对接口进行分析:

    • 请求路径与参数结构
    • 响应数据格式(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:

    Key Value
    _seq 1

    Body:

    1
    2
    3
    4
    {
    "platform": "hortor",
    "version": "1.37.2-wx"
    }

    用户认证

    https://xxz-xyzw.hortorgames.com/login/authuser

    Request

    params:

    Key Value
    _seq 1

    加密后的Body:

    image-20250901141200273

    Response

    加密后的body:

Websocket部分:

连接参数:

image-20250901141652195

发送报文:

image-20250901141755749

响应报文:

image-20250901141814792

通过初步分析,可以得知:登录的encryptCombUser部分以及用户凭证接口的Request、Response都进行了加密,Websocket的请求参数中用到了加密后的roleToken字段,而且发送和响应报文的字节头都以7078开头。

综上,后面分析可以重点从以上几个部分入手。

四、协议逆向思路及分析

小程序往往会对通信数据进行加密或签名。常见的处理方式包括:

  • 对称加密(AES 等)保护敏感字段
  • 签名校验(MD5/HMAC)防止参数篡改
  • 混淆处理(Base64 / Protobuf 等)提高逆向门槛

分析时,可以在反编译后的 game.js 中搜索加解密相关的关键字(如 cryptoencryptdecode 等),并结合抓包内容逐步推断逻辑。

首先,从抓包的登录接口的Response中我们知道encryptCombUser字段是经过加密的。

game.js中按 CRTL+F 全文搜索encrypt关键字

image-20250901142824536

从这段代码中定义了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" 使用 XXTEAencryptMod/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方法:image-20250901150453351

这个方法定义了完整的通信框架核心实现,主要功能:

  • 数据编码/解码(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 加密
    • "" → 不加密(默认)

通信封装

  1. HTTP 请求 (HttpRequest, HttpDelegate)
    • 封装了 XMLHttpRequest,统一处理超时、错误、headers。
    • sendAsync 支持二进制发送和 ArrayBuffer 接收。
  2. WebSocket (WebSocketClient, WebSocketDelegate, WebSocketDelegateAutoRecover)
    • 通过状态机维护连接状态(Idle, Connecting, Running, Reconnecting, Error)。
    • 心跳机制 (heartbeatInterval/heartbeatTimeout) 保持连接活跃。
    • 自动重连逻辑。

消息处理

  • ReceiveMsgImpl (be):封装服务端返回的消息,提供:
    • cmdseqrespack 等属性;
    • 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>

image-20250901152934168

然后发现此依赖中的压缩方法与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: // Map
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: // Array
int arrLen = dr.read7BitInt();
List<Object> list = new ArrayList<>();
for (int i = 0; i < arrLen; i++)
{
list.add(decode());
}
return list;
case 10: // DateTime
long timestamp = dr.readInt64();
return new java.util.Date(timestamp);
case 99: // String ref
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 {
// POJO → Map
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 {
// BigInteger, BigDecimal fallback
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());
}

// 结构体/POJO编码
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());
}
}

// 反射转Map(仅导出字段)
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;
}

// 提取 key
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);

// XOR 解密
int n = Math.min(result.length, 100);
for (int i = 2; i < n; i++)
{
result[i] ^= key;
}

// 替换 LZ4 头部(Magic number)
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;
}

// 提取 key
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);

// 异或解密(从尾到头,跳过前 4 字节)
for (int i = result.length - 1; i >= 4; i--)
{
result[i] ^= key;
}

// 返回去掉前4个字节的有效负载
return Arrays.copyOfRange(result, 4, result.length);
}

public static byte[] encryptX(byte[] data)
{
// 1. 随机生成一个 4 字节的 ID
int randomId = RANDOM.nextInt(); // 等价 rand.Uint32()

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);

// 2. 复制原始数据
System.arraycopy(data, 0, result, 4, data.length);

// 3. 随机 key (2 ~ 249)
int key = 2 + RANDOM.nextInt(248);

// 4. 整体 XOR 加密
for (int i = result.length - 1; i >= 0; i--)
{
result[i] ^= key;
}

// 5. 设置固定头部标识(加密后的内容会被覆盖)
result[0] = 112; // 'p'
result[1] = 120; // 'x'

// 6. 将 key 编码到 result[2], result[3]
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];
}

// 1. LZ4压缩
byte[] compressed = compressLZ4(data);

// 2. 生成密钥:范围 2~249
int key = 2 + RANDOM.nextInt(248);

// 3. 前100字节异或加密
int n = Math.min(compressed.length, 100);
for (int i = 0; i < n; i++)
{
compressed[i] ^= key;
}

// 4. 写入标记字节 + key位信息
if (compressed.length >= 4)
{
compressed[0] = 112; // 'p'
compressed[1] = 108; // 'l'

// 高4位 -> 第2字节
compressed[2] = (byte)((170 & compressed[2]) |
(((key >> 7) & 1) << 6) |
(((key >> 6) & 1) << 4) |
(((key >> 5) & 1) << 2) |
(((key >> 4) & 1) << 0));

// 低4位 -> 第3字节
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 {
// Go 的实现是用两个 int32 模拟的,Java 可直接读 8 字节
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); // Java 无法避免复制
}
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 进行通讯,具体步骤:

  1. 连接建立:抓取 ws://wss:// 的连接请求,观察握手数据。
  2. 消息格式:拆解每一条消息,区分心跳包、业务逻辑包。
  3. 数据解析:对照源码逻辑,理解消息的编码方式(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("*");
}
}

实现类(参考):

image-20250901154325928

注意: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文件:

image-20250901161922743
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); // 对应 _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:
# 去掉两端意外的空白或逗号等非 0-9A-F 字符
t = re.sub(r'[^0-9A-Fa-f]', '', t)
if not t:
continue
# 单字符前面补 0,双字符保持不变
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参数
hex_str = """
7078bf507471797618150f0815121f08352849584a3d3f443a454f4e514e3a3a4551484c4d3951444a4f3a51453f384c49444e3a4a3f4d4e79770c1d1f171d1b19321d111979691f13115214130e08130e521b1d11190f520405060b79781f131819795c4c4d4d2f1f1111104e0a0f4f281a48122d4d13104e36044c153b4f2f1f111148797a1b1d1119280c797f1d0c0c797f111d1f796d4c4e464c4c464c4c464c4c464c4c464c4c797a150f2f1f1d127a7d797a1b1d11193518797b0405060b1d0c0c797b0a190e0f151312797a4c524e4d524c797b0f050f35121a13791a07765c5c5e0f050f0819115e5c465c5e15332f5e50765c5c5e1e0e1d12185e5c465c5e3d0c0c10195e50765c5c5e11131819105e5c465c5e152c141312194d4e50445e50765c5c5e14130e08130e2f18172a190e0f1513125e5c465c5e4d524b524d4d5e7601797e080c797a1d0c0c510b19797b1f141d1212191079743d0c0c2f08130e19797815181a1d79584c4c4c4c4c4c4c4c514c4c4c4c514c4c4c4c514c4c4c4c514c4c4c4c4c4c4c4c4c4c4c4c797218190a151f192912150d091935181f7d
"""
hex_to_bin(hex_str, "output.bin")

登录连接效果:

image-20250901160318008

转换后的JSON串:image-20250901160803552

“resp”:”ack”: 2, “time”: 1756713779261,”cmd”: “Arena_GetAreaTargetResp”,”body”: {..},正好对应js中的编码格式。

控制台输出的json数据即为使用Websocket解密后的服务器响应信息,我们看到的游戏中的功能交互按钮就是用来将json串解析到页面展示出来的。

六、总结

研究过程虽然复杂,但收获颇丰。未来还可以继续研究小程序的反外挂机制以及安全防护策略。