翼度科技»论坛 编程开发 mysql 查看内容

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

6

主题

6

帖子

18

积分

新手上路

Rank: 1

积分
18
最近在分析ERROR 1045 (28000): Access denied for user 'root'@'localhost' (using password: YES)这个报错的常见原因。
在分析的过程中,不可避免会涉及到 MySQL 身份验证的一些实现细节。
加之之前对这一块就有很多疑问,包括:

  • 一个明文密码,是如何生成 mysql.user 表中的 authentication_string?
  • 在进行身份验证时,客户端是否会直接发送明文密码给 MySQL 服务端?
  • MySQL 8.0 为什么要将默认的身份认证插件调整为 caching_sha2_password,mysql_native_password 有什么问题嘛?
所以,就从代码层面对 MySQL 身份验证插件(主要是 mysql_native_password)的一些实现细节进行了分析。
本文主要包括以下几部分:

  • 服务端是如何对明文密码进行加密的?
  • 服务端是如何进行客户端身份验证的?
  • 客户端是如何处理明文密码的?会直接发送明文密码给服务端么?
  • 服务端是如何验证客户端密码是否正确的?
  • 为什么 MySQL 8.0 要将默认的身份认证插件调整为 caching_sha2_password?
服务端是如何对明文密码进行加密的?

在 mysql_native_password 中,对明文密码进行加密是在 my_make_scrambled_password_sha1函数中实现的。
  1. // sql/auth/password.cc
  2. void my_make_scrambled_password_sha1(char *to, const char *password,
  3.                                      size_t pass_len) {
  4.   uint8 hash_stage2[SHA1_HASH_SIZE];

  5.   /* Two stage SHA1 hash of the password. */
  6.   compute_two_stage_sha1_hash(password, pass_len, (uint8 *)to, hash_stage2);

  7.   /* convert hash_stage2 to hex string */
  8.   *to++ = PVERSION41_CHAR;
  9.   octet2hex(to, (const char *)hash_stage2, SHA1_HASH_SIZE);
  10. }

  11. // sql/auth/password.cc
  12. inline static void compute_two_stage_sha1_hash(const char *password,
  13.                                                size_t pass_len,
  14.                                                uint8 *hash_stage1,
  15.                                                uint8 *hash_stage2) {
  16.   /* Stage 1: hash password */
  17.   compute_sha1_hash(hash_stage1, password, pass_len);

  18.   /* Stage 2 : hash first stage's output. */
  19.   compute_sha1_hash(hash_stage2, (const char *)hash_stage1, SHA1_HASH_SIZE);
  20. }
复制代码
实现其实非常简单:

  • 使用 OpenSSL 库中的函数对输入的密码进行 SHA-1 哈希,生成 hash_stage1。
  • 对生成的 hash_stage1 进行二次 SHA-1 哈希,生成 hash_stage2。
  • 将 hash_stage2 转换为十六进制表示。
最后生成的字符串即我们在mysql.user中看到的authentication_string。
相同的功能用下面这段 Python 代码很容易就能实现出来。
  1. import hashlib

  2. def compute_sha1_hash(data):
  3.     sha1 = hashlib.sha1()
  4.     sha1.update(data)
  5.     return sha1.digest()

  6. password = "123456".encode('utf-8')
  7. hash_stage1 = compute_sha1_hash(password)
  8. hash_stage2 = compute_sha1_hash(hash_stage1)
  9. print('*%s'%hash_stage2.hex().upper())
复制代码
密码是123456,最后打印的结果是 *6BB4837EB74329105EE4568DDA7DC67ED2CA2AD9。
同mysql.user中的authentication_string的值完全一样。
  1. mysql> create user u1@'%' identified with mysql_native_password by '123456';
  2. Query OK, 0 rows affected (0.04 sec)

  3. mysql> select user,host,authentication_string from mysql.user where user='u1';
  4. +------+------+-------------------------------------------+
  5. | user | host | authentication_string                     |
  6. +------+------+-------------------------------------------+
  7. | u1   | %    | *6BB4837EB74329105EE4568DDA7DC67ED2CA2AD9 |
  8. +------+------+-------------------------------------------+
  9. 1 row in set (0.00 sec)
