Fork me on GitHub

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. 1. 三次握手建立 TCP 连接。
  2. 2. 建立 MySQL 连接,也就是认证阶段。
  3. 服务端 -> 客户端:发送握手初始化包 (Handshake Initialization Packet)。
  4. 客户端 -> 服务端:发送验证包 (Client Authentication Packet)。
  5. 服务端 -> 客户端:认证结果消息。
  6. 3. 认证通过之后,客户端开始与服务端之间交互,也就是命令执行阶段。
  7. 客户端 -> 服务端:发送命令包 (Command Packet)。
  8. 服务端 -> 客户端:发送回应包 (OK Packet, or Error Packet, or Result Set Packet)。
  9. 4. 断开 MySQL 连接。
  10. 客户端 -> 服务器:发送退出命令包。
  11. 5. 四次握手断开 TCP 连接。

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

报文格式

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

  1. +------------------------+-------------------+---------------------------------------------------+
  2. | 3 Bytes | 1 Byte | N Bytes |
  3. +----------------------- +-------------------+---------------------------------------------------+
  4. |<= length of msg =>|<= sequence =>|<============= data ================>|
  5. |<============ 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 结尾,分为了有两种形式:

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

客户端请求报文

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

  1. +----------------------------+------------------------------------------------------------------+
  2. | 1 Bytes | N Bytes |
  3. +----------------------------+------------------------------------------------------------------+
  4. |<==== command ====>|<============== arguments ====================>|

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

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

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

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

  1. 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 分析。

  1. ----- 将抓包的数据保存到文件
  2. # tcpdump -i lo port 3306 -w filename
  3. ----- 当然,也可以打印到终端,然后处理数据
  4. # tcpdump -i lo port 3306 -nn -X -q
  5. # 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. 1. the server sends the random scramble to the client.
  2. 2. client sends the encrypted password back to the server.
  3. 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 发送给服务端。

    1. stage1_hash = SHA1(明文密码)
    2. 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 本身。对于当前情况的话,实际的计算过程如下。

  1. token = SHA1(scramble + SHA1(SHA1(password))) XOR SHA1(password) // 客户端返回的值
  2. = PASSWORD XOR SHA1(password)
  3. stage1_hash = token XOR SHA1(scramble + mysql.user.password) = token XOR PASSWORD
  4. = [PASSWORD XOR SHA1(password)] XOR PASSWORD
  5. = SHA1(password)

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

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

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

源码分析

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

客户端

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

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

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

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

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

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

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

  1. int read_and_execute(bool interactive)
  2. {
  3. while (!aborted) {
  4. if (!interactive) { // 是否为交互模式
  5. ... ... // 非交互模式,直接执行
  6. } else { // 交互模式
  7. char *prompt = ...; // 首先会设置提示符
  8. line = readline(prompt); // 从命令行读取
  9. if ( ... && (com= find_command(line))) { // 从commands[]中查找
  10. (*com->func)(&glob_buffer,line); // 如果是help、edit等指令,则直接执行
  11. }
  12. add_line(...); // 常见的SQL,最终在此执行
  13. }
  14. }
  15. }
  16. int com_go(String *buffer,char *line)
  17. {
  18. timer=start_timer(); // 设置时间
  19. error= mysql_real_query_for_lazy(buffer->ptr(),buffer->length()); // 执行查询SQL
  20. do {
  21. // 获取结果
  22. } while(!(err= mysql_next_result(&mysql)));
  23. }

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

服务端

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

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

  1. handle_one_connection()
  2. |-thd_prepare_connection()
  3. |-login_connection()
  4. |-check_connection()
  5. |-acl_authenticate()

源码内容如下。

  1. /* sql/sql_connect.cc */
  2. int check_connection(THD *thd)
  3. {
  4. if (!thd->main_security_ctx.host) { // 通过TCP/IP连接,或者本地用-h 127.1
  5. if (acl_check_host(...)) // 检查hostname
  6. } else { // 使用unix sock连接,不会进行检测
  7. ... ...
  8. }
  9. return acl_authenticate(thd, connect_errors, 0)
  10. }
  11. /* sql/sql_acl.cc */
  12. bool acl_authenticate(THD *thd, uint connect_errors, uint com_change_user_pkt_len)
  13. {
  14. if (command == COM_CHANGE_USER) {
  15. } else {
  16. do_auth_once() // 执行认证模式
  17. }
  18. }

在 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 参数自动加载相应的授权插件。

  1. ----- 获得foobar的加密格式
  2. mysql> select password('foobar');
  3. ----- 旧的加密格式
  4. mysql> select old_password('foobar');
  5. ----- 默认方式
  6. mysql> create user 'foobar2'@'localhost' identified via mysql_native_password using 'xxx';
  7. ----- 也可以动态加载
  8. mysql> install plugin test_plugin_server soname 'auth_test_plugin.so';
  9. ----- 查看当前支持的插件
  10. mysql> select * from information_schema.plugins where plugin_type='authentication';
  11. mysql> create user 'foobar'@'localhost' identified with test_plugin_server;
  12. mysql> SET PASSWORD FOR 'foobar'@'localhost'=PASSWORD('new_password');
  13. mysql> DROP USER 'foobar'@'localhost';
  14. mysql> FLUSH PRIVILEGES;
  15. mysql> SELECT host, user, password, plugin FROM mysql.user;

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

总结

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

参考

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

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

MySQL 通讯协议

2020-05-12 20:13:21  LeeChan 阅读(49) 评论(0) 标签:MySQL,通讯协议 分类:技术编程