Programming a Minecraft Honeypot


This blog post is designed for readers with beginner to intermediate experience.1

I. Introduction

Before getting started, we need to define what honeypots are in the context of Minecraft servers. You might be wondering what exactly a honeypot is?

Generally, a honeypot consists of data (for example, in a network site) that appears to be a legitimate part of the site which contains information or resources of value to attackers. It is actually isolated, monitored, and capable of blocking or analyzing the attackers.

Wikipedia

This definition summarizes it well, because we’re about to code a program that will mimic a standard Notchian server (Notchian servers are running Minecraft: Java Edition), but its sole purpose is to gather information about the connecting machine (e.g., its IP address) and without acting as a functional server.

The honeypot will only implement the Server List Ping (SLP) which is an interface provided by Minecraft servers used to query information such as the Message of the Day (MOTD), player count, max players, server version and more. Think about it this way, when you open up Minecraft and click the ‘Multiplayer’ button, your client is using the SLP interface to query and then show you the status of each server you’ve added.

In short, a honeypot in the context of a Minecraft server is really just a way to capture the IP of a connecting client by mimicking a normal server.

The reason for making that kind of software is so as to grab information about malicious actors, people who scan all over the internet. These actors port scan2 all 4,294,967,2963 IPv4 addresses, and then use the SLP protocol on every IP with an open 25565 or neighbouring port (25565 is the default port for Minecraft: Java Edition), it’s like knowing which house has hidden golden ingots in a huge city. This might seem harmless, but 2B2T‘s now infamous griefer group The Fifth Column, made the Copenheimer project, designed to index and grief as many innocent servers as possible. FitMC made a great video about that exact subject.

FitMC explaining The Fifth Column’s project Copenheimer.

II. Protocol

For this part, we’ll heavily rely on the unofficial protocol documentation and focus on the ‘Minecraft Modern’ segment. The protocol wiki was available at wiki.vg, until the webmaster, Tyler Kennedy, decided to move on from the project, which had originally been intended to be only ‘temporary’. Wiki.vg was later merged into the official Minecraft wiki, which you can access here.

Minecraft: Java Edition uses the TCP protocol to exchange packets between server and client, unlike Minecraft: Bedrock Edition, which uses UDP.

Now that we know we’ll have to manipulate packets using the TCP protocol, let’s delve into how packets are represented. In a word, packets are contiguous arrays of bytes that the computer sends and receives enabling it to communicate information.


Packet Format

Just as a protocol implies, it has rules and conventions. The structure of a Minecraft uncompressed packet is as follows:

Field NameField TypeNotes
Length4VarIntLength of Packet ID + Data
Packet IDVarInt
DataByte ArrayDepends on the connection state and packet ID.
As seen at https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Protocol#Packet_format
Important Note on VarInts

In our implementation of the Server List Ping interface, handling VarInt encoding and decoding may not be necessary, as VarInts are primarily beneficial for encoding smaller numbers efficiently. Since VarInt-encoded numbers smaller than 128 have the same binary representation as a standard integer and our application within this interface doesn’t exceed this range, we can omit this encoding method and stick to just using integers.

// Pseudocode representing a packet

byte packetId = 0x01; // 0x01 = 1
byte packet[] data = {0xA, 0x3C, 0xB3, 0xDD, 0x4};

// sizeof() returns the size in bytes of a variable
byte length = sizeof(packetId) + sizeof(data);

// Initialize an empty byte array that can hold as
// many bytes as we need to append to it.
byte packet[length];

// The .append() method adds bytes to the end of the array
// It works just like the .append() method in Python
packet.append(lenght);
packet.append(packetId);
packet.append(data);

string encoding

As we will need to send strings to the client, knowing how to encode strings is important.

UTF-8 string prefixed with its size in bytes as a VarInt.

https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Protocol#Type:String
// Pseudocode demonstrating the process of appending a string to a packet

// The Minecraft protocol specifies that strings are 
// UTF-8 encoded, and a UTF-8 character can be more than
// 1 byte in size ('€' is 3 bytes long). 

// We need to account for this if we use UTF-8.
// To determine the number of bytes in C, use sizeof(),
// but be mindful of the null terminator. 

// Subtract 1 from sizeof() to exclude the null
// terminator.

// We assume that packet is already initialized as a byte array

// We are only sending ASCII, so we'll use strlen()

// ----------------------------------------------

// Initialize the string
string message = "Hello, World!";

// Calculate the length of the message
byte messageLength = strlen(message); 
// strlen() returns the number of characters of the string.


// When appending a string to a packet:

// 1. Add the string length
packet.append(messageLength); 

// 2. Add the string bytes
packet.append(message);
// .append() appends the string bytes to the end of the packet

normal status ping sequence

