MySQL 通讯协议

Client/Server 通讯协议用于客户端链接、代理、主备复制等,支持 SSL、压缩,在链接阶段进行认证,在执行命令时可以支持 Prepared Statements 以及 Stored Procedures 。

当打算编写数据库代理、中间件、对 MySQL 数据包进行审核时,都需要了解底层的通信协议。在本文中,主要介绍 MySQL 通讯协议相关的内容。

简介

服务器启动后,会使用 TCP 监听一个本地端口,当客户端的连接请求到达时,就会执行三段握手以及 MySQL 的权限验证;验证成功后,客户端开始发送请求,服务器会以响应的报文格式返回数据;当客户端发送完成后,会发送一个特殊的报文,告知服务器已结束会话。

MySQL 定义了几种包类型,A) 客户端->服务器,登录时的 auth 包、执行 SQL 时的 CMD 包;B) 服务器->客户端,登录时的握手包、数据包、数据流结束包、成功包(OK Packet)、错误信息包。

协议定义了基本的数据类型,如 int、string 等;数据的传送格式等。

协议

MySQL 的客户端与服务器之间支持多种通讯方式,最广泛使用的是 TCP 通讯;另外,还支持命名管道和共享内存,而 TCP 就是最通用的一种方式,在此仅介绍 TCP 方式。

在 C/S 之间,实际采用的是一种类似半双工式的模式收发数据,即在一个 TCP 链路上,客户端发出请求数据后,只有在接收完所有的服务端响应数据以后才能发下一次请求,中间不能发其它数据,需要有很强的顺序性要求。

MySQL 客户端与服务器的交互主要分为两个阶段,分别为握手认证阶段和命令执行阶段,详细来说一次正常的过程如下:

1. 三次握手建立 TCP 连接。

2. 建立 MySQL 连接,也就是认证阶段。
    服务端 -> 客户端:发送握手初始化包 (Handshake Initialization Packet)。
    客户端 -> 服务端:发送验证包 (Client Authentication Packet)。
    服务端 -> 客户端:认证结果消息。

3. 认证通过之后,客户端开始与服务端之间交互,也就是命令执行阶段。
    客户端 -> 服务端:发送命令包 (Command Packet)。
    服务端 -> 客户端:发送回应包 (OK Packet, or Error Packet, or Result Set Packet)。

4. 断开 MySQL 连接。
    客户端 -> 服务器:发送退出命令包。

5. 四次握手断开 TCP 连接。

从服务器发往客户端的数据包有四种:数据包、数据结束包、成功报告包以及错误消息包。Result Set Packet 首先发送包头+列包+EOF包+行包+EOF包。

报文格式

所有的包有统一的格式,并通过函数 my_net_write()@sql/net_serv.cc 写入 buffer 等待发送。

+-------------------+--------------+---------------------------------------------------+
|      3 Bytes      |    1 Byte    |                   N Bytes                         |
+-------------------+--------------+---------------------------------------------------+
|<= length of msg =>|<= sequence =>|<==================== data =======================>|
|<============= header ===========>|<==================== body =======================>|

MySQL 报文格式如上,消息头包含了 A) 报文长度,标记当前请求的实际数据长度,以字节为单位;B) 序号,为了保证交互时报文的顺序,每次客户端发起请求时,序号值都会从 0 开始计算。

消息体用于存放报文的具体数据,长度由消息头中的长度值决定。

单个报文的最大长度为 (2^24-1)Bytes ,也即 (16M-1)Bytes,对于包长为 (2^24-1)Bytes 也会拆为两个包发送。这是因为最初没有考虑 16M 的限制,从而没有预留任何字段来标志这个包的数据不完整,所以只好把长度为 (2^24-1) 的包视做不完整的包,直到后面收到一个长度小于 (2^24-1) 的包,然后拼起来。

这也意味着最后一个包的长度有可能是 0。

基本类型

接下来介绍一下报文中的数据类型,也就是不同的数据类型在报文中的表现形式。

整型值

MySQL 报文中整型值分别有 1、2、3、4、8 字节长度,使用小字节序传输。

