故障分析 | 從一則錯誤日誌到 MySQL 認證機制與 bug 的深入分析

愛可生雲資料庫發表於2023-04-11

作者:李錫超

一個愛笑的江蘇蘇寧銀行 資料庫工程師,主要負責資料庫日常運維、自動化建設、DMP平臺運維。擅長MySQL、Python、Oracle,愛好騎行、研究技術。
本文來源:原創投稿

*愛可生開源社群出品,原創內容未經授權不得隨意使用,轉載請聯絡小編並註明來源。


研發同學反饋某系統效能測試環境MySQL資料庫相關的業務系統執行正常,但存在大量警告日誌,需配合分析原因。

一、異常現象

mysql錯誤日誌檔案中存在大量如下資訊:

2023-01-10T01:07:23.035479Z 13 [Warning] [MY-013360] [Server] Plugin sha256_password reported: ''sha256_password' is deprecated and will be removed in a future release. Please use caching_sha2_password instead'

關鍵環境資訊

二、初步分析

當看到如上警告日誌時,根據經驗主義,第一反應應該是客戶端的版本過低,其授權認證外掛是服務端將要廢棄的版本,所以產生了以上告警資訊。特別是一些常見的客戶端的工具,可能會由於更新頻率,會很容易觸發該問題。

嘗試復現

根據初步分析建議,將初步分析建議與研發同學溝通後,透過常見的資料庫工具訪問資料庫,看是否能否復現該錯誤。但透過資料庫裡面常見的資料庫使用者,透過不同的工具訪問資料庫,均未在訪問時刻觸發該異常。
由此,第一次嘗試復現失敗。難道是因為其它原因?

再第一次嘗試訪問的過程,透過實時觀察資料庫錯誤日誌。在用客戶端嘗試訪問的過程中,沒有復現該錯誤。但是仍然看到對應的警告日誌在持續輸出到錯誤日誌檔案。且頻率較高、間隔時間固定,由此也證明在錯誤不是資料庫工具人工訪問的。

應用系統執行正常,又不是客戶端導致的!作為DBA的你,應該如何進一步分析呢?

初放小招

由於所處測試環境,針對該錯誤,可以執行如下操作啟用MySQL的一般日誌:

-- 開通一般日誌:
show variables like 'general_log';
set global general_log=on;
show variables like 'general_log';
-- 檢視一般日誌路徑:
show variables like 'general_log_file';

啟用日誌後,觀察錯誤日誌,發現一般日誌中如下記錄:

提示:發現異常後,立即關閉一般日誌,避免產生過多日誌耗盡磁碟空間:

-- 開通一般日誌:
show variables like 'general_log';
set global general_log=off;
show variables like 'general_log';

即使用者dbuser2 在問題時刻從10.x.y.43 伺服器發起了訪問資料庫的請求。確認異常訪問的使用者和伺服器後,檢查資料庫mysql.user表、skip-grant-tables等配置,發現資料庫並不存在該使用者,且沒有跳過授權表等配置。使用該使用者將無法登入到資料庫。

將資訊反饋至研發同學,很快就確認了是由於部分應用配置不合理,使用了不存在的資料庫使用者,並定時連線資料庫執行任務。於是研發同學修改配置後,警告日誌不再產生。

那麼該問題分析到此,可以結束了麼?

修改配置後,警告日誌不在發生!但既然是不存在的使用者,訪問時為什麼還提示認證外掛將廢棄呢?

三、原始碼分析

帶著問題,首先想到的是:既然資料庫使用者為存在於mysql.user表,登入也會產生警告,難道這個使用者是mysql的內部使用者,被硬編碼了麼!於是取到對應版本原始碼,透過如下命令進行確認:

cd mysql-8.0.27/
grep -rwi "dbuser2" *

其訪問結果為空,即不存在猜想的“內部使用者”。

正常登入認證邏輯

既然沒有硬編碼,那就只能是內部邏輯導致。於是首先對正常情況下mysql使用者登入過程,原始碼分析結果如下:

|—> handle_connection
  |—> thd_prepare_connection
    |—> login_connection
      |—> check_connection
        // 判斷客戶端的主機名是否可以登入(mysql.user.host),如果 mysql.user.host 有 '%' 那麼將 allow_all_hosts,允許所有主機。
        |—> acl_check_host   
        |—> acl_authenticate
          |—> server_mpvio_initialize // 初始化mpvio物件,包括賦值 mpvio->ip / mpvio->host
          |—> auth_plugin_name="caching_sha2_password"
          |—> do_auth_once
            |—> caching_sha2_password_authenticate // auth->authenticate_user
              |—> server_mpvio_read_packet // vio->read_packet(vio, &pkt) // pkt=passwd
                |—> parse_client_handshake_packet
                  |—> char *user = get_string(&end, &bytes_remaining_in_packet, &user_len);
                  |—> passwd = get_length_encoded_string(&end, &bytes_remaining_in_packet, &passwd_len);
                  |—> mpvio->auth_info.user_name = my_strndup(key_memory_MPVIO_EXT_auth_info, user, user_len, MYF(MY_WME))
                  // 根據 user 搜尋 mysql.user.host ,並與客戶端的 hostname/ip 進行比較:匹配記錄後,賦值 mpvio->acl_user
                  |—> find_mpvio_user(thd, mpvio) 
                    |—> list = cached_acl_users_for_name(mpvio->auth_info.user_name); // 根據 user 搜尋 mysql.user.host 
                    |—> acl_user_tmp->host.compare_hostname(mpvio->host, mpvio->ip) //  與客戶端的 hostname/ip 進行比較
                    |—> mpvio->acl_user_plugin = mpvio->acl_user->plugin; // 賦值 acl_user_plugin 屬性為使用者的plugin名
                    |—> mpvio->auth_info.multi_factor_auth_info[0].auth_string = mpvio->acl_user->credentials[PRIMARY_CRED].m_auth_string.str; 
                    |—> mpvio->auth_info.auth_string = mpvio->auth_info.multi_factor_auth_info[0].auth_string; 
                  |—> if (my_strcasecmp(system_charset_info, mpvio->acl_user_plugin.str,plugin_name(mpvio->plugin)->str) != 0)
                  |—> my_strcasecmp(system_charset_info, client_plugin,user_client_plugin_name) //檢查客戶端的認證外掛與使用者外掛是否相同
              |—> make_hash_key(info->authenticated_as, hostname ? hostname : nullptr, authorization_id);  // 生成 authorization_id = user1\000% 
              |—> g_caching_sha2_password->fast_authenticate(authorization_id,*scramble,20,pkt,false) // 進行快速授權操作
                |—> m_cache.search(authorization_id, digest) // 根據 user、host 搜尋密碼,賦值到digest
                |—> Validate_scramble validate_scramble_first(scramble, digest.digest_buffer[0], random, random_length);
                |—> validate_scramble_first.validate(); // 校驗 scramble
              // 如驗證成功
              |—> vio->write_packet(vio, (uchar *)&fast_auth_success, 1)
              |—> return CR_OK;
              // 否則進行進行慢授權操作
              |—> g_caching_sha2_password->authenticate( authorization_id, serialized_string, plaintext_password);
          |—> server_mpvio_update_thd(thd, &mpvio);
          |—> check_and_update_password_lock_state(mpvio, thd, res);
          // 繼續其它授權操作

即核心的認證操作在函式 caching_sha2_password_authenticate() 中執行,先呼叫函式find_mpvio_user(),透過user、hostname找到已經配置的使用者,然後呼叫函式fast_authenticate()對密碼進行快速驗證。

使用不存在使用者認證邏輯

當使用者不存在時,mysql使用者登入過程,原始碼分析結果如下:

|—> handle_connection
  |—> thd_prepare_connection
    |—> login_connection
      |—> check_connection
        // 判斷客戶端的主機名是否可以登入(mysql.user.host),如果 mysql.user.host 有 '%' 那麼將 allow_all_hosts,允許所有主機。
        |—> acl_check_host   
        |—> acl_authenticate
          |—> server_mpvio_initialize // 初始化mpvio物件,包括賦值 mpvio->ip / mpvio->host
          |—> auth_plugin_name="caching_sha2_password"
          |—> do_auth_once
            |—> caching_sha2_password_authenticate // auth->authenticate_user
              |—> server_mpvio_read_packet // vio->read_packet(vio, &pkt) // pkt=passwd
                |—> parse_client_handshake_packet
                  |—> char *user = get_string(&end, &bytes_remaining_in_packet, &user_len);
                  |—> passwd = get_length_encoded_string(&end, &bytes_remaining_in_packet, &passwd_len);
                  |—> mpvio->auth_info.user_name = my_strndup(key_memory_MPVIO_EXT_auth_info, user, user_len, MYF(MY_WME))
                  |—> find_mpvio_user(thd, mpvio) 
                    |—> list = cached_acl_users_for_name(mpvio->auth_info.user_name); // 根據 user 搜尋 mysql.user.host, 由於使用者不存在,搜尋不到記錄
                    |—> mpvio->acl_user = decoy_user(usr, hst, mpvio->mem_root, mpvio->rand, initialized); // 
                      |—> Auth_id key(user);
                      // 判斷是否使用者存在於 unknown_accounts
                      |—> unknown_accounts->find(key, value)
                      // 如存在:
                      |—> user->plugin = Cached_authentication_plugins::cached_plugins_names[value];
                      // 如不存在:
                      |—> 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->insert(key, plugin_num)
                    |—> mpvio->acl_user_plugin = mpvio->acl_user->plugin; // 賦值 acl_user_plugin 屬性為使用者的plugin名
                    |—> mpvio->auth_info.multi_factor_auth_info[0].auth_string = mpvio->acl_user->credentials[PRIMARY_CRED].m_auth_string.str; // ""
                    |—> mpvio->auth_info.auth_string = mpvio->auth_info.multi_factor_auth_info[0].auth_string; // ""
                    |—> mpvio->auth_info.additional_auth_string_length = 0; // 0
                    |—> mpvio->auth_info.auth_string_length = mpvio->auth_info.multi_factor_auth_info[0].auth_string_length; // 0
                  |—> if (my_strcasecmp(system_charset_info, mpvio->acl_user_plugin.str,plugin_name(mpvio->plugin)->str) != 0)
                  |—> return packet_error;
                |—> if (pkt_len == packet_error) goto err;
                |—> return -1;
          |—> auth_plugin_name = mpvio.acl_user->plugin;
          |—> res = do_auth_once(thd, auth_plugin_name, &mpvio);
            |—> sha256_password_authenticate() //auth->authenticate_user(mpvio, &mpvio->auth_info);
              |—> LogPluginErr // Deprecate message for SHA-256 authentication plugin.
              // 列印: 2023-01-10T01:07:23.035479Z 13 [Warning] [MY-013360] [Server] Plugin sha256_password reported: ''sha256_password' is deprecated and will be removed in a future release. Please use caching_sha2_password instead'
              |—> server_mpvio_read_packet() // vio->read_packet(vio, &pkt)
              |—> if (info->auth_string_length == 0 && info->additional_auth_string_length == 0) // info -> auth_info
              |—>   return CR_ERROR;
            |—> return res; // 0
          |—> server_mpvio_update_thd(thd, &mpvio);
          |—> check_and_update_password_lock_state(mpvio, thd, res); // 直接返回
          ...
          |—> login_failed_error // 列印登入報錯資訊
          // 2023-01-10T02:02:44.659796Z 19 [Note] [MY-010926] [Server] Access denied for user 'user2'@'localhost' (using password: YES)
      |—> thd->send_statement_status();  // 客戶端終止

即當使用不存在的使用者登入資料庫時,透過函式 decoy_user() 建立的 acl_user 物件。在建立這個物件時,其 plugin 屬性採用隨機方式從 cached_plugins_enum 選擇。從而有可能選擇到 PLUGIN_SHA256_PASSWORD 外掛。但在函式sha256_password_authenticate() 的入口,就會生成Warning級別的提示,以提示該驗證PLUGIN_SHA256_PASSWORD 將被廢棄。隨後,由於在decoy_user() 建立的 acl_user 物件auth_string_length 長度未0,在後續的認證邏輯中會直接返回CR_ERROR,即認證失敗。

根因總結

根據以上認證過的分析,導致錯誤日誌存在 PLUGIN_SHA256_PASSWORD 將被廢棄的根本原因為:在當前版本,當使用不存在的使用者登入資料庫時,mysql會隨機選擇使用者的密碼認證外掛,在當前的版本版本中,有1/3的機率會選擇到 PLUGIN_SHA256_PASSWORD 外掛。選擇該外掛後,在後續的認證邏輯將會觸發警告日誌生成。

四、問題解決

綜合以上分析過程,導致該問題的直接原因是應用配置了不存在的資料庫使用者,根本原因為資料庫登入認證邏輯存在一定缺陷。那麼解決該問題可參考如下幾種方案:

1.參考初步分析中的方案,將應用的連線配置修改為正確的使用者資訊;

2.可以在mysql資料庫中透過引數將該告警過濾,避免該告警資訊輸入到錯誤日誌檔案。相關配置如下:

show variables like 'log_error_suppression_list';
set global log_error_suppression_list='MY-013360;
show variables like 'log_error_suppression_list';

注意,使用該方案也會導致某個存在且使用SHA256_PASSWORD認證外掛產生的告警。可以作為臨時方案;

3.修改mysql程式碼,避免在使用不存在使用者登入資料庫時,選擇 SHA256_PASSWORD認證外掛。目前針對該方案已提交Bug #109635。

附:關鍵函式位置

find_mpvio_user() (./sql/auth/sql_authentication.cc:2084)
parse_client_handshake_packet() (./sql/auth/sql_authentication.cc:2990)
server_mpvio_read_packet() (./sql/auth/sql_authentication.cc:3282)
caching_sha2_password_authenticate() (./sql/auth/sha2_password.cc:955)
do_auth_once() (./sql/auth/sql_authentication.cc:3327)
acl_authenticate() (./sql/auth/sql_authentication.cc:3799)
check_connection() (./sql/sql_connect.cc:651)
login_connection() (./sql/sql_connect.cc:716)
thd_prepare_connection() (./sql/sql_connect.cc:889)
handle_connection() (./sql/conn_handler/connection_handler_per_thread.cc:298)

相關文章