写点什么

从源码分析 MySQL 身份验证插件的实现细节

  • 2024-01-12
    福建
  • 本文字数:5945 字

    阅读完需:约 20 分钟

最近在分析ERROR 1045 (28000): Access denied for user 'root'@'localhost' (using password: YES)这个报错的常见原因。


在分析的过程中,不可避免会涉及到 MySQL 身份验证的一些实现细节。


加之之前对这一块就有很多疑问,包括:


  1. 一个明文密码,是如何生成 mysql.user 表中的 authentication_string?


  1. 在进行身份验证时,客户端是否会直接发送明文密码给 MySQL 服务端?


  1. MySQL 8.0 为什么要将默认的身份认证插件调整为 caching_sha2_password,mysql_native_password 有什么问题嘛?


所以,就从代码层面对 MySQL 身份验证插件(主要是 mysql_native_password)的一些实现细节进行了分析。


本文主要包括以下几部分:


  1. 服务端是如何对明文密码进行加密的?

  2. 服务端是如何进行客户端身份验证的?

  3. 客户端是如何处理明文密码的?会直接发送明文密码给服务端么?

  4. 服务端是如何验证客户端密码是否正确的?

  5. 为什么 MySQL 8.0 要将默认的身份认证插件调整为 caching_sha2_password?


服务端是如何对明文密码进行加密的?


在 mysql_native_password 中,对明文密码进行加密是在 my_make_scrambled_password_sha1函数中实现的。


// sql/auth/password.ccvoid my_make_scrambled_password_sha1(char *to, const char *password,                                     size_t pass_len) {  uint8 hash_stage2[SHA1_HASH_SIZE];
  /* Two stage SHA1 hash of the password. */  compute_two_stage_sha1_hash(password, pass_len, (uint8 *)to, hash_stage2);
  /* convert hash_stage2 to hex string */  *to++ = PVERSION41_CHAR;  octet2hex(to, (const char *)hash_stage2, SHA1_HASH_SIZE);}
// sql/auth/password.ccinline static void compute_two_stage_sha1_hash(const char *password,                                               size_t pass_len,                                               uint8 *hash_stage1,                                               uint8 *hash_stage2) {  /* Stage 1: hash password */  compute_sha1_hash(hash_stage1, password, pass_len);
  /* Stage 2 : hash first stage's output. */  compute_sha1_hash(hash_stage2, (const char *)hash_stage1, SHA1_HASH_SIZE);}
复制代码


实现其实非常简单:


  1. 使用 OpenSSL 库中的函数对输入的密码进行 SHA-1 哈希,生成 hash_stage1。


  1. 对生成的 hash_stage1 进行二次 SHA-1 哈希,生成 hash_stage2。


3 .将 hash_stage2 转换为十六进制表示。


最后生成的字符串即我们在mysql.user中看到的authentication_string


相同的功能用下面这段 Python 代码很容易就能实现出来。


import hashlib
def compute_sha1_hash(data):    sha1 = hashlib.sha1()    sha1.update(data)    return sha1.digest()
password = "123456".encode('utf-8')hash_stage1 = compute_sha1_hash(password)hash_stage2 = compute_sha1_hash(hash_stage1)print('*%s'%hash_stage2.hex().upper())
复制代码


密码是123456,最后打印的结果是 *6BB4837EB74329105EE4568DDA7DC67ED2CA2AD9


mysql.user中的authentication_string的值完全一样。


mysql> create user u1@'%' identified with mysql_native_password by '123456';Query OK, 0 rows affected (0.04 sec)
mysql> select user,host,authentication_string from mysql.user where user='u1';+------+------+-------------------------------------------+| user | host | authentication_string                     |+------+------+-------------------------------------------+| u1   | %    | *6BB4837EB74329105EE4568DDA7DC67ED2CA2AD9 |+------+------+-------------------------------------------+1 row in set (0.00 sec)
复制代码


有木有一种很简单的感觉?


服务端是如何进行客户端身份验证的?


在 mysql_native_password 中,对客户端进行身份验证是在 native_password_authenticate函数中实现的。