二进制数据

也就是 Length Coded Binary,其数据长度不固定,长度值由数据前的 1-9 个字节决定,其中长度值所占的字节数不定,字节数由第 1 个字节决定,如下:

第一个字节值    后续字节数  长度值说明
    0-250            0   第一个字节值即为数据的真实长度
      251            0   空数据,数据的真实长度为零
      252            2   后续额外2个字节标识了数据的真实长度
      253            3   后续额外3个字节标识了数据的真实长度
      254            8   后续额外8个字节标识了数据的真实长度

字符串

根据是否以 NULL 结尾,分为了有两种形式:

  • 以 NULL 结尾,Null-Terminated String
    字符串长度不固定,当遇到 'NULL'(0x00) 字符时结束。
  • 长度编码,Length Coded String
    字符串长度不固定,无 'NULL'(0x00) 结束符,编码方式与上面的二进制数据相同。

客户端请求报文

也就是从客户端发送到服务端的请求命令。

+-------------------+------------------------------------------------------------------+
|      1 Bytes      |                             N Bytes                              |
+-------------------+------------------------------------------------------------------+
|<==== command ====>|<============================ arguments =========================>|

客户端向服务端发送的请求,其中第一个字节用于标识当前请求消息的类型,这也就定义了请求的种类,其中包括了:切换数据库 COM_INIT_DB(0x02)、查询命令 COM_QUERY(0x03) 等。

命令的宏定义在 include/mysql_com.h 文件中,该命令会在 dispatch_command() 中根据不同的命令进入不同代码处理逻辑。

报文中的参数内容是用户在 MySQL 客户端输入的命令,不包括每行命令结尾的 ';' 分号,采用的是非 NULL 结尾的字符串表示方法。

例如:当在 MySQL 客户端中执行 use mysql; 命令时,发送的请求报文数据会是下面的样子:

0x02 0x6d 0x79 0x73 0x71 0x6c

0x02 为请求类型值 COM_INIT_DB,后面的 0x6d 0x79 0x73 0x71 0x6c 为 ASCII 字符 mysql 。

错误码

也就是当发生了错误之后,服务端发送给客户端的报文。

MySQL 的错误包含了三部分:A) MySQL 特定的错误码,数字类型,不通用;B) SQLSTATE,为 5 个字符的字符串,采用 ANSI SQL 和 ODBC 的标准;C) 错误信息。

对于错误报文的格式可以参照参考文件,其中第二字节表示由 MySQL 定义的错误编码,服务器状态实际是 ANSI SQL 对应的编码,两者并非一一对应。

在 MySQL 中可以通过 perror ERROR 查看;详细的文档,可以参考官方文档 Appendix B Errors, Error Codes, and Common Problems 。

抓包分析

可以通过 tcpdump 捕获包并保存在文件中,然后通过 Wireshark 打开文件,查看网络包的内容,相对来说比较方便。可以通过 tcpdump -D 查看支持的网卡接口,通过 -i 指定接口,在此使用 lo

注意,tcpdump 不能捕获 unix socket,链接时不能使用 -S /tmp/mysql.sock 或者 -h localhost 参数,应当使用 -h 127.1 。

可以将 tcpdump 的包输出到 stdout 或者通过 -w 保存到文件,然后用 Wireshark 分析。

----- 将抓包的数据保存到文件
# tcpdump -i lo port 3306 -w filename

----- 当然,也可以打印到终端,然后处理数据
# tcpdump -i lo port 3306 -nn -X -q
# tcpdump -i any -s 0 -l -w - dst port 3306 | strings | grep -iE 'select|update'

认证协议

认证稍微有点复杂,单独拉出来。

MySQL 的用户管理模块信息存储在系统表 mysql.user 中,其中包括了授权用户的基本信息以及一些权限信息。在登陆时,只会用到 host、user、passwd 三个字段,也就是说登陆认证需要 host+user 关联,当然可以使用 * 通配符。

