URL
date
AI summary
slug
status
tags
summary
type
在前一篇文章分析MySQL连接问题的时候,顺带学习了一下连接建立的过程。
首先,在MySQL客户端和服务端之间的TCP连接建立之后,服务端会立马发起Handshake流程。此时发起的包在Wireshark上显示为Server Greeting包,而MySQL官方文档里叫做Handshake包。这个包是服务端用来告知客户端自己的SSL/TLS版本信息、自己支持的能力(比如有没有设置SSL/TLS相关的配置),并且会尝试乐观猜测一下客户端要用的身份认证插件(Authentication Plugin),也就是Server里配置的default_authentication_plugin(较新的版本可能还和authentication_policy有关)
客户端接收到这个Handshake包之后,会结合自己支持的能力,来判断后续应该走什么流程:
  1. 如果服务端配置了require_secure_transport = ON,那么客户端必须使用加密连接。如果客户端未开启SSL/TLS,则连接会报错
  1. 如果服务端未配置require_secure_transport = ON,那么启用不启用主要看客户端。在mysql-connector-java-5.1.48版本中,如果服务端是5.7以上的版本并且配置了SSL/TLS,那么客户端默认就会启用加密连接

普通连接

下面我们先看看不启用加密连接的情况。收到Handshake包之后,客户端开始尝试登录(去服务端验证身份),也就是会在Handshake Response里携带身份认证的信息。MySQL对于验证身份采用了插件化的思想,支持不同的AuthenticationPlugin。比如常见的mysql_native_passwordcaching_sha2_passwordsha256_password。不同的AuthenticationPlugin在服务端和客户端端都有对应的实现逻辑。
notion image

客户端确定Authentication Plugin

