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

59

引子

众所周知,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 连接。它不是执行身份验证和登录(如协议和协议加密中所述),而是发送以下格式的数据包:

  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 作为分隔符,具体解析如下:

字段名称

字段类型

注意事项

包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 + 以后,客户端与服务端的连接方式发生改变。

握手

首先,客户端发送状态设置为 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秒,然后超时并发送响应。

数据包标识

字段名称

字段类型

说明

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 形式存在大量键值对,过于混乱不再给出)