From 86fc54487659449258b0202b53d99e61eeac0170 Mon Sep 17 00:00:00 2001 From: Sch8ill <92382209+Sch8ill@users.noreply.github.com> Date: Wed, 20 Mar 2024 18:26:35 +0100 Subject: [PATCH] add server software fingerprinting --- client.go | 380 +++++++++++++++++++++++++++++++++++++ cmd/main.go | 51 +++++ fingerprint/fingerprint.go | 203 ++++++++++++++++++++ 3 files changed, 634 insertions(+) create mode 100644 client.go create mode 100644 cmd/main.go create mode 100644 fingerprint/fingerprint.go diff --git a/client.go b/client.go new file mode 100644 index 0000000..81fdf93 --- /dev/null +++ b/client.go @@ -0,0 +1,380 @@ +package mclib + +import ( + "errors" + "fmt" + "net" + "time" + + "github.com/sch8ill/mclib/address" + "github.com/sch8ill/mclib/packet" + "github.com/sch8ill/mclib/slp" +) + +const ( + DefaultTimeout = 5 * time.Second + DefaultProtocol int32 = 47 + + StatusState int32 = 1 + LoginState int32 = 2 +) + +// ConnState represents the connection state of the Client. +type ConnState int64 + +const ( + Idle ConnState = iota + Connected + HandshakeComplete +) + +// Client represents a client for interacting with Minecraft servers through the Minecraft protocol. +type Client struct { + addr *address.Address + timeout time.Duration + srv bool + protocol int32 + state ConnState + conn net.Conn +} + +// ClientOption represents a functional option for configuring a Client instance. +type ClientOption func(*Client) + +// WithTimeout sets a custom timeout for communication with the server. +func WithTimeout(timeout time.Duration) ClientOption { + return func(c *Client) { + c.timeout = timeout + } +} + +// WithProtocolVersion sets a custom Minecraft protocol version. +func WithProtocolVersion(protocol int32) ClientOption { + return func(c *Client) { + c.protocol = protocol + } +} + +// WithoutSRV disables SRV record lookups for the client. +func WithoutSRV() ClientOption { + return func(c *Client) { + c.srv = false + } +} + +// WithConnection set a custom already connected connection. +func WithConnection(conn net.Conn) ClientOption { + return func(c *Client) { + c.conn = conn + c.state = Connected + } +} + +// WithAddress sets a custom address. +func WithAddress(addr *address.Address) ClientOption { + return func(c *Client) { + c.addr = addr + } +} + +// NewClient creates a new Client for pinging a Minecraft server at the specified address. +func NewClient(addr string, opts ...ClientOption) (*Client, error) { + a, err := address.New(addr) + if err != nil { + return nil, err + } + + client := &Client{ + addr: a, + timeout: DefaultTimeout, + protocol: DefaultProtocol, + srv: true, + } + + for _, opt := range opts { + opt(client) + } + + return client, nil +} + +// StatusPing performs both a status query and a ping to the Minecraft server and returns the combined result. +func (c *Client) StatusPing() (*slp.Response, error) { + res, err := c.Status() + if err != nil { + return nil, fmt.Errorf("failed to get server status: %w", err) + } + + latency, err := c.Ping() + if err != nil { + return nil, fmt.Errorf("failed to determine latency: %w", err) + } + res.Latency = latency + + return res, nil +} + +// Status performs a status query to the Minecraft server and retrieves server information. +func (c *Client) Status() (*slp.Response, error) { + if err := c.connectAndHandshake(StatusState); err != nil { + return nil, err + } + + if err := c.sendStatusRequest(); err != nil { + return nil, err + } + + rawRes, err := c.recvStatusResponse() + if err != nil { + return nil, fmt.Errorf("failed to receive status response: %w", err) + } + + res, err := slp.NewResponse(rawRes) + if err != nil { + return nil, fmt.Errorf("failed to parse json response: %w", err) + } + + return res, nil +} + +// Ping performs a ping operation to the Minecraft server and returns the latency in milliseconds. +func (c *Client) Ping() (int, error) { + if err := c.connectAndHandshake(StatusState); err != nil { + return 0, err + } + + timestamp := time.Now() + + if err := c.sendPing(timestamp.Unix()); err != nil { + return 0, err + } + + id, err := c.recvPong() + if err != nil { + return 0, fmt.Errorf("failed to receive pong: %w", err) + } + + latency := int(time.Since(timestamp).Milliseconds()) + + if id != timestamp.Unix() { + return latency, fmt.Errorf("server responded with wrong pong id") + } + + // the server closes the connection after the pong packet + c.state = Idle + return latency, nil +} + +// LoginError tries to trigger an exception in the servers packet parser. +// The error response can be used to fingerprint the server software. +func (c *Client) LoginError() (string, int32, error) { + if err := c.connectAndHandshake(LoginState); err != nil { + return "", 0, err + } + + if err := c.sendLoginStartCrash("mclib", make([]byte, 16)); err != nil { + return "", 0, err + } + + res, err := packet.NewInboundPacket(c.conn, c.timeout) + if err != nil { + return "", 0, err + } + + reason, err := res.ReadString() + if err != nil { + return "", 0, err + } + + return reason, res.ID(), nil +} + +// sendHandshake sends a handshake packet to the Minecraft server during the connection setup. +func (c *Client) sendHandshake(state int32) error { + // handshake packet: + // packet id (VarInt) (0) + // protocol version (VarInt) (-1 = not set) + // hostname (string) + // port (uint16) + // next state (VarInt) (1: status, 2: login) + // + // https://wiki.vg/Server_List_Ping#Handshake + + handshake := packet.NewOutboundPacket(packet.HandshakeID) + handshake.WriteVarInt(c.protocol) + if err := handshake.WriteString(c.addr.Host()); err != nil { + return fmt.Errorf("failed to write host: %w", err) + } + handshake.WriteShort(int16(c.addr.Port())) + handshake.WriteVarInt(state) + if err := handshake.Write(c.conn); err != nil { + return fmt.Errorf("failed to send handshake: %w", err) + } + + c.state = HandshakeComplete + + return nil +} + +// sendStatusRequest sends a status request packet to the Minecraft server. +func (c *Client) sendStatusRequest() error { + // status request: + // packet id (VarInt) (0) + // + // https://wiki.vg/Protocol#Status_Request + + statusRequest := packet.NewOutboundPacket(packet.StatusID) + if err := statusRequest.Write(c.conn); err != nil { + return fmt.Errorf("failed to send status request: %w", err) + } + + return nil +} + +// recvResponse receives the status response from the Minecraft server. +func (c *Client) recvStatusResponse() (string, error) { + // status response: + // packet id (VarInt) (0) + // json response (string) + // + // https://wiki.vg/Server_List_Ping#Status_Response + + res, err := packet.NewInboundPacket(c.conn, c.timeout) + if err != nil { + return "", fmt.Errorf("failed to read status response: %w", err) + } + + id := res.ID() + if id == packet.DisconnectID || id == packet.LegacyDisconnectID { + msg, err := res.ReadString() + if err != nil { + return "", fmt.Errorf("failed to read disconnect reason: %w", err) + } + + return "", fmt.Errorf("disconnect packet from server: %s", msg) + } + + if id != packet.StatusID { + return "", fmt.Errorf("response packet contains bad packet id: %d", res.ID()) + } + + resBody, err := res.ReadString() + if err != nil { + return "", fmt.Errorf("failed to read status response body: %w", err) + } + + return resBody, nil +} + +// sendPing sends a ping packet to the Minecraft server to measure latency. +func (c *Client) sendPing(timestamp int64) error { + // ping packet: + // packet id (VarInt) (1) + // timestamp (Int64) + // + // https://wiki.vg/Server_List_Ping#Ping_Request + + ping := packet.NewOutboundPacket(packet.PingID) + ping.WriteLong(timestamp) + if err := ping.Write(c.conn); err != nil { + return fmt.Errorf("failed to send ping: %w", err) + } + + return nil +} + +// recvPong receives the pong packet from the Minecraft server. +func (c *Client) recvPong() (int64, error) { + // pong packet: + // packet id (VarInt) (1) + // payload (Int64) + // + // https://wiki.vg/Server_List_Ping#Pong_Response + + pong, err := packet.NewInboundPacket(c.conn, c.timeout) + if err != nil { + return 0, fmt.Errorf("failed to read pong: %w", err) + } + + if pong.ID() != packet.PongID { + return 0, fmt.Errorf("response packet contains bad packet id: %d", pong.ID()) + } + + id, err := pong.ReadLong() + if err != nil { + return 0, fmt.Errorf("failed to read pong id: %w", err) + } + + return id, nil +} + +// sendLoginStartCrash sends a bad login start packet to the server to trigger an error. +func (c *Client) sendLoginStartCrash(name string, uuid []byte) error { + // login start crash packet: + // packet id (VarInt) (0) + // name (string) + // uuid (uuid) + // + // unexpected: + // padding (byte) + // + // https://wiki.vg/Protocol#Login_Start + + if len(name) > 16 { + return fmt.Errorf("player name cannot be longer than 16 characters: length: %d", len(name)) + } + + if len(uuid) != 16 { + return fmt.Errorf("player uuid has to be 16 bytes long: length: %d", len(uuid)) + } + + login := packet.NewOutboundPacket(packet.LoginStartID) + if err := login.WriteString(name); err != nil { + return err + } + login.WriteBytes(uuid) + login.WriteByte(0) + + if err := login.Write(c.conn); err != nil { + return err + } + + return nil +} + +// connectAndHandshake handles the connection setup and handshake with the Minecraft server. +func (c *Client) connectAndHandshake(state int32) error { + if c.state < Connected { + if err := c.connect(); err != nil { + return err + } + } + + if c.state < HandshakeComplete { + if err := c.sendHandshake(state); err != nil { + return err + } + } + + return nil +} + +// connect establishes a connection to the Minecraft server. +func (c *Client) connect() error { + if c.state > Idle { + return errors.New("client is already connected") + } + + if c.srv { + _ = c.addr.ResolveSRV() + } + + conn, err := net.DialTimeout("tcp", c.addr.String(), c.timeout) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + c.conn = conn + c.state = Connected + + return nil +} diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..d6cce16 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/sch8ill/mclib" + "github.com/sch8ill/mclib/fingerprint" +) + +func main() { + addr := flag.String("addr", "localhost", "the server address") + timeout := flag.Duration("timeout", mclib.DefaultTimeout, "the connection timeout") + srv := flag.Bool("srv", true, "whether a srv lookup should be made") + protocol := flag.Int("protocol", 760, "the protocol version number the client should use") + doFingerprint := flag.Bool("fingerprint", true, "whether a software fingerprint should be performed on the server") + flag.Parse() + + opts := []mclib.ClientOption{mclib.WithTimeout(*timeout), mclib.WithProtocolVersion(int32(*protocol))} + if !*srv { + opts = append(opts, mclib.WithoutSRV()) + } + + mcs, err := mclib.NewClient(*addr, opts...) + if err != nil { + panic(err) + } + + res, err := mcs.StatusPing() + if err != nil { + panic(err) + } + + fmt.Printf("version: %s\n", res.Version.Name) + fmt.Printf("protocol: %d\n", res.Version.Protocol) + fmt.Printf("description: %s\n", res.Description.String()) + fmt.Printf("online players: %d\n", res.Players.Online) + fmt.Printf("max players: %d\n", res.Players.Max) + fmt.Printf("sample players: %+q\n", res.Players.Sample) + fmt.Printf("latency: %dms\n", res.Latency) + fmt.Printf("favicon: %t\n", res.Favicon != "") + + if *doFingerprint { + software, err := fingerprint.FingerprintWithProtocol(*addr, res.Version.Protocol, opts...) + if err != nil { + fmt.Printf("failed to perform fingerprint: %s\n", err) + } else { + fmt.Printf("software fingerprint: %s\n", software) + } + } +} diff --git a/fingerprint/fingerprint.go b/fingerprint/fingerprint.go new file mode 100644 index 0000000..9e25a5a --- /dev/null +++ b/fingerprint/fingerprint.go @@ -0,0 +1,203 @@ +// Package fingerprint provides functionality to determine a Minecraft Servers software +// by sending maliciously crafted packets to the server and analyzing the responses. +package fingerprint + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "strings" + + "github.com/sch8ill/mclib" + "github.com/sch8ill/mclib/packet" +) + +const ( + Vanilla string = "vanilla" + CraftBukkit = "craftbukkit" + Fabric = "fabric" + Forge = "forge" + Velocity = "velocity" + Empty = "empty" + Encryption = "encryption" + Success = "success" + Compression = "compression" + Plugin = "plugin" + Unknown = "unknown" +) + +var ConnectionThrottled = errors.New("connection throttled by server") + +func Fingerprint(addr string, opts ...mclib.ClientOption) (string, error) { + statusClient, err := mclib.NewClient(addr, opts...) + if err != nil { + return Unknown, err + } + + status, err := statusClient.Status() + if err != nil { + return Unknown, err + } + + return FingerprintWithProtocol(addr, status.Version.Protocol, opts...) +} + +func FingerprintWithProtocol(addr string, protocol int, opts ...mclib.ClientOption) (string, error) { + opts = append(opts, mclib.WithProtocolVersion(int32(protocol))) + client, err := mclib.NewClient(addr, opts...) + + res, id, err := client.LoginError() + if errors.Is(err, io.EOF) { + return Empty, nil + } + if err != nil { + return Unknown, err + } + + switch id { + case packet.LoginDisconnectID: + + case packet.LoginEncryptionID: + return Encryption, nil + + case packet.LoginSuccessID: + return Success, nil + + case packet.LoginCompressionID: + return Compression, nil + + case packet.LoginPluginID: + return Plugin, nil + + default: + return Unknown, fmt.Errorf("unfamilliar packet id: %d", id) + + } + + if res == "" { + return Empty, nil + } + + if res == "\"Connection throttled! Please wait before reconnecting.\"" { + return Unknown, ConnectionThrottled + } + + versionMismatch := regexp.MustCompile("^\"Outdated client! Please use \\d\\.\\d+\\.\\d+\"$") + if versionMismatch.MatchString(res) { + return Unknown, fmt.Errorf("version mismatch: %s", res) + } + + // Forge disconnect message: + // This server has mods that require Forge to be installed on the client. \ + // Contact your server admin for more details. + // or + // This server has mods that require FML/Forge to be installed on the client. [...] + if strings.Contains(res, "Forge") { + return Forge, nil + } + + msg, err := NewDisconnectMsg(res) + if err != nil { + return "", err + } + + mismatch, version := msg.VersionMismatch() + if mismatch { + return Unknown, fmt.Errorf("version mismatch: %s", version) + } + + return msg.Fingerprint() +} + +type DisconnectMsg struct { + Translate string `json:"translate"` + With []string `json:"with"` + Text string `json:"text"` +} + +func NewDisconnectMsg(res string) (*DisconnectMsg, error) { + msg := new(DisconnectMsg) + if err := json.Unmarshal([]byte(res), msg); err != nil { + return nil, fmt.Errorf("failed to parse disconnect message: %w", err) + } + + return msg, nil +} + +// Fingerprint tries to determine the underlying software of a Minecraft server by +// analyzing the returned bad login packet disconnect message. +// Heavily inspired by matscan: +// https://github.com/mat-1/matscan/blob/master/src/processing/minecraft_fingerprinting.rs +func (m *DisconnectMsg) Fingerprint() (string, error) { + if m.Text == "This server is only compatible with Minecraft 1.13 and above." { + return Velocity, nil + } + + if m.Text == "Connection throttled! Please wait before reconnecting." { + return Unknown, ConnectionThrottled + } + + if m.Translate == "" { + return Unknown, errors.New("empty error topic") + } + + if m.Translate != "disconnect.genericReason" && m.Translate != "%s" { + return Unknown, fmt.Errorf("server responded with unfamiliar error topic: %s", m.Translate) + } + + if len(m.With) < 1 { + return Unknown, errors.New("incomplete disconnect message") + } + + // example disconnect message (Spigot 1.20.4 / 765) + // { + // "translate": "disconnect.genericReason", + // "with": [ + // "Internal Exception: io.netty.handler.codec.DecoderException: java.io.IOException: \ + // Packet login/0 (PacketLoginInStart) was larger than I expected, found 1 bytes extra \ + // whilst reading packet 0" + // ] + // } + msg := strings.TrimPrefix( + m.With[0], + "Internal Exception: io.netty.handler.codec.DecoderException: java.io.IOException: Packet ") + + re := regexp.MustCompile(" was larger than I expected, found \\d+ bytes extra whilst reading packet \\d+$") + msg = re.ReplaceAllString(msg, "") + + if msg == "login/0 (PacketLoginInStart)" { + return CraftBukkit, nil + } + + // forge without any mods + if msg == "login/0 (ServerboundHelloPacket)" { + return Forge, nil + } + + // vanilla e.g.: login/0 (afu) + vanillaRe := regexp.MustCompile("^login/0 \\(.{2,3}?\\)$") + if vanillaRe.MatchString(msg) { + return Vanilla, nil + } + + // fabric e.g.: 2/0 (class_2915) + fabricRe := regexp.MustCompile("^\\d+/\\d+ \\(class_\\d*\\)$") + if fabricRe.MatchString(msg) { + return Fabric, nil + } + + return Unknown, nil +} + +func (m *DisconnectMsg) VersionMismatch() (bool, string) { + if m.Translate == "multiplayer.disconnect.incompatible" { + if len(m.With) < 1 { + return true, "" + } + return true, m.With[0] + } + + return false, "" +}