MySQL Protocolに入門する

ふと気になった。

ローカル開発環境でMySQLに接続するときにはTCPunix domain socketを使用していた。ここで、例えばAPIにアクセスするときにはさらにHTTPなどのプロトコルを使用することになるが、MySQLでは一体何のプロトコルを使用しているんだろうということが気になった。HTTPではなさそうだし。

というわけで調べていくとMySQL ProtocolというMySQL独自のプロトコルを使用していることがわかった。

MySQL :: MySQL Internals Manual :: 14 MySQL Client/Server Protocol

では具体的に見ていこうということで、ここではまずMySQL接続時にサーバから送られるInitial Handshake Packetから解析する。次に、Handshake Response Packetを構築してクライアントからサーバに送信し、OK_packetが返ってくるところまで確認する。

コードとその出力結果は以下の通り(usernamepasswordはブログを書くときに変更している)。

package main

import (
    "bufio"
    "bytes"
    "crypto/sha1"
    "encoding/binary"
    "encoding/hex"
    "fmt"
    "io"
    "net"
)

const (
    CLIENT_PLUGIN_AUTH       = 0x00080000
    CLIENT_SECURE_CONNECTION = 0x00008000
    CLIENT_PROTOCOL_41       = 0x00000200
    CLIENT_LONG_PASSWORD     = 0x00000001
    CLIENT_TRANSACTIONS      = 0x00002000
    CLIENT_LONG_FLAG         = 0x00000004
)

var (
    username = "xxx"
    password = "xxx"
)

func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:3306")
    if err != nil {
        panic(err)
    }
    defer conn.Close()
    reader := bufio.NewReader(conn)

    // Parse connection phase packets
    // See https://dev.mysql.com/doc/internals/en/connection-phase-packets.html
    header := make([]byte, 4)
    reader.Read(header)
    fmt.Println("[header]")
    fmt.Printf("%s\n", hex.Dump(header))

    payloadSize := int(uint32(header[0]) | uint32(header[1])<<8 | uint32(header[2])<<16)
    payload := make([]byte, payloadSize)
    reader.Read(payload)
    fmt.Println("[payload]")
    fmt.Printf("%s\n", hex.Dump(payload))

    offset := 0
    protocolVersion := int(payload[offset])
    fmt.Printf("  [protocol_version] %d\n", protocolVersion)
    offset++

    idx := bytes.IndexByte(payload[offset:], 0x00)
    serverVersion := payload[offset : offset+idx]
    fmt.Printf("  [server_version] %s\n", serverVersion)
    offset += idx + 1

    connectionID := binary.LittleEndian.Uint32(payload[offset : offset+4])
    fmt.Printf("  [connection ID] %d\n", connectionID)
    offset += 4

    authPluginDataPart1 := payload[offset : offset+8]
    fmt.Print("  [auth-plugin-data-part1]\n")
    fmt.Printf("    %s", hex.Dump(authPluginDataPart1))
    offset += 8

    filter := int(payload[offset])
    fmt.Printf("  [filter] %d\n", filter)
    offset++

    capabilityLower := payload[offset : offset+2]
    offset += 2

    characterSet := int(payload[offset])
    fmt.Printf("  [character set] %d\n", characterSet)
    offset++

    statusFlags := binary.LittleEndian.Uint16(payload[offset : offset+2])
    fmt.Printf("  [status flags] %d\n", statusFlags)
    offset += 2

    capabilityUpper := payload[offset : offset+2]
    offset += 2

    capabilities := binary.LittleEndian.Uint32(append(capabilityLower, capabilityUpper...))

    var authPluginDataPart2 []byte
    var authPluginName []byte
    if capabilities&CLIENT_PLUGIN_AUTH > 0 {
        lengthOfAuthPluginData := int(payload[offset])
        fmt.Printf("  [length of auth-plugin-data] %d\n", lengthOfAuthPluginData)
        offset++

        // skipped reserved 10 bytes
        offset += 10

        if capabilities&CLIENT_SECURE_CONNECTION > 0 {
            lengthAuthPluginDataPart2 := lengthOfAuthPluginData - 8
            if lengthAuthPluginDataPart2 < 13 {
                lengthAuthPluginDataPart2 = 13
            }
            authPluginDataPart2 = payload[offset : offset+lengthAuthPluginDataPart2]
            fmt.Print("  [auth-plugin-data-part2]\n")
            fmt.Printf("    %s", hex.Dump(authPluginDataPart2))
            offset += lengthAuthPluginDataPart2

            idx = bytes.IndexByte(payload[offset:], 0x00)
            authPluginName = payload[offset : offset+idx]
            fmt.Printf("  [auth-plugin name] %s\n", authPluginName)
            offset += idx + 1
        }
    } else {
        panic("not supported")
    }

    // Handshake response

    // Secure Password Authentication
    // See https://dev.mysql.com/doc/internals/en/secure-password-authentication.html
    authResponse := sha1Sum([]byte(password))
    tmp := sha1Sum(append(append(authPluginDataPart1, authPluginDataPart2[0:12]...), sha1Sum([]byte(authResponse))...))
    for i := 0; i < 20; i++ {
        authResponse[i] ^= tmp[i]
    }

    bufferSize := 4                       // header
    bufferSize += 4                       // capability flags
    bufferSize += 4                       // max-packet size
    bufferSize++                          // character set
    bufferSize += 23                      // reserved
    bufferSize += len(username) + 1       // username
    bufferSize++                          // length of auth-response
    bufferSize += len(authResponse)       // auth-response
    bufferSize += len(authPluginName) + 1 // auth plugin name + NUL
    buffer := make([]byte, bufferSize)

    offset = 0
    size := bufferSize - 4
    buffer[0] = byte(size)
    buffer[1] = byte(size >> 8)
    buffer[2] = byte(size >> 16)
    buffer[3] = 0x01
    offset += 4

    // Capability flags
    // See https://dev.mysql.com/doc/internals/en/capability-flags.html#packet-Protocol::CapabilityFlags
    capabilityFlags := uint32(CLIENT_PROTOCOL_41 |
        CLIENT_SECURE_CONNECTION | CLIENT_LONG_PASSWORD |
        CLIENT_TRANSACTIONS | CLIENT_LONG_FLAG)
    buffer[offset] = byte(capabilityFlags)
    buffer[offset+1] = byte(capabilityFlags >> 8)
    buffer[offset+2] = byte(capabilityFlags >> 16)
    buffer[offset+3] = byte(capabilityFlags >> 24)
    offset += 4

    offset += 4 // Skip max-packet size

    charSet := 0x21
    buffer[offset] = byte(charSet)
    offset++

    offset += 23 // Skip reserved

    copy(buffer[offset:], username)
    offset += len(username) + 1

    buffer[offset] = byte(len(authResponse))
    offset++

    copy(buffer[offset:], authResponse)
    offset += len(authResponse) + 1

    copy(buffer[offset:], authPluginName)

    fmt.Printf("\n[write packets: %d bytes]: \n%s\n", len(buffer), hex.Dump(buffer))
    conn.Write(buffer)

    header = make([]byte, 4)
    reader.Read(header)
    fmt.Println("[header]")
    fmt.Printf("%s\n", hex.Dump(header))

    payloadSize = int(uint32(header[0]) | uint32(header[1])>>8 | uint32(header[2])>>16)
    payload = make([]byte, payloadSize)
    reader.Read(payload)
    fmt.Println("[payload]")
    fmt.Printf("%s\n", hex.Dump(payload))
}