服务器在收到新的连接请求时,会调用 login_connection() 作身份验证,先根据 IP 做 ACL 检查,然后才进入用户名密码验证阶段。

其中报文的格式如下。

auth protocol

MySQL 认证采用经典的 CHAP 协议,即挑战握手认证协议,在 native_password_authenticate()函数的注释中简单介绍了该协议的执行过程:

1. the server sends the random scramble to the client.
2. client sends the encrypted password back to the server.
3. the server checks the password.

random scramble 在 4.1 之前的版本中是 8 字节整数,在 4.1 以及后续版本是 20 字节整数,该值是通过 create_random_string() 函数生成。

根据版本不同,分为了两类。

4.0版本之前

基本流程如下:

  1. 服务器发送随机字符串 (scramble) 给客户端。可以参考 create_random_string() 的生成方法。
  2. 客户端把用户明文密码加密一下,然后再将其与服务器发送的随机字符串加密一下,然后变成了新的 scramble_buff 发送给服务端。可以参考 scramble() 函数的实现。
  3. 服务端将 mysql.user.password 中的值加上原始随机字符串进行加密,如果加密后的值和客户端发送过来的内容一样,则验证成功。

需要注意的是:真正意义上的密码是明文密码的加密 hash 值; 如果有人知道了这个用户的 password 哈希值,而不用知道原始明文密码,实际上他就能直接登录服务器。

4.1 以后版本

数据库中保存的密码是用 SHA1(SHA1(password)) 加密的,其流程为:

  1. 服务器发送随机字符串 (scramble) 给客户端。
  2. 客户端作如下计算,然后客户端将 token 发送给服务端。

    stage1_hash = SHA1(明文密码)

    token = SHA1(scramble + SHA1(stage1_hash)) XOR stage1_hash

  3. 服务端作如下计算,比对 SHA1(stage1_hash) 和 mysql.user.password 是否相同。

    stage1_hash = token XOR SHA1(scramble + mysql.user.password)

这里实际用到了异或的自反性: A XOR B XOR B = A ,对于给定的数 A,用同样的运算因子 B 作两次异或运算后仍得到 A 本身。对于当前情况的话,实际的计算过程如下。

token = SHA1(scramble + SHA1(SHA1(password))) XOR SHA1(password)         // 客户端返回的值
      = PASSWORD XOR SHA1(password)

stage1_hash = token XOR SHA1(scramble + mysql.user.password) = token XOR PASSWORD
            = [PASSWORD XOR SHA1(password)] XOR PASSWORD
            = SHA1(password)

因此,校验时,只需要 SHA1(stage1_hash) 与 mysql.user.password 比较一下即可。

这次没上一个版本的缺陷了. 有了 mysql.user.password 和 scramble 也不能获得 token,因为没法获得 stage1_hash

但是如果用户的 mysql.user.password 泄露,并且可以在网络上截取的一次完整验证数据,从而可以反解出 stage1_hash 的值。而该值是不变的,因此下次连接获取了新的 scramble 后,自己加密一下 token 仍然可以链接到服务器。

源码分析

接下来分别介绍客户端、服务端的程序。

客户端

对于 mysql 客户端,源码保存在 client/mysql.cc 文件中,下面是 main() 函数的主要执行流程。

main()
 |-sql_connect()
 | |-sql_real_connect()
 |   |-mysql_init()                             # 调用MySQL初始化
 |   |-mysql_options()                          # 设置链接选项
 |   |-mysql_real_connect()                     # sql-common/client.c
 |     |-connect_sync_or_async()                # 通过该函数尝试链接
 |     | |-my_connect()                         # 实际通过该函数建立链接
 |     |-cli_safe_read()                        # 等待第一个handshake包
 |     |-run_plugin_auth()                      # 通过插件实现认证
 |
 |-put_info()                                   # 打印客户端的欢迎信息
 |-read_and_execute()                           # 开始等待输入、执行SQL

客户端最终会调用 mysql_real_connect(),实际调用的是 cli_mysql_real_connect(),通过该函数建立链接,其中认证方式可以通过 run_plugin_auth() 时用插件实现。

