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版本之前

基本流程如下:

  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 。

MySQL 通讯协议的更多相关文章

  1. 浅谈MySQL压缩协议细节--从源码层面

    压缩协议属于mysql通讯协议的一部分,要启用压缩协议传输功能,前提条件客户端和服务端都必须要支持zlib算法,那么,现在有个问题,假如服务端已经默认开启压缩功能,那原生客户端在连接的时候要如何才可启 ...

  2. TCP/IP协议,TCP与平台通信,通讯协议压力测试(python)

    最近的项目来了一个需求,要求测试tcp网关通讯协议: 1.液压井盖通过TCP/IP TCP与平台通信: 2.硬件定期发送心跳包(10S)给平台,是平台与硬件保持长连接: 3.每台硬件有一个12字节的唯 ...

  3. mysql交互协议解析——mysql包基础数据、mysql包基本格式

    mysql交互协议是开发mysql周边组件常用的协议,如JDBC,libmysql等等. 在此我们要认识到mysql交互协议其实是半双工的交互协议,至于为什么,这里就先挖个小坑,以后再填. 在探讨my ...

  4. 基于dubbo框架下的RPC通讯协议性能测试

    一.前言 Dubbo RPC服务框架支持丰富的传输协议.序列化方式等通讯相关的配置和扩展.dubbo执行一次RPC请求的过程大致如下:消费者(Consumer)向注册中心(Registry)执行RPC ...

  5. MODBUS-RTU通讯协议简介

    MODBUS-RTU通讯协议简介   什么是MODBUS? MODBUS 是MODICON公司最先倡导的一种软的通讯规约,经过大多数公司 的实际应用,逐渐被认可,成为一种标准的通讯规约,只要按照这种规 ...

  6. 【读书笔记】iOS-防止通讯协议被轻易破解的方法

    开发者可以选择类似Protobuf之类的二进制通讯协议或者自己实现通讯协议,对于传输的内容进行一定程度的加密,以增加黑客破解协议的难度. 参考资料: <iOS开发进阶> --唐巧

  7. CNN 美国有线电视新闻网 wapCNN WAP 指无线应用通讯协议 ---- 美国有线电视新闻网 的无线应用

    wapCNN  wap指无线应用通讯协议  CNN美国有线电视新闻网   固, wapCNN 美国有线电视新闻网的无线应用 -------------------------------------- ...

  8. Netty 对通讯协议结构设计的启发和总结

    Netty 通讯协议结构设计的总结 key words: 通信,协议,结构设计,netty,解码器,LengthFieldBasedFrameDecoder 原创 包含与机器/设备的通讯协议结构的设计 ...

  9. php与mysql通讯那点事

    php与mysql通讯那点事 http://www.cnxct.com/libmysql-mysqlnd-which-is-best-and-what-about-mysqli-pdomysql-my ...

随机推荐

  1. echarts使用踩坑实录之气泡图

    最近想做一个统计文章点击率,评论率和点赞率的功能,听说echarts可以轻易完成它,于是我就选择使用echarts,考虑到我做的模块上文章是没有分类的,所以我的统计是基于一个个点,这一看嘛,感觉散点图 ...

  2. 【翻译】Flume 1.8.0 User Guide(用户指南) Channel

    翻译自官网flume1.8用户指南,原文地址:Flume 1.8.0 User Guide 篇幅限制,分为以下5篇: [翻译]Flume 1.8.0 User Guide(用户指南) [翻译]Flum ...

  3. php日志报错child exited with code 0 after seconds from start

    因为日志文件老是有这种提示: [27-May-2015 15:13:48] NOTICE: [pool www] child 3998 started [27-May-2015 15:13:59] N ...

  4. (24)How generational stereotypes hold us back at work

    https://www.ted.com/talks/leah_georges_how_generational_stereotypes_hold_us_back_at_work/transcript ...

  5. 解决ORA-30036:无法按8扩展段(在还原表空间‘XXXX’中)

    在update一数据量很大的表时,提示“ORA-30036:无法按8扩展段” 度娘了下原因与解决办法:   1.查询了一下undo表空间的使用,发现已经超过了80% SELECT a.tablespa ...

  6. File(File f, String child) File(String parent, String child)

    (转载)File(File f, String child) 根据f 抽象路径名和 child 路径名字符串创建一个新 File 实例. f抽象路径名用于表示目录,child 路径名字符串用于表示目录 ...

  7. openvpn搭建和使用

    一.openvpn原理 openvpn通过使用公开密钥(非对称密钥,加密解密使用不同的key,一个称为Publice key,另外一个是Private key)对数据进行加密的.这种方式称为TLS加密 ...

  8. 6 week work 1

    CSS单位 em and rem: are often used to create scalable layouts, which maintain the vertical rhythm of t ...

  9. Java中基本数据和包装类的比较

    public class AutoBoxTest { public static void main(String[] args) { Integer a1 = 127; Integer a2 = 1 ...

  10. Jenkins关闭和重启实现方式.

    1.关闭Jenkins 只需要在访问jenkins服务器的网址url地址后加上exit.例如我jenkins的地址http://localhost:8080/,那么我只需要在浏览器地址栏上敲下http ...