現象

最近遇到一個有趣的案例:在一個新創建的 MySQL 8.4 實例中,使用用户 u2 登錄時,返回了Plugin 'mysql_native_password' is not loaded錯誤。

$ mysql -h127.0.0.1 -P3316 -uu2 -p123
mysql: [Warning] Using a password on the command line interface can be insecure.
ERROR 1524 (HY000): Plugin 'mysql_native_password' is not loaded

奇怪的是,檢查mysql.user表後卻發現:

  • 實例裏並沒有 u2 這個用户;
  • 現有用户中並沒有用户在使用 mysql_native_password。
mysql> select host,user,plugin from mysql.user;
+-----------+------------------+-----------------------+
| host      | user             | plugin                |
+-----------+------------------+-----------------------+
| %         | root             | caching_sha2_password |
| localhost | mysql.infoschema | caching_sha2_password |
| localhost | mysql.session    | caching_sha2_password |
| localhost | mysql.sys        | caching_sha2_password |
| localhost | root             | caching_sha2_password |
+-----------+------------------+-----------------------+
5 rows in set (0.05 sec)

有意思的是,同樣是不存在,如果使用 u1 登錄,返回的卻是Access denied for user 'xxx'@'xxx'錯誤:

$ mysql -h127.0.0.1 -P3316 -uu1 -p123
mysql: [Warning] Using a password on the command line interface can be insecure.
ERROR 1045 (28000): Access denied for user 'u1'@'127.0.0.1' (using password: YES)

問題來了:

  1. 同樣不存在,為什麼 u1 和 u2 會返回不同的錯誤?
  2. 明明沒有用户在使用 mysql_native_password,為什麼 MySQL 會提示Plugin 'mysql_native_password' is not loaded

根因分析

下面結合 MySQL 客户端與服務端的認證流程,來分析上述報錯。

一、

客户端向 MySQL 服務端發起連接請求。

二、

服務端收到請求後,會調用do_auth_once()函數對客户端進行身份認證。

首次認證時,MySQL 會調用默認的密碼認證插件進行認證。

在 MySQL 8.4 之前,默認的密碼認證插件由 default_authentication_plugin 參數決定:

  • 5.7 默認是 mysql_native_password。
  • 8.0 改成了 caching_sha2_password。
  • 到了 8.4,移除了這個參數,默認插件被固定為 caching_sha2_password。

所以,在 MySQL 8.4 中,MySQL 會調用 caching_sha2_password 插件向客户端發送一個握手包(handshake packet)。

握手包的內容包括通信協議版本、服務端版本、隨機數(鹽值)、服務端能力標誌、默認字符集編號、密碼認證插件名稱等。

三、

客户端收到握手包後,默認會根據包中指定的認證插件(在 MySQL 8.4 中為 caching_sha2_password)生成並返回一個握手響應包(handshake response)。

響應包的內容包括客户端能力標誌、用户名、加密後的密碼、要連接的庫名、客户端使用的認證插件等。

四、

服務端收到響應包後,會調用parse_client_handshake_packet()函數進行處理。該函數主要做:

  1. 讀取客户端能力標誌。

  2. 如果客户端要求 SSL,則先完成 SSL 握手並重新讀取一個包。

  3. 設置客户端使用的字符集。

  4. 提取用户名、密碼、默認庫名和認證插件。

  5. 調用find_mpvio_user初始化 mpvio( mpvio 用於存儲認證過程中的用户信息、連接信息及插件交互狀態)。

find_mpvio_user會根據客户端發來的用户名與 host/ip,從 ACL 用户緩存(mysql.user)中找到對應的用户記錄。如果用户不存在,MySQL 不會直接暴露“用户名不存在”,而是走 decoy_user() 邏輯,為這類“未知用户”隨機分配一個認證插件,構造一個看起來正常的用户記錄。這樣可以避免外部探測哪些用户名真實存在。

以下是 decoy_user() 函數的具體實現。