Status List Ping main wiki page is available here.

The following explains the process when a Notchian client requests the status of a Notchian server:

When a Notchian client and server exchange information in a status ping, the exchange of packets will be as follows:

  1. C → SHandshake with Next State set to 1 (Status)
  2. Client and Server set protocol state to Status.
  3. C → SStatus Request
  4. S → CStatus Response
  5. C → SPing Request
  6. S → CPing Response
  7. Both sides close the connection

(Note that C is the Notchian client and S is the Notchian server).

https://minecraft.wiki/w/Minecraft_Wiki:Projects/wiki.vg_merge/Protocol_FAQ#What_does_the_normal_status_ping_sequence_look_like?

It seems the ‘Ping Response’ packet was renamed as ‘Pong Request’ in the merge.

III. Code Logic

Now that you are familiar with the protocol specifications and that we will have to send TCP packets, let us outline the code logic.

  1. Listen for TCP connection.
  2. Listen for the ‘Handshake‘ packet. Which always has its packetID set to 0 and always has its nextState set to 1. The Handshake packet composition is like so: [length(VarInt), packetID(0), protocolVersion(VarInt),serverAddress(String), serverPort(unsigned short), nextState(1)]. Here, read the packetID to confirm it is 0, and check that the nextState is 1 to make sure you are dealing with a Handshake packet.
  3. Listen for the ‘Status Request‘ packet, its composition is like so: [length(1), packetID(0)] you can then discard this packet.
  4. Now that the client has sent its Handshake (and Status Request) packets, it is listening for a response. Prepare to send a ‘Status Response‘ packet with the packetID set to 0 and containing a JSON String formatted as follows:
{
    "version": {
        "name": "1.19.4",
        "protocol": 762
    },
    "players": {
        "max": 100,
        "online": 5,
        "sample": [
            {
                "name": "thinkofdeath",
                "id": "4566e69f-c907-48ee-8d71-d7ba5aa00d20"
            }
        ]
    },
    "description": {
        "text": "Hello, world!"
    },
    "favicon": "data:image/png;base64,<data>",
    "enforcesSecureChat": false,
    "previewsChat": false
}
  • The version name is mandatory
  • the player sample is optional
  • The description is optional
  • The favicon is optional

If you want to be clever, you can parse the Handshake packet and modify version in the JSON response to always be the same as the client. For now let’s consider this a useless feature as all we want to do is get the client’s IP.

Speaking of useless feature…

Technically, sending a response to the client is unnecessary. If you want to minimize effort or maintain stealth, you can simply verify that the received packet follows the Handshake packet layout, then log the client’s IP address. This approach is simpler and, from the client’s (mass scanner’s) perspective, it appears as though nothing happened.

Let’s use this JSON string which is strictly under 128 characters (114) thus we do not have to VarInt-encode its length. Copy this and make the Status Response packet with it.

{"version":{"name":"1.21","protocol":767},"players":{"max":8,"online":6},"description":{"text":"I'm a honeypot!"}}

V. Listen for the ‘Ping Request‘ packet. Its packetID is 1 and has a payload of type long (8 bytes). ‘May be any number. Notchian clients use a system-dependent time value which is counted in milliseconds.’

VI. Then send the ‘Ping Response‘ (or ‘Pong Response’) packet with a payload of type long (8 bytes) and it should be the same number as the client. Simple enough isn’t it? Just send the client’s packet back.

VII. This covers everything. Congratulations! You can now close the connection (and loop back to listen for another).

IV. My Implementation

Although my experience with the C programming language is limited, I chose it to ensure the implementation remains straightforward and easily reproducible in other languages without relying on idiomatic code.

You can access the repository here: https://github.com/Urpagin/MinecraftHoneypot

Footnotes

  1. I am neither a professional nor an advanced programmer, so this assessment may be biased. ↩︎
  2. Some could use the well known masscan port scanner. “This is an Internet-scale port scanner. It can scan the entire Internet in under 5 minutes, transmitting 10 million packets per second, from a single machine.” ↩︎
  3. The IPv4 protocol uses 32 bits, allowing for a maximum of 232 unique addresses. In reality, malicious actors do not scan the entire range of addresses. For more information, see Reserved IP Addresses. ↩︎
  4. When a machine receives a packet, it first reads its total lenght VarInt-encoded. In VarInt encoding, each byte uses its most significant bit (leftmost) as a flag: a value of 1 indicates the presence of additional bytes, while a value of 0 signifies that the entire VarInt has been read.

    This approach clarifies how the machine does not need to know the size of the first sequential attribute in a packet (the packet length) in advance. The use of the VarInt protocol ensures that decoding is self-terminating: when the flag bit is 0, the machine knows it has completed decoding the packet length. ↩︎