static int native_password_authenticate(MYSQL_PLUGIN_VIO *vio,                                        MYSQL_SERVER_AUTH_INFO *info) {  uchar *pkt;  int pkt_len;  MPVIO_EXT *mpvio = (MPVIO_EXT *)vio;
  DBUG_TRACE;
  // 生成盐值(Salt)。  if (mpvio->scramble[SCRAMBLE_LENGTH])    generate_user_salt(mpvio->scramble, SCRAMBLE_LENGTH + 1);
  // 将盐值发送给客户端  if (mpvio->write_packet(mpvio, (uchar *)mpvio->scramble, SCRAMBLE_LENGTH + 1))    return CR_AUTH_HANDSHAKE;
  // 读取客户端的响应,其中pkt用来存储响应包的内容,pkt_len是包的长度。  if ((pkt_len = mpvio->read_packet(mpvio, &pkt)) < 0) return CR_AUTH_HANDSHAKE;  DBUG_PRINT("info", ("reply read : pkt_len=%d", pkt_len));
  ...  // 如果响应包的长度为0,则意味着客户端没有指定密码  if (pkt_len == 0) {    info->password_used = PASSWORD_USED_NO;    return mpvio->acl_user->credentials[PRIMARY_CRED].m_salt_len != 0               ? CR_AUTH_USER_CREDENTIALS               : CR_OK;  } else    info->password_used = PASSWORD_USED_YES;  bool second = false;  // 如果响应包的长度等于盐值的长度,则会验证密码是否正确。  if (pkt_len == SCRAMBLE_LENGTH) {    if (!mpvio->acl_user->credentials[PRIMARY_CRED].m_salt_len ||        check_scramble(pkt, mpvio->scramble,                       mpvio->acl_user->credentials[PRIMARY_CRED].m_salt)) {      second = true;      // 如果验证失败,则会验证第二个密码是否设置且正确。      // 在 MySQL 8.0 中,一个账户可以设置两个密码。      if (!mpvio->acl_user->credentials[SECOND_CRED].m_salt_len ||          check_scramble(pkt, mpvio->scramble,                         mpvio->acl_user->credentials[SECOND_CRED].m_salt)) {        return CR_AUTH_USER_CREDENTIALS;      } else {        if (second) {...}        return CR_OK;      }    } else {      return CR_OK;    }  }
  my_error(ER_HANDSHAKE_ERROR, MYF(0));  return CR_AUTH_HANDSHAKE;}
复制代码


该函数的主要作用如下:


  1. 通过generate_user_salt生成一个 20 位的盐值(Salt)。

    "盐值"(Salt)是密码学中一个常用的概念。它是一个随机生成的数据块,通常与密码一同进行哈希。

    相同的密码,由于盐值的不同,生成的哈希值也会不同。

    引入盐值可有效防止彩虹表攻击和碰撞攻击,提高密码的安全性。

  2. 将盐值发送给客户端。客户端会基于盐值对明文密码进行加密(具体的加密细节后面会介绍),然后将加密后的结果返回给服务端。

  3. 读取客户端的响应。

  4. 如果响应包的长度等于盐值的长度,则会调用 check_scramble验证客户端返回的加密密码是否与数据库中存储的加密密码相匹配(具体的匹配细节后面会介绍)。


客户端是如何处理明文密码的?


这里以 JDBC 驱动为例,客户端在接受到 MySQL 服务端发送的盐值后,会调用Security类中的scramble411方法对明文密码进行加密。


下面我们看看具体的实现细节。


// src/main/core-impl/java/com/mysql/cj/protocol/Security.javapublic static byte[] scramble411(byte[] password, byte[] seed) {    MessageDigest md;    try {        md = MessageDigest.getInstance("SHA-1");    } catch (NoSuchAlgorithmException ex) {        throw new AssertionFailedException(ex);    }
    byte[] passwordHashStage1 = md.digest(password);    md.reset();
    byte[] passwordHashStage2 = md.digest(passwordHashStage1);    md.reset();
    md.update(seed);    md.update(passwordHashStage2);
    byte[] toBeXord = md.digest();
    int numToXor = toBeXord.length;
    for (int i = 0; i < numToXor; i++) {        toBeXord[i] = (byte) (toBeXord[i] ^ passwordHashStage1[i]);    }
    return toBeXord;}
复制代码


该方法的主要作用如下:


  1. 使用 SHA-1 算法对明文密码(password)进行哈希,生成 passwordHashStage1。

  2. 对生成的 passwordHashStage1 再次使用 SHA-1 算法进行哈希,生成 passwordHashStage2。

  3. 调用md.update方法将 seed(服务端发送的盐值)和 passwordHashStage2 添加到消息摘要中。

  4. 调用md.digest获取最终的摘要值。

  5. 将摘要值中的每个字节与 passwordHashStage1 对应位置的字节进行异或运算。

    这么做,主要为了增加密码处理的复杂性,使得密码在传输过程中较难被破解。


简单来说,就是客户端基于服务端发送的盐值对明文密码进行加密,最后将加密后的结果发送给服务端,并不会直接发送明文密码。


服务端是如何验证客户端密码是否正确的?


在 mysql_native_password 中,验证客户端密码是否正确是在check_scramble_sha1函数中实现的。