那么客户端此时如何确定用哪个身份认证插件去响应服务端呢?从mysql-connector-java-5.1.48来看,一般是用服务端下发的,除非一些特殊情况。
private void proceedHandshakeWithPluggableAuthentication(String user, String password, String database, Buffer challenge) throws SQLException { int counter = 100; while (0 < counter--) { if (!done) { if (challenge != null) { if (this.connection.getUseSSL()) { negotiateSSLConnection(user, password, database, packLength); } String pluginName = null; // Due to Bug#59453 the auth-plugin-name is missing the terminating NUL-char in versions prior to 5.5.10 and 5.6.2. if ((this.serverCapabilities & CLIENT_PLUGIN_AUTH) != 0) { if (!versionMeetsMinimum(5, 5, 10) || versionMeetsMinimum(5, 6, 0) && !versionMeetsMinimum(5, 6, 2)) { pluginName = challenge.readString("ASCII", getExceptionInterceptor(), this.authPluginDataLength); } else { pluginName = challenge.readString("ASCII", getExceptionInterceptor()); } } plugin = getAuthenticationPlugin(pluginName); if (plugin == null) { plugin = getAuthenticationPlugin(this.clientDefaultAuthenticationPluginName); } else if (pluginName.equals(Sha256PasswordPlugin.PLUGIN_NAME) && !isSSLEstablished() && this.connection.getServerRSAPublicKeyFile() == null && !this.connection.getAllowPublicKeyRetrieval()) { plugin = getAuthenticationPlugin(this.clientDefaultAuthenticationPluginName); skipPassword = !this.clientDefaultAuthenticationPluginName.equals(pluginName); } this.serverDefaultAuthenticationPluginName = plugin.getProtocolPluginName(); checkConfidentiality(plugin); fromServer = new Buffer(StringUtils.getBytes(this.seed)); } else { // no challenge so this is a changeUser call } } else { } // call plugin try { plugin.setAuthenticationParameters(user, skipPassword ? null : password); done = plugin.nextAuthenticationStep(fromServer, toServer); } catch (SQLException e) { throw SQLError.createSQLException(e.getMessage(), e.getSQLState(), e, getExceptionInterceptor()); } // send response if (toServer.size() > 0) { if (challenge == null) { // no challenge so this is a changeUser call } else if (challenge.isAuthMethodSwitchRequestPacket()) { // write Auth Method Switch Response Packet last_sent = new Buffer(toServer.get(0).getBufLength() + HEADER_LENGTH); last_sent.writeBytesNoNull(toServer.get(0).getByteBuffer(), 0, toServer.get(0).getBufLength()); send(last_sent, last_sent.getPosition()); } else if (challenge.isRawPacket() || old_raw_challenge) { // write raw packet(s) for (Buffer buffer : toServer) { last_sent = new Buffer(buffer.getBufLength() + HEADER_LENGTH); last_sent.writeBytesNoNull(buffer.getByteBuffer(), 0, toServer.get(0).getBufLength()); send(last_sent, last_sent.getPosition()); } } else { // write Auth Response Packet String enc = getEncodingForHandshake(); last_sent = new Buffer(packLength); last_sent.writeLong(this.clientParam); last_sent.writeLong(this.maxThreeBytes); appendCharsetByteForHandshake(last_sent, enc); last_sent.writeBytesNoNull(new byte[23]); // Set of bytes reserved for future use. // User/Password data last_sent.writeString(user, enc, this.connection); if ((this.serverCapabilities & CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA) != 0) { // send lenenc-int length of auth-response and string[n] auth-response last_sent.writeLenBytes(toServer.get(0).getBytes(toServer.get(0).getBufLength())); } else { // send 1 byte length of auth-response and string[n] auth-response last_sent.writeByte((byte) toServer.get(0).getBufLength()); last_sent.writeBytesNoNull(toServer.get(0).getByteBuffer(), 0, toServer.get(0).getBufLength()); } if (this.useConnectWithDb) { last_sent.writeString(database, enc, this.connection); } if ((this.serverCapabilities & CLIENT_PLUGIN_AUTH) != 0) { last_sent.writeString(plugin.getProtocolPluginName(), enc, this.connection); } // connection attributes if (((this.clientParam & CLIENT_CONNECT_ATTRS) != 0)) { sendConnectionAttributes(last_sent, enc, this.connection); } send(last_sent, last_sent.getPosition()); } } } if (counter == 0) { throw SQLError.createSQLException(Messages.getString("CommunicationsException.TooManyAuthenticationPluginNegotiations"), getExceptionInterceptor()); } try { this.mysqlConnection = this.socketFactory.afterHandshake(); } catch (IOException ioEx) { throw SQLError.createCommunicationsException(this.connection, this.lastPacketSentTimeMs, this.lastPacketReceivedTimeMs, ioEx, getExceptionInterceptor()); } }
通过上面的代码,首先可以看到客户端对于身份认证的处理是多轮的,需要和服务端协商确定:
  1. 优先使用服务端在Handshake包里下发的authentication_plugin
  1. 如果下发的插件客户端找不到,那么会使用客户端配置的默认插件
还有一种特殊情况,就是如果服务端下发的是sha256_password,但是在当前的配置条件下客户端无法获得服务端的RSA公钥,这种情况也会使用客户端配置的默认插件。但是如果此时客户端配置的不是sha256_password,此次请求不会设置密码,防止有安全风险,看注释的意思是会等后续服务端会下发Auth Switch切换认证方式,再发送密码进行身份验证。但是我很好奇,为什么只有sha256_password有这个优化链路,caching_sha2_password也需要RSA公钥啊,为什么没有这条优化链路?

Auth Switch

刚才提到的Auth Switch是身份验证过程中的一个特殊流程,就是当服务端发现客户端选择的插件和服务端为该用户配置的认证插件不一致的时候,会发起一个Auth Switch的流程,让客户端切换身份认证。也就是说,服务端会以mysql.user表里配置的插件为准,当客户端提交一个不一样的身份认证插件上来,会被服务端驳回并切换到正确的插件。
notion image

Authentication Plugin配置

Authentication Plugin配置在MySQL的用户表(mysql.user)里:
mysql> select user, host, authentication_string, plugin from mysql.user where user in ('test1', 'test11', 'test'); +--------+------+------------------------------------------------------------------------+-----------------------+ | user | host | authentication_string | plugin | +--------+------+------------------------------------------------------------------------+-----------------------+ | test | | $A$005$ Tss.S\\77RERCSJzyxzNvkVoegaB.Xmu38KI4QrwZaE3oh2K6Cy.CNIQ7 | caching_sha2_password | | test1 | % | $5$#>!6TaP-m:m&lp9INc}$ZdOraw1Hk7IUHnAm6.Z1605JEG3J23BSi0jRWA05Ba4 | sha256_password | | test11 | % | *2470C0C06DEE42FD1618BB99005ADCA2EC9D1E19 | mysql_native_password | +--------+------+------------------------------------------------------------------------+-----------------------+ 3 rows in set (0.00 sec)
其中plugin里存的就是该用户应该使用的身份认证插件,而authentication_string则是这个插件需要用来验证身份的相关信息(比如算法、加密轮次、Salt、密码经过加密之后的密文等等),不同的插件存储的信息不同。

三种常用的身份认证插件

这里介绍三种常用的插件

mysql_native_password

MySQL 5.6/5.7的默认密码插件一直以来都是mysql_native_password。其优点是它支持challenge-response机制,这是非常快的验证机制,无需在网络中发送实际密码,并且不需要加密的连接。然而,mysql_native_password依赖于SHA1算法,但NIST(美国国家标准与技术研究院)已建议停止使用SHA1算法,因为SHA1和其他哈希算法(例如MD5)已被证明非常容易破解。
此外,由于mysql_native_passwordmysql.user表中authentication_string字段存储的是两次哈希SHA1(SHA1(password))计算的值,也就是说如果两个用户帐户使用相同的密码,那么经过mysql_native_password转换后在mysql.user表得到的哈希值相同。尽管有hash值也无法得到实际密码信息,但它仍然告诉这两个用户使用了相同的密码。为了避免这种情况,应该给密码加盐(salt),salt基本上是被用作输入,用于转换用户密码的加密散列函数。由于salt是随机的,即使两个用户使用相同的密码,转换后的最终结果将发生较大的变化。
我们可以通过下面的sql来验证mysql_native_password的存储的authentication_string
select UPPER(SHA1(UNHEX(SHA1('password'))));

sha256_password

从MySQL 5.6开始支持sha256_password认证插件。它使用一个加盐密码(salted password)进行多轮SHA256哈希(数千轮哈希,暴力破解更难),以确保哈希值转换更安全。然而,它需要要么在安全连接或密码使用RSA秘钥对加密。所以,虽然密码的安全性更强,但安全连接和多轮hash转换需要在认证过程中的时间更长。
sha256_passwordauthentication_string总共是67个字节,其中3个$分隔符
内容
字节数
说明
哈希算法
1字节
5 表示 SHA256 算法、6 表示 SHA512 算法
盐(salt)
20字节
用于解决相同密码相同哈希值问题
哈希值
43字节

caching_sha2_password

为了克服这些限制,从MySQL 8.0.3开始,引入了一个新的身份验证插件caching_sha2_password。从 MySQL 8.0.4开始,此插件成为MySQL服务器的新默认身份验证插件。caching_sha2_password尝试一个两全其美的结合,既解决安全性问题又解决性能问题。
首先,是caching_sha2_password对用户密码的处理,其实主要是sha256_password的机制:
  • 使用SHA2算法来转换密码。具体来说,它使用SHA256算法。
  • 保存在authentication_string列中的哈希值为加盐后的值,由于盐是一个20-byte的随机数,即使两个用户使用相同的密码,转换后的最终结果也将完全不同。
  • 为了使使用暴力破解机制更难以猜测密码,在将最终转换存储在mysql.user表中之前,对密码和盐进行了 5000轮SHA2散列。
支持两种操作方式:
  • COMPLETE:要求客户端安全地发送实际密码(通过TLS连接或使用RSA密钥对)。服务器生成5000轮哈希,并与mysql.user中存储的值进行比较。
  • FAST:允许使用SHA2哈希的进行基于challenge-response的身份验证。同时实现高性能和安全性。
caching_sha2_passwordauthentication_string总共是70个字节,其中3个$分隔符(盐和hash中间没有分隔符)
内容
字节数
说明
哈希算法
1字节
目前仅为 A,表示 SHA256 算法
哈希轮转次数
3字节
目前仅为 005,表示 5*1000=5000 次
盐(salt)
20字节
用于解决相同密码相同哈希值问题
哈希值
43字节
sha256_passwordcaching_sha2_password的客户端插件在传递密码时,如果是加密连接,可以直接传输明文密码。如果是非加密连接,必须要用服务端的RSA公钥加密之后再传输。

抓包

下面展示几种不同身份认证场景下的抓包信息

用户配置和默认一致

都是mysql_native_password,可以看到,账号密码都正确,认证成功,客户端已经开始正常发送命令了
notion image

Auth Switch抓包

下面展示两个Auth Switch的抓包
一个是从caching_sha2_password切换到sha256_password,并且可以看到中间还向服务端请求了RSA公钥(这里客户端必须配置allowPublicKeyRetrieval=true)。当然也可以通过serverRSAPublicKeyFile来指定RSA公钥的文件路径。
notion image
一个是从caching_sha2_password切换到mysql_native_password
notion image
Auth Switch Request之后的Auth Switch Response,只需要发送加密后的密码信息即可。
💡
allowPublicKeyRetrieval默认值是false,因为打开了之后,可能会存在中间人攻击。 Note that allowPublicKeyRetrieval=True could allow a malicious proxy to perform a MITM attack to get the plaintext password, so it is False by default and must be explicitly enabled.

加密连接

如果双方都想要建立加密连接,那么就要先进入SSL/TLS握手流程:
notion image

SSL/TLS 握手

简单说一下SSL/TLS的握手过程:
notion image
  1. ClientHello
    1. 客户端声明自己支持的一些能力(TLS版本/加密方法/压缩方法等)
    2. 产生一个随机数,稍后用于生成“对话密钥”
  1. ServerHello
    1. 确定要用的TLS版本、加密方法等
    2. 产生一个随机数,稍后用于生成“对话密钥”
    3. 服务器证书下发
  1. 客户端回应
    1. 客户端先验证证书合法
    2. 再产生一个随机数(pre-master),用于生成“对话密钥”,并用服务端公钥加密
    3. 编码改变通知,表示随后的信息都将用双方商定的加密方法和密钥发送。
    4. 客户端握手结束通知,表示客户端的握手阶段已经结束。这一项同时也是前面发送的所有内容的hash值,用来供服务器校验
  1. 服务端最后回应
    1. 编码改变通知,表示随后的信息都将用双方商定的加密方法和密钥发送
    2. 服务器握手结束通知,表示服务器的握手阶段已经结束。这一项同时也是前面发送的所有内容的hash值,用来供客户端校验
其实握手主要就是确定TLS协议版本以及沟通产生对话密钥,用于接下来的加密通信。详细的过程可以阅读阮一峰老师的科普文章——SSL/TLS协议运行机制的概述

关于Sha256PasswordPlugin的理解误区

看了Sha256PasswordPlugin客户端插件的源代码,没有找到和Sha256相关的hash算法。反而是:
public class Sha256PasswordPlugin implements AuthenticationPlugin { protected byte[] encryptPassword() throws SQLException { return encryptPassword("RSA/ECB/OAEPWithSHA-1AndMGF1Padding"); } protected byte[] encryptPassword(String transformation) throws SQLException { byte[] input = null; try { input = this.password != null ? StringUtils.getBytesNullTerminated(this.password, this.connection.getPasswordCharacterEncoding()) : new byte[] { 0 }; } catch (UnsupportedEncodingException e) { throw SQLError.createSQLException(Messages.getString("Sha256PasswordPlugin.3", new Object[] { this.connection.getPasswordCharacterEncoding() }), SQLError.SQL_STATE_GENERAL_ERROR, null); } byte[] mysqlScrambleBuff = new byte[input.length]; Security.xorString(input, mysqlScrambleBuff, this.seed.getBytes(), input.length); return ExportControlled.encryptWithRSAPublicKey(mysqlScrambleBuff, ExportControlled.decodeRSAPublicKey(this.publicKeyString, this.connection.getExceptionInterceptor()), transformation, this.connection.getExceptionInterceptor()); } }
这不是Sha1吗?为什么要叫Sha256呢?
看了同样有人提过这个问题,但是里面也没有人解释,只是说了存在服务端的authentication_string的含义。不过看到这段话我突然醒悟:这个客户端的Sha256PasswordPlugin可能只是为了传输的安全性,而Sha256则是服务端计算authentication_string时用到的hash算法。正确的流程应该是,客户端通过RSA算法加密之后传到服务端,服务端解密后拿到密码的原值再进行Sha256哈希算法进行计算,计算之后和authentication_string里的签名部分进行比较。

用户不存在时的认证问题

如果登录用户不存在于mysql.user表不是应该直接报错么?你可能也是这么想的。但是测试下来事实好像并非如此。下面是两个异常CASE的抓包信息,两次认证的用户都不存在,default_authentication_plugin配置的是caching_sha2_password。但是两次都走了Auth Switch流程,并且不存在的两个用户,服务端切换到了不同的身份认证插件。
尝试了flush PRIVILEGES;或者重启MySQL,也没有效果,应该和caching_sha2_password的缓存无关

异常CASE1

MySQL Protocol Packet Length: 253 Packet Number: 1 Login Request Client Capabilities: 0xa28f Extended Client Capabilities: 0x013a MAX Packet: 16777215 Charset: utf8 COLLATE utf8_general_ci (33) Unused: 0000000000000000000000000000000000000000000000 Username: root111 Password: 6b62b5981db5ef7237c705c99832c5afa87b934833d923e6c1d406d374fd1f6d Schema: test Client Auth Plugin: caching_sha2_password Prefix: 139 Length: 139 Connection Attributes Payload: 00000000000000000000000000
notion image

异常CASE2

MySQL Protocol Packet Length: 254 Packet Number: 1 Login Request Client Capabilities: 0xa28f Extended Client Capabilities: 0x013a MAX Packet: 16777215 Charset: utf8 COLLATE utf8_general_ci (33) Unused: 0000000000000000000000000000000000000000000000 Username: root1111 Password: 4bceb582d8c4f7ae48ebdc420008f74fee6979c9e1cb45c14e15d19c709649b8 Schema: test Client Auth Plugin: caching_sha2_password Prefix: 139 Length: 139 Connection Attributes Payload: 00000000000000000000000000
notion image

参考

  1. MySQL原生密码认证
  1. 技术分享 | MySQL : SSL 连接浅析
  1. 故障分析 | Java 连接 MySQL 8.0 排错案例
  1. SSL/TLS协议运行机制的概述
  1. [MySQL] MySQL身份验证插件
  1. MySQL · 源码分析 · 鉴权过程
  1. Connection Phase
  1. 两个密码验证插件的故事
  1. mysql-8-0-4-new-default-authentication-plugin-caching_sha2_password
  1. 【得物技术】MySQL 8.0:新的身份验证插件(caching_sha2_password)
  1. 从源码分析 MySQL 身份验证插件的实现细节
MySQL OnlineDDL发展历程及各算法介绍记一次mysql连接问题