func sha1Sum(data []byte) []byte {
    h := sha1.New()
    io.Copy(h, bytes.NewReader(data))
    return h.Sum(nil)
}
[header]
00000000  4a 00 00 00                                       |J...|

[payload]
00000000  0a 35 2e 37 2e 32 32 00  03 00 00 00 58 51 61 25  |.5.7.22.....XQa%|
00000010  52 49 5b 63 00 ff ff 21  02 00 ff c1 15 00 00 00  |RI[c...!........|
00000020  00 00 00 00 00 00 00 12  3d 25 6e 23 01 66 6c 40  |........=%n#.fl@|
00000030  1f 63 36 00 6d 79 73 71  6c 5f 6e 61 74 69 76 65  |.c6.mysql_native|
00000040  5f 70 61 73 73 77 6f 72  64 00                    |_password.|

  [protocol_version] 10
  [server_version] 5.7.22
  [connection ID] 3
  [auth-plugin-data-part1]
    00000000  58 51 61 25 52 49 5b 63                           |XQa%RI[c|
  [filter] 0
  [character set] 33
  [status flags] 2
  [length of auth-plugin-data] 21
  [auth-plugin-data-part2]
    00000000  12 3d 25 6e 23 01 66 6c  40 1f 63 36 00           |.=%n#.fl@.c6.|
  [auth-plugin name] mysql_native_password

[write packets: 90 bytes]: 
00000000  56 00 00 01 05 a2 00 00  00 00 00 00 21 00 00 00  |V...........!...|
00000010  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000020  00 00 00 00 6f 61 75 74  68 5f 75 73 65 72 00 14  |....oauth_user..|
00000030  ff 59 45 be 61 d8 e2 0b  b3 8a 30 fb ea 5a 3f 05  |.YE.a.....0..Z?.|
00000040  2b dd fe 97 00 6d 79 73  71 6c 5f 6e 61 74 69 76  |+....mysql_nativ|
00000050  65 5f 70 61 73 73 77 6f  72 64                    |e_password|

[header]
00000000  07 00 00 02                                       |....|

[payload]
00000000  00 00 00 02 00 00 00                              |.......|

以下、各やりとりの補足。

MySQL Packet

MySQL Client/Serverで通信するときのパケットの構成は、

  • 先頭3バイトはpayload_length
  • 次の1バイトはsequence_id
  • 残りがpayload

となっている(コード上では先頭4バイトをheaderと呼んでいる)。

MySQL :: MySQL Internals Manual :: 14.1.2 MySQL Packets

Initial Handshake Packetでの先頭4バイトが4a 00 00 00となっており、ここではpayload_lengthが4a 00 00である。最初、なんで最後じゃなくて先頭が4aになっているんだと思っていたが、little endian表記だとこうなるとのこと。

エンディアン - Wikipedia

Initial Handshake Packet

ここではplain-handshakeに従っている。

MySQL :: MySQL Internals Manual :: 14.2.1.1 Plain Handshake

Initial Handshake Packetはこちらを参照するといい。

https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::Handshake

Handshake Response Packet

Handshake Response Packetはこちらを参照するといい。

https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::HandshakeResponse

OK_packet

OK_packetはこちらを参照するといい。

https://dev.mysql.com/doc/internals/en/packet-OK_Packet.html

参考

MySQLユーザーのためのMySQLプロトコル入門 | GREE Engineers' Blog