static bool check_scramble_sha1(const uchar *scramble_arg, const char *message,                                const uint8 *hash_stage2) {  uint8 buf[SHA1_HASH_SIZE];  uint8 hash_stage2_reassured[SHA1_HASH_SIZE];
  /* create key to encrypt scramble */  compute_sha1_hash_multi(buf, message, SCRAMBLE_LENGTH,                          (const char *)hash_stage2, SHA1_HASH_SIZE);  /* encrypt scramble */  my_crypt((char *)buf, buf, scramble_arg, SCRAMBLE_LENGTH);
  /* now buf supposedly contains hash_stage1: so we can get hash_stage2 */  compute_sha1_hash(hash_stage2_reassured, (const char *)buf, SHA1_HASH_SIZE);
  return (memcmp(hash_stage2, hash_stage2_reassured, SHA1_HASH_SIZE) != 0);}
复制代码


函数中的 scramble_arg 是客户端返回的加密密码,message 是盐值,hash_stage2 是 authentication_string 的二进制表示。


该函数的具体实现如下:


  1. 调用compute_sha1_hash_multi计算 message 和 hash_stage2 的 SHA-1 哈希值,对应客户端实现中的 3、4 步。


  1. 将步骤 1 中的结果与客户端返回的加密密码进行异或运算。


  1. 因为 XOR(s1, XOR(s1, s2)) == s2,所以最后得到的结果实际上就是客户端实现中的 passwordHashStage1。


  1. 调用compute_sha1_hash对 passwordHashStage1 进行一次 SHA-1 哈希,生成 hash_stage2_reassured。


  1. 判断 authentication_string 的二进制表示是否与 hash_stage2_reassured 相同。


  1. 如果相同,则意味着客户端输入的密码是正确的,否则是错误的。


看上去有点复杂,但实际上它跟客户端的实现类似。


为什么 MySQL 8.0 要将默认的密码认证插件调整为 caching_sha2_password


在 MySQL 8.0 中,默认的密码认证插件由 mysql_native_password 调整为了 caching_sha2_password。


官方为什么要做这个调整呢?


主要原因还是因为 mysql_native_password 不够安全。


不够安全主要体现在以下两点:


  1. SHA-1 自身不再安全。这主要是指 SHA-1 存在碰撞漏洞,即两个不同的输入可以产生相同的哈希值。


  1. 容易引起彩虹表攻击。


在 mysql_native_password 中,对于同一个明文密码,会生成一个确定的加密密码。


123456对应的加密密码永远是*6BB4837EB74329105EE4568DDA7DC67ED2CA2AD9,这就很容易引起彩虹表攻击。


彩虹表(Rainbow Table)是一种密码破解技术,其核心思想是事先计算并存储大量可能的密码和其对应的哈希值。这样,当攻击者获取到加密系统中存储的哈希值时,就可以直接查找对应的明文密码,而无需进行逐一尝试的破解。


所以只要获取到 mysql.user 表 authentication_string 字段的内容,再加上事先构建的彩虹表,破解出明文密码并不是一件难事。


这里顺便介绍个黑科技,在 MySQL 8.0 之前,因为 mysql.user 表使用的是 MyISAM 存储引擎,所以,只要有主机登陆权限,就能通过 vim 查看 authentication_string 字段的内容。



总结


1. mysql.user 中的 authentication_string 字段存储的是HEX(SHA1(SHA1(password)))


2. 服务端对客户端进行身份验证的流程图如下:



服务端在对客户端进行身份验证时,会首先发送一个 20 字节的盐值,客户端接受到这个盐值后,会返回一个通过以下公式计算的加密密码。


SHA1(password) XOR SHA1(seed <concat> SHA1(SHA1(password)))
复制代码


3. 因为 mysql_native_password 容易引起彩虹表攻击,且 SHA-1 本身就不够安全,所以在 MySQL 8.0 中,默认的是身份验证插件由 mysql_native_password 调整为了 caching_sha2_password。

实际上,caching_sha2_password 底层使用的加密算法(SHA-256)早在 sha256_password 这个认证插件( MySQL 5.6 中引入的)中就使用了。虽然 sha256_password 足够安全,但因为认证速度比较慢,性能不理想,所以在线上用得并不多。


4. caching_sha2_password 在 sha256_password 的基础上,新增了一个内存缓存,用于存储哈希密码,以加快认证速度。


参考


  1. Native Authentication:https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_authentication_methods_native_password_authentication.html

  2. WL#9591: Caching sha2 authentication plugin:https://dev.mysql.com/worklog/task/?id=9591

  3. WL#10774: Remove old_passwords, PASSWORD(), other deprecated auth features:https://dev.mysql.com/worklog/task/?id=10774


文章转载自:iVictor

原文链接:https://www.cnblogs.com/ivictor/p/17953165

体验地址:http://www.jnpfsoft.com/?from=001

用户头像

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
从源码分析 MySQL 身份验证插件的实现细节_MySQL_快乐非自愿限量之名_InfoQ写作社区