ACL_USER *decoy_user(const LEX_CSTRING &username, const LEX_CSTRING &hostname,
                     MEM_ROOT *mem, struct rand_struct *rand,
                     bool is_initialized) {
  ...
  if (is_initialized) {
    // 根據用户名和 host/ip 生成一個 key
    Auth_id key(user);
     
    uint value;
    // 如果該 unknown user 已經出現過,則複用之前分配的認證插件。
    // 這樣保證同一個客户端每次收到的登錄驗證行為一致,避免泄露用户名是否存在的信息。
    if (unknown_accounts->find(key, value)) {
      user->plugin = Cached_authentication_plugins::cached_plugins_names[value];
    } else {
      // 對於首次遇到的 unknown user,則會從 cached_plugins_names 中隨機分配一個認證插件。
      const int DECIMAL_SHIFT = 1000;
      const int random_number = static_cast<int>(my_rnd(rand) * DECIMAL_SHIFT);
      uint plugin_num = (uint)(random_number % ((uint)PLUGIN_LAST));
      user->plugin =
          Cached_authentication_plugins::cached_plugins_names[plugin_num];
      unknown_accounts->clear_if_greater(MAX_UNKNOWN_ACCOUNTS);
      // 將客户端及分配的插件記錄到 unknown_accounts 緩存中
      if (!unknown_accounts->insert(key, plugin_num)) {
        if (!unknown_accounts->find(key, plugin_num))
          user->plugin = default_auth_plugin_name;
        else
          user->plugin =
              Cached_authentication_plugins::cached_plugins_names[plugin_num];
      }
    }
  }
  ...
  return user;
}
// cached_plugins_names 的定義
const LEX_CSTRING Cached_authentication_plugins::cached_plugins_names[(
    uint)PLUGIN_LAST] = {{STRING_WITH_LEN("caching_sha2_password")},
                         {STRING_WITH_LEN("mysql_native_password")},
                         {STRING_WITH_LEN("sha256_password")}};

對於 u1 用户,隨機分配到的認證插件可能是 caching_sha2_password 或 sha256_password,對於 u2 用户,恰好分配到了 mysql_native_password。

五、

parse_client_handshake_packet()函數中,如果發現客户端使用的密碼認證插件與mysql.user表中記錄的插件不一致(對於“偽用户”,則是隨機分配的插件),MySQL 會調用do_auth_once()進行二次認證。

此時,認證會使用mysql.user表中的插件,或者偽用户被隨機分配的插件。

如果指定的認證插件在服務端不存在,則會觸發Plugin 'xxx' is not loaded錯誤。

do_auth_once()的具體實現如下:

static int do_auth_once(THD *thd, const LEX_CSTRING &auth_plugin_name,
                        MPVIO_EXT *mpvio) {
  DBUG_TRACE;
  int res = CR_OK, old_status = MPVIO_EXT::FAILURE;
  bool unlock_plugin = false;
  // 先嚐試從緩存中獲取指定插件
  plugin_ref plugin =
      g_cached_authentication_plugins->get_cached_plugin_ref(&auth_plugin_name);
  // 若緩存中不存在,則按名稱加載插件
  if (!plugin) {
    if ((plugin = my_plugin_lock_by_name(thd, auth_plugin_name,
                                         MYSQL_AUTHENTICATION_PLUGIN)))
      unlock_plugin = true;
  }

  mpvio->plugin = plugin;
  old_status = mpvio->status;
  // 如果插件存在,則調用對應的 authenticate_user() 方法與客户端進行認證交互
  if (plugin) {
    st_mysql_auth *auth = (st_mysql_auth *)plugin_decl(plugin)->info;
    res = auth->authenticate_user(mpvio, &mpvio->auth_info);

    if (unlock_plugin) plugin_unlock(thd, plugin);
  } else {
    // 如果插件無法加載,就會觸發 Plugin xxx is not loaded 錯誤。
    Host_errors errors;
    errors.m_no_auth_plugin = 1;
    inc_host_errors(mpvio->ip, &errors);
    my_error(ER_PLUGIN_IS_NOT_LOADED, MYF(0), auth_plugin_name.str);
    res = CR_ERROR;
  }
  ...
  return res;
}

具體到 u2 用户,因為分配的認證插件正好是 mysql_native_password,所以在二次認證階段,MySQL 會嘗試使用該插件進行驗證。

但在 MySQL 8.4 中,mysql_native_password 默認是被禁用的,所以就觸發了ERROR 1524 (HY000): Plugin 'mysql_native_password' is not loaded錯誤。

總結

當客户端使用一個不存在的用户名連接 MySQL 時:

  • MySQL 不會直接提示用户不存在。
  • 而是為該用户構造一個“假用户”。
  • 並隨機分配一個認證插件進行認證,以防止用户名枚舉攻擊。

在 MySQL 8.4 中,由於默認禁用了 mysql_native_password,因此,

  • 若隨機分配到該插件,就會觸發ERROR 1524 (HY000): Plugin 'mysql_native_password' is not loaded錯誤。
  • 若分配到其他插件,則會走完整認證流程並返回Access denied for user 'xxx'@'xxx'錯誤。

這就是 u1 和 u2 報錯不同的根本原因。