教你如何从外部 Ping 通Minecraft服务器

1,016 阅读8分钟

本文已参与「新人创作礼」活动, 一起开启掘金创作之路。

引子

众所周知,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 这一个字节即可,服务器会按照以下以下协议返回其当前状态信息:

字段名称字段类型注意事项
包IDByte返回的包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与字段长度之外的可视化数据)

img

解析后

img

细心的小伙伴可以看到诸如motd、在线玩家数之类的数据都已经获取到了,但是还有部分例如 serverVersion 的数据未被捕获。不要着急,这些是高版本协议中所增加的元素内容。

1.6

有小伙伴会问:为什么为什么先讲 1.6 ?1.4 和 1.5 去哪了?原因很简单,因为 1.4、1.5 的协议是 1.6 的简化版,1.6 的 Notchian 服务器为了兼容先前的版本,都只接收老版本的协议。

客户端到服务端

对于 1.4+ 的 MC 的客户端与服务端 TCP 连接。它不是执行身份验证和登录(如协议和协议加密中所述),而是发送以下格式的数据包:

  1. FE — 服务器列表 ping 的数据包标识符
  2. 01 — 服务器列表 ping 的有效负载(始终为 1)
  3. FA — 插件消息的数据包标识符
  4. 00 0B — 以下字符串的长度,以字符为单位,作为短字符串(始终为 11)
  5. 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
  6. XX XX — 其余数据的长度,作为短。计算为 ,其中 是 UTF-16BE 编码主机名中的字节数。7 + len(hostname)len(hostname)
  7. XX — 协议版本,例如 最后一个版本 (74)4a
  8. XX XX — 以下字符串的长度,以字符为单位,作为短字符串
  9. ... — 客户端连接到的主机名,编码为UTF-16BE字符串
  10. 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 作为分隔符,具体解析如下:

字段名称字段类型注意事项
包IDByte返回的包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与字段长度之外的可视化数据)

img

解析后

img

1.4 - 1.5

在 Minecraft 1.6 之前,客户端到服务器的操作要简单得多,只发送两字节的起始标识即可:FE 01

当前

1.6 + 以后,客户端与服务端的连接方式发生改变。

握手

首先,客户端发送状态设置为 1 的握手数据包。

数据包标识字段名称字段类型说明
0x00协议版本VarInt请参阅协议版本号。客户端计划用于连接到服务器的版本(对于 ping 并不重要)。如果客户端正在 ping 以确定要使用的版本,则应按照惯例进行设置。-1
0x00服务器地址字符串用于连接的主机名或 IP,例如 localhost 或 127.0.0.1。Notchian服务器不使用此信息。请注意,SRV 记录是完全重定向,例如,如果_minecraft._tcp.example.com指向 mc.example.org,则连接到 example.com 的用户除了连接到它之外,还将提供 mc.example.org 作为服务器地址。
0x00服务器端口无符号短默认值为 25565。Notchian服务器不使用此信息。
0x00下一个状态VarInt状态应为 1,但登录时也可以为 2。

请求

客户端跟进请求数据包。此数据包没有字段。

数据包标识字段名称字段类型说明
0x00无字段无字段无字段

响应

服务器应使用响应数据包进行响应。请注意,Notchian 服务器将由于未知原因等待接收以下 Ping数据包30秒,然后超时并发送响应。

数据包标识字段名称字段类型说明
0x00JSON 响应字符串见下文;与所有字符串一样,这以 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 形式存在大量键值对,过于混乱不再给出)

img