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 检查,然后才进入用户名密码验证阶段。
其中报文的格式如下。
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版本之前
基本流程如下:
- 服务器发送随机字符串 (scramble) 给客户端。可以参考
create_random_string()
的生成方法。 - 客户端把用户明文密码加密一下,然后再将其与服务器发送的随机字符串加密一下,然后变成了新的
scramble_buff
发送给服务端。可以参考scramble()
函数的实现。 - 服务端将
mysql.user.password
中的值加上原始随机字符串进行加密,如果加密后的值和客户端发送过来的内容一样,则验证成功。
需要注意的是:真正意义上的密码是明文密码的加密 hash 值; 如果有人知道了这个用户的 password 哈希值,而不用知道原始明文密码,实际上他就能直接登录服务器。
4.1 以后版本
数据库中保存的密码是用 SHA1(SHA1(password)) 加密的,其流程为:
- 服务器发送随机字符串 (scramble) 给客户端。
-
客户端作如下计算,然后客户端将 token 发送给服务端。
stage1_hash = SHA1(明文密码)
token = SHA1(scramble + SHA1(stage1_hash)) XOR stage1_hash
-
服务端作如下计算,比对 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 。