clock-up-blog

go-mi-tech

MySQLユーザに旧パスワードハッシュ形式が含まれる場合の認証プロトコルについて

とある MySQL に関するやっかいな問題の発生と解決の流れまでのお話をします。
こんな現象に遭遇する不幸な人は稀かと思いますので、別に覚えておく必要もない部類の小話。

結果(対策)だけ書いて済ませておけば良い部類の話でもないので長々と経緯を書くことにする。


事の発端はというと、一部の MySQL サーバに C# プログラムから接続しようとしたときに "Read past end of buffer looking for NUL." という見慣れないエラーが発生する問題に遭遇した事だった。

これがどのくらい見慣れないエラーメッセージかというと、

f:id:kobake:20170826113321p:plain:w500

このくらい見慣れない。(Google検索結果2件)

※これは(後述する)本現象が稀というよりは、 MySqlConnector 利用者がこの現象に当たる可能性が低かったことに依る情報の少なさであると思われる。

例外発生個所

良い時代になったもので、この例外を発行している実装そのものを Google 検索で見つけることができる。冒頭の検索結果の2件目がまさにその実装そのものである。

MySqlConnector/ByteArrayReader.cs at master · mysql-net/MySqlConnector · GitHub

public byte[] ReadNullTerminatedByteString()
{
    int index = m_offset;
    while (index < m_maxOffset && m_buffer[index] != 0)
        index++;
    if (index == m_maxOffset)
        throw new FormatException("Read past end of buffer looking for NUL."); // ★ココ
    byte[] substring = new byte[index - m_offset];
    Buffer.BlockCopy(m_buffer, m_offset, substring, 0, substring.Length);
    m_offset = index + 1;
    return substring;
}

なお、新しい版の MySqlConnector (0.25.1等) を使っていると上述のような "Read past end of buffer looking for NUL." というエラーメッセージになるが、少し古い版の MySqlConnector (0.19.2等) だとエラーメッセージは "Object reference not set to an instance of an object." になる。

問題の原因特定

まず例外の発行箇所である MySqlConnectorReadNullTerminatedByteString() を見ると、バイト列内に期待するデータ値がなかった(ヌル文字終端の文字列が見つからなかった)ことが伺える。

