本帖最后由 IllTamer 于 2022-3-22 11:51 编辑
引子 众所周知,Minecraft 的服务端和客户端是分离的两部分,客户端与服务端通过 TCP / IP(特指 Java 版,基岩版使用的是 UDP) 进行数据通讯(所以我们需要在服务端配置 server.properties 的 port 属性以及客户端连接时所需输入 IP:PORT)。如果我们知道客户端与服务端所采用的具体通讯协议,那么就可以伪装客户端对服务器发起访问请求从而进行一系列操作(比如压测
知识点本节所涉及知识点如下:- 对于特定协议的解析与封装
- Socket API
- BIO
说明 截止到发帖日期,Minecraft Server 的最新版本已经达到了 1.18+,对于这样一个累积多年进行了无数版本迭代的成熟项目,其协议必然也经过了一系列发展变化,所以出于上手难度的考虑,本文将从低到高介绍 MC C / S 通信协议版本,基于 MC Server 的向下兼容,高版本服务器也支持对低版本客户端的解析,故此处我们使用 Sugarcane 1.17.1 这与的高板本服务器完成本章的测试。 开始 BETA 1.8 - 1.3 在 Minecraft 1.4 以前,如果需要请求服务器返回当前基本信息,则仅需向服务器发送 0xFE 这一个字节即可,服务器会按照以下以下协议返回其当前状态信息:
字段名称 | 字段类型 | 注意事项 | 包ID | Byte | 返回的包ID应为: 0xFF | 字段长度 | Short | 数据包剩余部分的长度 | MOTD | 一段以 UTF-16BE 编码的字符串 | 从这里开始,所有字段都应该在同一个字符串中用 § 分隔。此字符串的最大长度为 64 字节。 | 在线玩家数 | 服务器当前游玩的玩家数量. | 最大玩家数 | 服务器能支持的最大玩家数量 | 基于上述我们可以编写以下程序对数据包进行解析,此处先给出通用工具方法 /** * 获取经校验的合法字符串内容 * @apiNote 数据包ID需为 0xFF 且长度合法 * */ protected static String getSecureString(InputStream inputStream, InputStreamReader inputStreamReader) throws IOException { int packetId = inputStream.read(); if (packetId == -1) throw new IOException("Premature end of stream."); if (packetId != 0xFF) throw new IOException("Invalid packet ID (" + packetId + ")."); int length = inputStreamReader.read(); if (length == -1) throw new IOException("Premature end of stream."); if (length == 0) throw new IOException("Invalid string length.");
char[] chars = new char[length]; if (inputStreamReader.read(chars, 0, length) != length) throw new IOException("Premature end of stream."); return new String(chars); }解析代码 /** * @version BETA - 1.3 * */ private void connect() throws IOException { try ( Socket socket = new Socket() ) { socket.setSoTimeout(TIMEOUT); socket.connect(new InetSocketAddress(host, port), TIMEOUT); try ( OutputStream dataOutputStream = socket.getOutputStream(); InputStream inputStream = socket.getInputStream(); InputStreamReader inputStreamReader = new InputStreamReader(inputStream, StandardCharsets.UTF_16BE); ) { dataOutputStream.write(0xFE);
String string = getSecureString(inputStream, inputStreamReader); String[] args = string.split("§"); motd = args[0]; onlinePlayers = Integer.parseInt(args[1]); maxPlayers = Integer.parseInt(args[2]); } } }返回的数据内容(敏感部分已做处理 原始数据(指除包ID与字段长度之外的可视化数据)
 解析后
 细心的小伙伴可以看到诸如motd、在线玩家数之类的数据都已经获取到了,但是还有部分例如 serverVersion 的数据未被捕获。不要着急,这些是高版本协议中所增加的元素内容。 1.6 有小伙伴会问:为什么为什么先讲 1.6 ?1.4 和 1.5 去哪了?原因很简单,因为 1.4、1.5 的协议是 1.6 的简化版,1.6 的 Notchian 服务器为了兼容先前的版本,都只接受老版本的协议。 客户端到服务端 对于 1.4+ 的 MC 的客户端与服务端 TCP 连接。它不是执行身份验证和登录(如协议和协议加密中所述),而是发送以下格式的数据包:- FE— 服务器列表 ping 的数据包标识符
- 01— 服务器列表 ping 的有效负载(始终为 1)
- FA— 插件消息的数据包标识符
- 00 0B— 以下字符串的长度,以字符为单位,作为短字符串(始终为 11)
- 00 4D 00 43 00 7C 00 50 00 69 00 6E 00 67 00 48 00 6F 00 73 00 74— 编码为UTF-16BE字符串的字符串MC|PingHost
- XX XX— 其余数据的长度,作为短。计算为 ,其中 是 UTF-16BE 编码主机名中的字节数。7 + len(hostname)len(hostname)
- XX—协议版本,例如 最后一个版本 (74)4a
- XX XX— 以下字符串的长度,以字符为单位,作为短字符串
- ...— 客户端连接到的主机名,编码为UTF-16BE字符串
- XX XX XX XX— 客户端正在连接到的端口,作为整数。
注:所有数据类型都是 big-endian 的,而为了向下兼容,所有 Notchian 服务器只关心前 3 个字节(且您只能发送这 3 个字节),而 Bukkit 服务器仅关心前两个字节。读取后,响应将发送到客户端,所有旧版服务器 (<=1.6) 将相应地响应。FE 01 FA 数据包示例: 0000000: fe01 fa00 0b00 4d00 4300 7c00 5000 6900 ......M.C.|.P.i. 0000010: 6e00 6700 4800 6f00 7300 7400 1949 0009 n.g.H.o.s.t..I.. 0000020: 006c 006f 0063 0061 006c 0068 006f 0073 .l.o.c.a.l.h.o.s 0000030: 0074 0000 63dd .t..c.
服务端到客户端 在最初的三个字节之后,形如编码为 UTF-16BE 字符串的数据包以 ASCII 0167 的字符为起始标志,每个元素间使用 \0 作为分隔符,具体解析如下:
字段名称 | 字段类型 | 注意事项 | 包ID | Byte | 返回的包ID应为: 0xFF | 字段长度 | Short | 数据包剩余部分的长度 | 协议版本 | 一段以 UTF-16BE 编码的字符串 | 例如 74 | 服务器版本 | 如 1.8.7 | MOTD | 从这里开始,所有字段都应该在同一个字符串中用 § 分隔。此字符串的最大长度为 64 字节。 | 在线玩家数 | 服务器当前游玩的玩家数量. | 最大玩家数 | 服务器能支持的最大玩家数量 | 解析代码 private void connect() throws IOException { try ( Socket socket = new Socket() ) { socket.setSoTimeout(TIMEOUT); socket.connect(new InetSocketAddress(host, port), TIMEOUT); try ( DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream()); InputStream inputStream = socket.getInputStream(); InputStreamReader inputStreamReader = new InputStreamReader(inputStream, StandardCharsets.UTF_16BE); ) { dataOutputStream.write(new byte[]{(byte) 0xFE, (byte) 0x01/*, (byte) 0xFA*/});
String string = ServerInfoV1_3.getSecureString(inputStream, inputStreamReader);
if (string.startsWith("§")) { String[] data = string.split("\0"); pingVersion = Integer.parseInt(data[0].substring(1)); protocolVersion = Integer.parseInt(data[1]); serverVersion = data[2]; motd = data[3]; onlinePlayers = Integer.parseInt(data[4]); maxPlayers = Integer.parseInt(data[5]); } else { String[] data = string.split("§"); motd = data[0]; onlinePlayers = Integer.parseInt(data[1]); maxPlayers = Integer.parseInt(data[2]); } } } }返回的数据内容 原始数据(指除包ID与字段长度之外的可视化数据)
 解析后
 1.4 - 1.5 在 Minecraft 1.6 之前,客户端到服务器的操作要简单得多,只发送两字节的起始标识即可:FE 01 当前 1.6 + 以后,客户端与服务端的连接方式发生改变。 握手
数据包标识 | 字段名称 | 字段类型 | 笔记 | 0x00 | 协议版本 | VarInt | 请参阅协议版本号。客户端计划用于连接到服务器的版本(对于 ping 并不重要)。如果客户端正在 ping 以确定要使用的版本,则应按照惯例进行设置。-1 | 服务器地址 | 字符串 | 用于连接的主机名或 IP,例如 localhost 或 127.0.0.1。Notchian服务器不使用此信息。请注意,SRV 记录是完全重定向,例如,如果_minecraft._tcp.example.com指向 mc.example.org,则连接到 example.com 的用户除了连接到它之外,还将提供 mc.example.org 作为服务器地址。 | 服务器端口 | 无符号短 | 默认值为 25565。Notchian服务器不使用此信息。 | 下一个状态 | VarInt | 状态应为 1,但登录时也可以为 2。 | 请求响应 服务器应使用 响应数据包进行响应。请注意,Notchian 服务器将由于未知原因等待接收以下 Ping数据包30秒,然后超时并发送响应。 数据包标识 | 字段名称 | 字段类型 | 笔记 | 0x00 | JSON 响应 | 字符串 | 见下文;与所有字符串一样,这以 VarInt 的长度为前缀 |
JSON 响应字段是一个 JSON 对象,其格式如下: { "version": { "name": "1.8.7", "protocol": 47 }, "players": { "max": 100, "online": 5, "sample": [ { "name": "thinkofdeath", "id": "4566e69f-c907-48ee-8d71-d7ba5aa00d20" } ] }, "description": { "text": "Hello world" }, "favicon": "data:image/png;base64,<data>"} 对于此版本的协议传输,我们需要使用 Minecraft 指定的 varInt 函数将 int 转换为 varInt 类型从而构造正确的握手数据包,转换代码如下: /** * varInt 读取函数 * @apiNote https://wiki.vg/index.php?title=Protocol&oldid=16681 */ protected static int readVarInt(DataInputStream in) throws IOException { int i = 0; int j = 0; while (true) { int k = in.readByte(); i |= (k & 0x7F) << (j++ * 7); if (j > 5) throw new RuntimeException("VarInt too big"); if ((k & 0x80) != 0x80) break; } return i; } /** * varInt 写入函数 * */ protected static void writeVarInt(DataOutputStream out, int paramInt) throws IOException { while (true) { if ((paramInt & ~0x7F) == 0) { out.writeByte(paramInt); return; } out.writeByte(paramInt & 0x7F | 0x80); paramInt >>>= 7; } } 基于上述,可以给出以下解析代码,其中解析 json 部分不再赘述: /** * 发送数据包格式为:数据包长度 + 内容 * */ private void connect() throws IOException { try (Socket socket = new Socket()) { socket.setSoTimeout(9000); socket.connect(new InetSocketAddress(host, port), 9000); try ( DataOutputStream out = new DataOutputStream(socket.getOutputStream()); DataInputStream in = new DataInputStream(socket.getInputStream()); //> Handshake ByteArrayOutputStream handshake_bytes = new ByteArrayOutputStream(); DataOutputStream handshake = new DataOutputStream(handshake_bytes); ) { handshake.writeByte(PACKET_HANDSHAKE); writeVarInt(handshake, packageProtocolVersion); writeVarInt(handshake, host.length()); handshake.writeBytes(host); handshake.writeShort(port); writeVarInt(handshake, PACKET_STATUS_HANDSHAKE);
//< Status Handshake writeVarInt(out, handshake_bytes.size()); // Size of packet out.write(handshake_bytes.toByteArray());
//< Status Request out.writeByte(0x01); // Size of packet out.writeByte(PACKET_STATUS_REQUEST);
//< Status Response // https://wiki.vg/Protocol#Response readVarInt(in); // Size pingVersion = readVarInt(in); int length = readVarInt(in); byte[] data = new byte[length]; in.readFully(data); String json = new String(data, StandardCharsets.UTF_8);
JsonObject jsonObject = new Gson().fromJson(json, JsonObject.class);
parseJson(jsonObject); } } }解析后数据(原始数据为 json 形式存在大量键值对,过于混乱不再给出)
 最后 Minecraft 系列会持续更新,后期会关注生物 AI、自制类库等方面的内容。感兴趣的小伙伴不妨关注一波~
|