教你如何从外部 Ping 通 Minecraft 服务器
引子
众所周知,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 这一个字节即可,服务器会按照以下以下协议返回其当前状态信息:
基于上述我们可以编写以下程序对数据包进行解析,此处先给出通用工具方法
基于上述我们可以编写以下程序对数据包进行解析,此处先给出通用工具方法
/**
* 获取经校验的合法字符串内容
* @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 作为分隔符,具体解析如下:
解析代码
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 + 以后,客户端与服务端的连接方式发生改变。
握手
首先,客户端发送状态设置为 1 的握手数据包。
请求
客户端跟进请求数据包。此数据包没有字段。
响应
服务器应使用响应数据包进行响应。请注意,Notchian 服务器将由于未知原因等待接收以下 Ping数据包30秒,然后超时并发送响应。
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 形式存在大量键值对,过于混乱不再给出)