public byte[] ReadNullTerminatedByteString()
{
    int index = m_offset;
    while (index < m_maxOffset && m_buffer[index] != 0)
        index++;
    if (index == m_maxOffset)
        throw new FormatException("Read past end of buffer looking for NUL.");
    …


MySqlConnectorソースコードGitHub から丸々手元に落とせば Visual Studio 上でデバッグも可能なのでモリモリと漁ってみた結果、この問題は AuthenticationMethodSwitchRequestPayload.Create() から呼ばれている ReadNullTerminatedByteString() に起因することが分かった。

public static AuthenticationMethodSwitchRequestPayload Create(PayloadData payload)
{
    var reader = new ByteArrayReader(payload.ArraySegment);
    reader.ReadByte(Signature);
    var name = Encoding.UTF8.GetString(reader.ReadNullTerminatedByteString()); // ★ココ


AuthenticationMethodSwitchRequestPayload.Create() はいわゆる Authentication Method Switch Request を読み取ろうとしている。これはどういうデータかというと、{0xFE, 任意長のヌル終端文字列, 任意長のバイト列} というパケット内ペイロードである。

AuthenticationMethodSwitchRequestPayload.Create()
{0xFE, 任意長のヌル終端文字列, 任意長のバイト列} を(サーバ応答に含まれる)パケット内ペイロードから順次読み取ろうとした結果、任意長のヌル終端文字列を読み取るところで、そもそもそこにヌル終端文字列が存在せず、上述の "Read past end of buffer looking for NUL." という例外を吐いていた、というのが問題の直接的な要因。

public static AuthenticationMethodSwitchRequestPayload Create(PayloadData payload)
{
    var reader = new ByteArrayReader(payload.ArraySegment);
    reader.ReadByte(Signature); // ここまではデータがあるが
    var name = Encoding.UTF8.GetString(reader.ReadNullTerminatedByteString()); // ここからのデータが無くて例外が発生
    var data = reader.ReadByteString(reader.BytesRemaining);
    return new AuthenticationMethodSwitchRequestPayload(name, data);
}


では実際そのペイロードに何が含まれていたのかというと、これはライブラリのステップイン実行またはパケットキャプチャ等(自分はWiresharkを用いる)すれば分かるのだが、今回分析したペイロードに含まれるデータは 1 バイトの 0xFE だけであった。

シングルバイト 0xFE 応答の意味

ペイロード種別:Authentication Method Switch Request Packet

まずペイロードがシングルバイトでなんであれ、先頭バイトが 0xFE である以上、このペイロードが「Authentication Method Switch Request Packet」なのではなかろうかとアタリを付けるのは筋として悪くない…と思われたが…

MySQL :: MySQL Internals Manual :: 14.2.5 Connection Phase Packets

Protocol::AuthSwitchRequest:
 Authentication Method Switch Request Packet. If both server and client support CLIENT_PLUGIN_AUTH capability, server can send this packet to ask client to use another authentication method.
 
Payload
 1 [fe]
 string[NUL] plugin name
 string[EOF] auth plugin data

このプロトコルでは 0xFE に続けてやはり(ドキュメント上でも)ヌル終端文字列と任意バイト列が来ることが期待されている。

…何かがおかしい。

ペイロード種別:Old Authentication Method Switch Request Packet

上述と同じページの下部のほうに以下のような記載がある。

MySQL :: MySQL Internals Manual :: 14.2.5 Connection Phase Packets

Protocol::OldAuthSwitchRequest:
 Old Authentication Method Switch Request Packet consisting of a single 0xfe byte. It is sent by server to request client to switch to Old Password Authentication if CLIENT_PLUGIN_AUTH capability is not supported (by either the client or the server)
 
Payload
 1 [fe]

フムフムー。「Authentication Method Switch Request Packet」には「Oldなもの」と「そうでないもの」の2種類があることが分かる。そして両方ともペイロード先頭に 0xFE が入る形となっている。

つまりペイロード先頭が 0xFE だからといって必ずしも新しいほうの「Authentication Method Switch Request Packet」とは言えない。

ペイロード Authentication Method Switch Request Packet の新旧判別

以下のように判別すると良いだろうかと思う。

  • ペイロードの先頭バイトが 0xFE であった場合、
    • そのペイロード長が 1 バイトであれば「Old Authentication Method Switch Request Packet」
    • そのペイロード長が 2 バイト以上であれば「(新しいほうの) Authentication Method Switch Request Packet」

MySqlConnector の実装を見直す

さて MySqlConnector 側の実装を見直してみると、まさにペイロードの先頭バイトが 0xFE であるか否かを判別するコードがここ↓にある。

MySqlConnector/MySqlSession.cs at master · mysql-net/MySqlConnector

public async Task ConnectAsync(ConnectionSettings cs, IOBehavior ioBehavior, CancellationToken cancellationToken)
{
    …
    // ペイロード先頭バイト 0xFE の判別
    if (payload.HeaderByte == AuthenticationMethodSwitchRequestPayload.Signature)
    {
        // 処理は SwitchAuthenticationAsync() へ進むが…
        await SwitchAuthenticationAsync(cs, payload, ioBehavior, cancellationToken).ConfigureAwait(false);
        payload = await ReceiveReplyAsync(ioBehavior, cancellationToken).ConfigureAwait(false);
    }
    …
}
…
private async Task SwitchAuthenticationAsync(ConnectionSettings cs, PayloadData payload, IOBehavior ioBehavior, CancellationToken cancellationToken)
{
    // 残念ながらここでは「Old Authentication Method Switch Request Packet」は考慮されず、
    // 新しいほうの「Authentication Method Switch Request Packet」を読み取ろうとするので、
    // ペイロードがシングルバイト長であった場合には例外が発生する
    var switchRequest = AuthenticationMethodSwitchRequestPayload.Create(payload);
    …
}
…

というわけで、コネクタ側(今回は MySqlConnector)が「Old Authentication Method Switch Request Packet」を考慮した実装になっていないことにより、今回の認証時の例外が発生していた。

「Old Authentication Method Switch Request Packet」への対応

MySqlConnector の実装に手を入れて「Old Authentication Method Switch Request Packet」に対応することも考えたが、これはなかなか関連する情報量が少なく、難しい(これしか選択肢がなければやるけど、すごくやりたくない)。

そもそもこのパケットに対応している MySQL コネクタって今じゃそんなに多くないはず。だいたいセキュリティ的にも非推奨とされる Old なもの(後述)に対してコネクタ側があえて実装対応する理由が無い。

そもそもどのような条件で「Old Authentication Method Switch Request Packet」が発生するのか

これは経験則とか口伝で人から教わった情報とかでしかないのだが、認証の対象となる MySQL ユーザのレコードに保管されているパスワードハッシュが古い形式(16文字)であると、今回のこの「Old Authentication Method Switch Request Packet」が発生する。何度か検証してみた限りではこの説は正しそうである。

MySQLソースコード等を漁ればサーバ側ロジックの正確な箇所も見つかる気がするがそこまでは調べていない。

古い形式のパスワードハッシュ (16文字) について

MySQL :: MySQL 5.6 リファレンスマニュアル :: 6.1.2.4 MySQL でのパスワードハッシュ

元の (4.1 より前の) ハッシュ方式
元のハッシュ方式では 16 バイト文字列が生成されていました。そのようなハッシュは、次のようになります。

mysql> SELECT PASSWORD('mypass');
+--------------------+
| PASSWORD('mypass') |
+--------------------+
| 6f8c114b58f2ce9e |
+--------------------+


MySQL 4.1.1 ではハッシュ方式が変更され、41 バイトの長いハッシュ値が生成されるようになりました。

mysql> SELECT PASSWORD('mypass');
+-------------------------------------------+
| PASSWORD('mypass') |
+-------------------------------------------+
| *6C8989366EAF75BB670AD8EA7A7FC1176A95CEF4 |
+-------------------------------------------+

長いパスワードハッシュ形式は暗号化特性に優れ、長いハッシュに基づくクライアント認証の方が短いハッシュに基づく認証よりセキュアです。

という経緯となっている。古い形式のパスワードハッシュ (16文字) の残っているシステムは MySQL 4.1 以前の名残である模様。

パスワードハッシュを古い形式 (16文字) から新しい形式 (41文字) に変更する

セキュリティ的な意味でも今回の「Old Authentication Method Switch Request Packet」を発生させない意味でも、パスワードハッシュを新しい形式に切り替えることが諸問題の解決になり得ることが分かってきた。

それではパスワードハッシュをこれから変更したいわけだが、これはどういう手順でやれば良いかというと、SET PASSWORD 文を発行するだけで良い。

つまりシステム設定変更不要、再起動不要の作業になるので危なさとか面倒くささはそれほど無い。しかし認証に絡むところなので、特に既存サービスが運用中である場合等は慎重に作業すること。

※サービス運用中などで既存ユーザレコードをどうしても変更したくない(理論上は問題ないはずだがそれでも触るのは怖い)場合には新規ユーザを発行し、そちらに新しいパスワードハッシュを格納し、利用プログラムからは新規ユーザを使って認証する、という選択もある。

パスワードハッシュ計算の確認

まずいきなり SET PASSWORD をする前に、パスワードハッシュの計算の仕組みと結果を確認しておきたい。

パスワードハッシュは PASSWORD() という関数によって計算することができるが、この関数はシステム変数 old_passwords の ON/OFF によって挙動が変わる。

  • old_passwords = ON の場合 … PASSWORD() は古いハッシュ(16文字)を返す
  • old_passwords = OFF の場合… PASSWORD() は新しい形式のハッシュ(41文字)を返す

PASSWORD() 関数自体は SET PASSWORD と関係なく単体で実行確認できるので以下のように挙動を確かめると良い(システム運用者はこれを目でみてフーンではなくて本当に実際に手を動かして試してみて欲しい。実感がまるで違うから)。

なお、今回の検証では old_passwords をセッション上でしか変更していないので他セッションには影響を与えない。

■ 規定の old_passwords の確認
mysql> SHOW SESSION VARIABLES LIKE 'old_passwords';                                             
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| old_passwords | 0     |
+---------------+-------+

■ old_passwords を ON (1) にした状態で PASSWORD() 挙動を確かめる
mysql> SET SESSION old_passwords = 1;
mysql> SHOW SESSION VARIABLES LIKE 'old_passwords';
mysql> SELECT PASSWORD('hoge');
+------------------+
| PASSWORD('hoge') |
+------------------+
| 13ae08e92ef36dd0 |
+------------------+

■ old_passwords を OFF (0) にした状態で PASSWORD() 挙動を確かめる
mysql> SET SESSION old_passwords = 0;
mysql> SHOW SESSION VARIABLES LIKE 'old_passwords';
mysql> SELECT PASSWORD('hoge');
+-------------------------------------------+
| PASSWORD('hoge')                          |
+-------------------------------------------+
| *4266488C892EA7950486FEC0A1CFFC1BD9543F7B |
+-------------------------------------------+

このように、old_passwords の状態でハッシュ計算結果が新旧切り替わることを確認できる。

実際にパスワードハッシュを書き換える

今回は例として dbuser@localhost (パスワード: hoge) というユーザがいることを前提とする。

以下手順によりパスワードハッシュを旧形式から新形式に差し替える。

■ 現在状態の確認
mysql> SELECT Host, User, Password FROM mysql.user WHERE User = 'dbuser';
+-----------+--------+------------------+
| Host      | User   | Password         |
+-----------+--------+------------------+
| localhost | dbuser | 13ae08e92ef36dd0 | -- 短い
+-----------+--------+------------------+

■ パスワードハッシュを新形式に変更
mysql> SET SESSION old_passwords = 0; -- システム変数設定
mysql> SET PASSWORD FOR dbuser@localhost = PASSWORD('hoge'); -- ハッシュ書き換え
mysql> SELECT Host, User, Password FROM mysql.user WHERE User = 'dbuser'; -- 結果確認
+-----------+-------+-------------------------------------------+
| Host      | User  | Password                                  |
+-----------+-------+-------------------------------------------+
| localhost | dbuser| *4266488C892EA7950486FEC0A1CFFC1BD9543F7B | -- 長くなった
+-----------+-------+-------------------------------------------+

対応は以上。
これで「Old Authentication Method Switch Request Packet」は発生しなくなる。

お礼

パスワードの再設定で今回の件が対応できることについては @kuzuha 氏に教えてもらいました。ありがとうございます。

kobake:
 MySqlConnectorがポシャる原因、サーバが古すぎることに起因していて
 http://imysql.com/mysql-internal-manual/connection-phase-packets.html
 > Old Authentication Method Switch Request Packet consisting of a single 0xfe byte.
 コネクタがこれに対応している必要があった
 もう運用中のMySQLサーバだとバージョン上げるのは難しそうだし
 コネクタ側を古いプロトコルに対応させるパッチ作ることになりそう
 
kuzuha:
 オッ昔廃止されたやつだ
 mysqlのバージョンあげなくてもパスワードの再設定でいけそうだけどな
 
kobake:
 0xFE のシングルバイト応答が来たときの実装がコネクタ側にないので
 それが返らないようにサーバを簡単に設定できるならそれが一番楽
 
kuzuha:
 show variables like 'old_passwords' で 1 が返ってきたら多分そのせい
 old_passwords = 0 に設定してユーザを全部作り直せば治るんじゃないかな
 だるい作業だけどmysql connectorに手を入れるよりはマシ
 
kobake:
 +---------------+-------+
 | Variable_name | Value |
 +---------------+-------+
 | old_passwords | ON |
 +---------------+-------+
 ヒット
 ありがとうございます

まとめ

  • MySQL サーバとの認証で「ペイロード長が 1 byte で中身が 0xFE」の応答が返ってきたら、それは「Old Authentication Method Switch Request Packet」とみなしてほぼ間違いない。
  • 最近の MySQL コネクタだとそんなプロトコルに対応していることがおそらく少ないはずで、結果的にこのパケットに対応していないコネクタがこのパケットを受け取ると変な例外が発生したりする。
  • その辺の周りでおかしいな?と思ったらサーバ側のシステム変数 old_passwords であったり、パスワードハッシュの長さを確認してみると良い。
  • パスワードハッシュ長が 16 byte であれば明らかに形式が古い。場合によるが、基本的には新形式 (41文字) への変更をお勧めする。変更に際して基本的には MySQL の再起動は必要ない。手順は本記事内を参照。
  • old_passwords はあくまでもシステム変数でしかないので、この変数値を変えた瞬間にユーザレコードのパスワードハッシュが書き換わるわけではない。明示的にパスワード設定し直すとき(SET PASSWORD であったり GRANT であったりを実行するとき)に old_passwords が参照される、というだけ。
  • そんなわけで、old_passwords が OFF であっても既存ユーザレコードのパスワードハッシュが旧形式のままであることは普通にあり得るので、ちゃんとユーザレコードも見ること。

MySqlConnector に PR 投げた

github.com

MySqlConnectorOld Authentication Method Switch Request Packet を受け取ってしまったときに、それが Old Authentication Method Switch Request Packet であることを明示するような例外を吐く実装を追加した。

public async Task ConnectAsync(ConnectionSettings cs, IOBehavior ioBehavior, CancellationToken cancellationToken)
{
    …
    if (payload.HeaderByte == AuthenticationMethodSwitchRequestPayload.Signature)
    {
        if (payload.ArraySegment.Count == 1) // 長さチェック
        {
            // 明示的な例外
            throw new NotSupportedException("Old Authentication Method Switch is not supported. Use new password hash format of 41-byte in MySQL server, not old format of 16-byte.");
        }
        else
        {
            …
        }
    }
    …
}

早く取り込まれると良いな。


8/27 00:30 JST もうマージされてました。反応早いと嬉しいですね!

おしまい

知っている人は知っている、知らない人は途方にくれる、そんな問題。

不幸にも同じ苦労をする人がこの記事で少しでも救われますように。

});