然后,会输出一系列的欢迎信息,并通过 read_and_execute() 执行 SQL 命令。

在 MySQL 客户端执行时,并非所有的命令都是需要发送到服务端的,其中有一个数组定义了常见的命令。

static COMMANDS commands[] = {
  { "?",      '?', com_help,   1, "Synonym for `help'." },
  { "clear",  'c', com_clear,  0, "Clear the current input statement."},
  ... ...
};

每次读取一行都会通过 find_command() 函数进行检测,如果满足对应的命令,且对应的函数变量非空,则直接执行,如 clear,此时不需要输入分号即可;如果没有找到,则必须要等待输入分号。

int read_and_execute(bool interactive)
{
    while (!aborted) {
        if (!interactive) {                               // 是否为交互模式
            ... ...   // 非交互模式,直接执行
        } else {                                          // 交互模式
            char *prompt = ...;                           // 首先会设置提示符
            line = readline(prompt);                      // 从命令行读取
            if ( ... && (com= find_command(line))) {      // 从commands[]中查找
                (*com->func)(&glob_buffer,line);          // 如果是help、edit等指令,则直接执行
            }
            add_line(...);                                // 常见的SQL,最终在此执行
        }
    }
}

int com_go(String *buffer,char *line)
{
    timer=start_timer();                                                // 设置时间
    error= mysql_real_query_for_lazy(buffer->ptr(),buffer->length());   // 执行查询SQL
    do {
        // 获取结果
    } while(!(err= mysql_next_result(&mysql)));
}

在 add_line() 函数中,最终会调用 com_go() 函数,该函数是执行的主要函数,会最终调用 MySQL API 执行相应的 SQL、返回结果、输出时间等统计信息。

服务端

服务端通过 network_init() 执行一系列初始化之后,会阻塞在 handle_connections_sockets() 函数的 select()/poll() 函数处。

对于 one_thread_per_connection 这种方式,会新建一个线程执行 handle_one_connection() 。

handle_one_connection()
 |-thd_prepare_connection()
   |-login_connection()
     |-check_connection()
       |-acl_authenticate()

源码内容如下。

/* sql/sql_connect.cc */
int check_connection(THD *thd)
{
    if (!thd->main_security_ctx.host) {  // 通过TCP/IP连接,或者本地用-h 127.1
         if (acl_check_host(...))        // 检查hostname
    } else {                             // 使用unix sock连接,不会进行检测
        ... ...
    }
    return acl_authenticate(thd, connect_errors, 0)
}

/* sql/sql_acl.cc */
bool acl_authenticate(THD *thd, uint connect_errors, uint com_change_user_pkt_len)
{
    if (command == COM_CHANGE_USER) {

    } else {
        do_auth_once()                      // 执行认证模式

    }
}

在 acl_check_host() 会检查两个对象,一个是 hash 表 acl_check_hosts;另一个是动态数组 acl_wild_hosts 。这2个对象是在启动的时候,通过 init_check_host() 从 mysq.users 表里读出并加载的,其中 acl_wild_hosts 用于存放有统配符的主机,acl_check_hosts 存放具体的主机。

最终会调用 acl_authenticate() 这是主要的认证函数。

插件实现

MySQL 的认证授权采用插件实现。

默认采用 mysql_native_password 插件,也就是使用 native_password_auth_client() 作用户端的认证,实际有效的函数是 scramble()

上述的函数通过用户输入的 password、服务器返回的 scramble 生成 reply,返回给服务端;可以通过 password('string') 查看加密后的密文。

以 plugin/auth/ 目录下的插件为例,在启动服务器时,可添加 --plugin-load=auth_test_plugin.so参数自动加载相应的授权插件。

----- 获得foobar的加密格式
mysql> select password('foobar');
----- 旧的加密格式
mysql> select old_password('foobar');
----- 默认方式
mysql> create user 'foobar2'@'localhost' identified via mysql_native_password using 'xxx';