复制代码
有木有一种很简单的感觉?
服务端是如何进行客户端身份验证的?

在 mysql_native_password 中,对客户端进行身份验证是在 native_password_authenticate函数中实现的。
  1. static int native_password_authenticate(MYSQL_PLUGIN_VIO *vio,
  2.                                         MYSQL_SERVER_AUTH_INFO *info) {
  3.   uchar *pkt;
  4.   int pkt_len;
  5.   MPVIO_EXT *mpvio = (MPVIO_EXT *)vio;

  6.   DBUG_TRACE;

  7.   // 生成盐值(Salt)。
  8.   if (mpvio->scramble[SCRAMBLE_LENGTH])
  9.     generate_user_salt(mpvio->scramble, SCRAMBLE_LENGTH + 1);

  10.   // 将盐值发送给客户端
  11.   if (mpvio->write_packet(mpvio, (uchar *)mpvio->scramble, SCRAMBLE_LENGTH + 1))
  12.     return CR_AUTH_HANDSHAKE;

  13.   // 读取客户端的响应,其中pkt用来存储响应包的内容,pkt_len是包的长度。
  14.   if ((pkt_len = mpvio->read_packet(mpvio, &pkt)) < 0) return CR_AUTH_HANDSHAKE;
  15.   DBUG_PRINT("info", ("reply read : pkt_len=%d", pkt_len));

  16.   ...
  17.   // 如果响应包的长度为0,则意味着客户端没有指定密码
  18.   if (pkt_len == 0) {
  19.     info->password_used = PASSWORD_USED_NO;
  20.     return mpvio->acl_user->credentials[PRIMARY_CRED].m_salt_len != 0
  21.                ? CR_AUTH_USER_CREDENTIALS
  22.                : CR_OK;
  23.   } else
  24.     info->password_used = PASSWORD_USED_YES;
  25.   bool second = false;
  26.   // 如果响应包的长度等于盐值的长度,则会验证密码是否正确。
  27.   if (pkt_len == SCRAMBLE_LENGTH) {
  28.     if (!mpvio->acl_user->credentials[PRIMARY_CRED].m_salt_len ||
  29.         check_scramble(pkt, mpvio->scramble,
  30.                        mpvio->acl_user->credentials[PRIMARY_CRED].m_salt)) {
  31.       second = true;
  32.       // 如果验证失败,则会验证第二个密码是否设置且正确。
  33.       // 在 MySQL 8.0 中,一个账户可以设置两个密码。
  34.       if (!mpvio->acl_user->credentials[SECOND_CRED].m_salt_len ||
  35.           check_scramble(pkt, mpvio->scramble,
  36.                          mpvio->acl_user->credentials[SECOND_CRED].m_salt)) {
  37.         return CR_AUTH_USER_CREDENTIALS;
  38.       } else {
  39.         if (second) {...}
  40.         return CR_OK;
  41.       }
  42.     } else {
  43.       return CR_OK;
  44.     }
  45.   }

  46.   my_error(ER_HANDSHAKE_ERROR, MYF(0));
  47.   return CR_AUTH_HANDSHAKE;
  48. }
复制代码
该函数的主要作用如下:

  • 通过generate_user_salt生成一个 20 位的盐值(Salt)。
    "盐值"(Salt)是密码学中一个常用的概念。它是一个随机生成的数据块,通常与密码一同进行哈希。
    相同的密码,由于盐值的不同,生成的哈希值也会不同。
    引入盐值可有效防止彩虹表攻击和碰撞攻击,提高密码的安全性。
  • 将盐值发送给客户端。客户端会基于盐值对明文密码进行加密(具体的加密细节后面会介绍),然后将加密后的结果返回给服务端。
  • 读取客户端的响应。
  • 如果响应包的长度等于盐值的长度,则会调用 check_scramble验证客户端返回的加密密码是否与数据库中存储的加密密码相匹配(具体的匹配细节后面会介绍)。
客户端是如何处理明文密码的?

这里以 JDBC 驱动为例,客户端在接受到 MySQL 服务端发送的盐值后,会调用Security类中的scramble411方法对明文密码进行加密。
下面我们看看具体的实现细节。
[code]// src/main/core-impl/java/com/mysql/cj/protocol/Security.java
public 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 

举报 回复 使用道具