----- 也可以动态加载
mysql> install plugin test_plugin_server soname 'auth_test_plugin.so';
----- 查看当前支持的插件
mysql> select * from information_schema.plugins where plugin_type='authentication';

mysql> create user 'foobar'@'localhost' identified with test_plugin_server;
mysql> SET PASSWORD FOR 'foobar'@'localhost'=PASSWORD('new_password');
mysql> DROP USER 'foobar'@'localhost';
mysql> FLUSH PRIVILEGES;
mysql> SELECT host, user, password, plugin FROM mysql.user;

在 plugin 目录下有很多 auth 插件可供参考,详细可参考官网 Writing Authentication Plugins 。

总结

在如下列举客户端与服务端的详细交互过程,其中客户端代码在 client 目录下。

### Client(mysql)  ###                       ### Server(mysqld) ###
----------------------------------------     --------------------------------------------------
main()                                       mysqld_main()
 |-sql_connect()                              |-init_ssl()
 | |-sql_real_connect() {for(;;)}             |-network_init()
 |   |-mysql_init()                           |-handle_connections_sockets()
 |   |-init_connection_options()                |-select()/poll()
 |   |-mysql_real_connect()                     |
 |     |-cli_mysql_real_connect()               |
 |       |-socket()                             |
 |       |-vio_new()                            |
 |       |-vio_socket_connect()                 |
 |       | |-mysql_socket_connect()             |
 |       |   |-connect()                        |
 |       |   |                                  |
 |       |   |        [Socket Connect]          |
 |       |   |>>==========>>==========>>======>>|
 |       |                                      |-accept()
 |       |-vio_keepalive()                      |-vio_new()
 |       |-my_net_set_read_timeout()            |-my_net_init()
 |       |-my_net_set_write_timeout()           |-create_new_thread()
 |       |-vio_io_wait()                          |-handle_one_connection()    {新线程}
 |       |                                          |-thd_prepare_connection() {for(;;)}
 |       |                                          | |-login_connection()
 |       |                                          |   |-check_connection()
 |       |                                          |     |-acl_check_host()
 |       |                                          |     |-vio_keepalive()
 |       |                                          |     |-acl_authenticate()
 |       |                                          |       |-do_auth_once()
 |       |                                          |       | |-native_password_authenticate()  {插件实现}
 |       |                                          |       |   |-create_random_string()
 |       |                                          |       |   |-send_server_handshake_packet()
 |       |                                          |       |   |
 |       |              [Handshake Initialization]  |       |   |
 |       |<<==<<==========<<==========<<==========<<==========<<|
 |       |-cli_safe_read()                          |       |   |-my_net_read()
 |       |-run_plugin_auth()                        |       |   |
 |       | |-native_password_auth_client()          |       |   |
 |       |   |-scramble()                           |       |   |
 |       |     |-my_net_write()                     |       |   |
 |       |     |                                    |       |   |
 |       |     |            [Client Authentication] |       |   |
 |       |     |>>==========>>==========>>==========>>========>>|
 |       |                                          |       |   |-check_scramble()
 |       |                                          |       |-mysql_change_db()
 |       |                                          |       |-my_ok()
 |       |                      [OK]                |       |
 |       |<<==========<<==========<<==========<<==========<<|
 |       |-cli_safe_read()                          |
 |                                                  |
 |                                                  |
 |                                                  |
 |                                                  |
 |-put_info() {Welcome Info}                        |
 |-read_and_execute() [for(;;)]                     |
                                                    |-thd_is_connection_alive()  [while()]
                                                    |-do_command()

参考

关于 MySQL的认证流程,包括客户端和服务器端,可以参考本地 MySQL认证协议;详细的协议介绍可以参考 MySQL Client/Server Protocol,或者 中文资料,或者保存的本地资料 MySQL服务器和客户端通信协议分析 。

MySQL 的认证授权可以采用插件,在 plugin 目录下有很多 auth 插件可供参考,具体可以参考官网的 MySQL Reference - Writing Authentication Plugins 。

posted @ 2019-04-04 15:32  邱明成  阅读(2144)  评论(0编辑  收藏  举报