🐬

パスワードレス MySQL Proxy を作る

2023/07/26に公開

やりたいこと

下記の MySQL 版を作りたいです。
https://github.com/mothership/rds-auth-proxy

なお、現時点では完全に上記のツールと同等な機能ではありません。

動機

AWS RDS MySQL や Aurora MySQL を利用していると、ローカル PC などの端末からデータベースインスタンスに接続したい場面があると思います。
データベースインスタンスへのネットワーク接続については、Session Manager の PortForward を利用することで踏み台の管理も必要なく、また、AWS IAM 認証情報をもとに接続制限ができます。
また、データベースに接続するときの認証情報(ユーザー名やパスワード)については、IAM データベース認証を利用することで、パスワードの代わりに認証トークンを利用できるため、データベースに接続するときの認証情報を不必要に利用せずに済みます。

ただ、IAM データベース認証は毎回 AWS CLI などで認証トークンを発行し、MySQL Client のデータベースに接続するときの認証情報を更新する必要があり、少し面倒です(DBeaver の有料版は https://dbeaver.com/docs/wiki/AWS-Credentials/ IAM データベース認証を直接 DBeaver から利用できるみたいです)。

そこで、MySQL Client と MySQL Server の間に Proxy を立ち上げてこの作業を少しでも簡略化したいと思いました。MySQL Client と Proxy の間はパスワードなしで、Proxy と MySQL Server の間は IAM データベース認証なり、Secrets Manager から取得したパスワードを利用するなりする、といったイメージです。
Proxy が文字通り MySQL Client の代理として MySQL Server への認証を通す必要があるため、MySQL のプロトコルを確認していきます。

MySQL プロトコル

MySQL Client と MySQL Server 間のプロトコルはドキュメントに記載があります。

クエリを発行するまでに必要な認証手続きは以下のようなフローになります。

  1. MySQL Server から Initial Handshake パケットが送信される
  2. Initial Handshake パケットをもとに、データベースに接続情報(ユーザー名とパスワードのハッシュ)を計算する
  3. MySQL Client から MySQL Server に Handshake Response パケットを送信する
  4. MySQL Server から MySQL Client に Ok パケットが返却される
  5. (クエリを発行する)

なお、今回は SSL 接続は無効化する前提です。

MySQL プロトコルについては日本語でも解説されているブログが多くありますので、ぜひご確認ください。

実装

今回はこちらのブログを参考に、Rust で実装してみました。
下記レポジトリに全体のコードを載せています。

https://github.com/tyrwzl/mysql-passwordless-proxy

TCP 接続の確立

まずは tokio を利用して MySQL Client と MySQL Server との間の TCP 接続を確立します。
ローカルの 3306 ポートに MySQL Client が接続しにいき、MySQL Server には ローカルの 3307 ポートから接続する構成にしています。

src/main.rs
#[tokio::main]
async fn main() -> std::io::Result<()> {
    let listen_addr = "127.0.0.1:3306";
    let server_addr = "127.0.0.1:3307";

    println!("Listening on: {}", listen_addr);
    println!("Proxying to: {}", server_addr);

    let listener = TcpListener::bind(listen_addr).await?;
    while let Ok((mut inbound, _)) = listener.accept().await {
        let mut outbound = TcpStream::connect(server_addr).await?;

        // handle authentication
        handle_auth(&mut inbound, &mut outbound).await?;
        
        ...
    }

    Ok(())
}

Initial Handshake の読み取り

次に、MySQL Server からパケットが送信されるので、パケットをパースして、Initial Handshake パケットの情報を取得します。
MySQL のパケットはヘッダーとペイロードの二つにわけることができます。

ヘッダーは最初の 4 byte で、頭から 3 byte にペイロードのサイズが、最後の byte にシーケンス番号が記載されています。
ペイロードは可変長なので、ペイロード全体を読み取るために、ペイロードサイズを読み取ります。
正確にはパケットが分割される場合もありますが、今回は省略しています。
ペイロードから Handshake Response の計算に必要な情報をパースします。
今回利用しないものについては、payload_offset に対応するバイト数を加算してスキップしています。

src/mysql/auth.rs
struct InitialHandshake<'slice> {
    payload_size: usize,
    auth_plugin_data_part1: &'slice [u8],
    auth_plugin_data_part2: &'slice [u8],
    auth_plugin_name: &'slice [u8],
}

fn parse_initial_handshake(buf: &[u8]) -> InitialHandshake {
    let payload_size = buf[0] as usize + (buf[1] as usize >> 8) + (buf[2] as usize >> 16);

    // skip header
    let mut payload_offset = 4;
    // skip protocol version
    payload_offset += 1;
    let idx = buf[payload_offset..]
        .iter()
        .position(|p| p == &0u8)
        .unwrap();
    // skip server version
    payload_offset += 1 + idx;
    // skip thread id
    payload_offset += 4;

    let auth_plugin_data_part1 = &buf[payload_offset..payload_offset + 8];
    payload_offset += 8;

    // skip filler
    payload_offset += 1;
    // skip capability_flags_1
    payload_offset += 2;
    // skip character_set
    payload_offset += 1;
    // skip status_flags
    payload_offset += 2;
    // skip capability_flags_2
    payload_offset += 2;

    let length_of_plugin_auth_data = buf[payload_offset] as usize;
    payload_offset += 1;

    // reserved 10bytes
    payload_offset += 10;

    let auth_plugin_data_part2 =
        &buf[payload_offset..payload_offset + (length_of_plugin_auth_data - 8)];
    payload_offset += length_of_plugin_auth_data - 8;

    let idx = buf[payload_offset..]
        .iter()
        .position(|p| p == &0u8)
        .unwrap();
    let auth_plugin_name = &buf[payload_offset..payload_offset + idx];

    InitialHandshake {
        payload_size,
        auth_plugin_data_part1,
        auth_plugin_data_part2,
        auth_plugin_name,
    }
}

Handshake Response の計算 && 送信

MySQL Server Greeting の読み取りが完了したら、次は MySQL Server 側に送信する Handshake Response パケットを構築します。

src/mysql/auth.rs
fn calc_handshake_response(initial_handshake: &InitialHandshake, buf: &mut [u8]) -> usize {
    let mut offset = 0;
    // skip header
    offset += 4;

    let capabilities = (CLIENT_PROTOCOL_41
        | CLIENT_SECURE_CONNECTION
        | CLIENT_LOCAL_FILES
        | CLIENT_LONG_PASSWORD
        | CLIENT_TRANSACTIONS
        | CLIENT_INTERACTIVE
        | CLIENT_DEPRECATE_EOF
        | CLIENT_QUERY_ATTRIBUTES
        | CLIENT_LONG_FLAG)
        .to_le_bytes();
    buf[offset] = capabilities[0];
    buf[offset + 1] = capabilities[1];
    buf[offset + 2] = 0x00;
    buf[offset + 3] = capabilities[3];
    offset += 4;

    // set max_packet_size
    offset += 3;
    buf[offset] = 0x01;
    offset += 1;

    // set character_set
    buf[offset] = 0x2d;
    offset += 1;

    // skip filler
    offset += 23;

    let username = "適切なユーザー名を入れてね".as_bytes();
    buf[offset..offset + username.len()].clone_from_slice(username);
    offset += username.len() + 1;

    // calculate auth_response
    // SHA1( password )
    let password = "適切なパスワードを入れてね".as_bytes();
    let mut hasher = Sha1::new();
    hasher.update(password);
    let mut auth_response = hasher.finalize();

    // SHA1( SHA1( password ) )
    let mut hasher = Sha1::new();
    hasher.update(auth_response);
    let key2 = hasher.finalize();

    // challenge
    let challenge = [
        initial_handshake.auth_plugin_data_part1,
        &initial_handshake.auth_plugin_data_part2[..12],
    ]
    .concat();

    // SHA1(challenge + SHA1(SHA1(password)))
    let challenge = [challenge, key2.to_vec()].concat();
    let mut hasher = Sha1::new();
    hasher.update(challenge);
    let challenge_key = hasher.finalize();

    // SHA1( password ) XOR SHA1( challenge + SHA1( SHA1( password ) ) )
    for i in 0..20 {
        // XOR
        auth_response[i] ^= challenge_key[i]
    }

    // set auth_response
    buf[offset] = auth_response.len() as u8;
    offset += 1;
    buf[offset..offset + auth_response.len()].clone_from_slice(&auth_response);
    offset += auth_response.len();

    // set auth_plugin_name
    buf[offset..offset + initial_handshake.auth_plugin_name.len()]
        .clone_from_slice(&initial_handshake.auth_plugin_name);
    offset += initial_handshake.auth_plugin_name.len();
    offset += 1;

    // set payload_size
    let payload_size = (offset - 4).to_le_bytes();
    buf[0] = payload_size[0];
    buf[1] = payload_size[1];
    buf[2] = payload_size[2];
    // set sequence_id
    buf[3] = 0x1;

    return offset;
}

なお、今回はユーザ名とパスワード、capabilities フラグを予め決めたものにしています。
最後に、読み取りとは逆に送信するパケットの先頭 3 パケットにペイロードサイズとシーケンス ID をセットします(シーケンス ID が適切でないと、Got packets out of order というエラーを受け取ります。)

OK パケットの確認

無事に計算が正しければ下記のようなパケットを受け取ることができます。

src/mysql/auth.rs
    // read OK from MySQL Server
    let mut response = [0u8; 1024];
    outbound.read(&mut response).await?;
    println!("payload from server: ");
    hexdump::hexdump(&response);
出力
payload from server: 
|07000002 00000002 00000000 00000000| ................ 00000000
|00000000 00000000 00000000 00000000| ................ 00000010
|00000000 00000000 00000000 00000000| ................ 00000020
|00000000 00000000 00000000 00000000| ................ 00000030

クエリ発行

この後は MySQL Client と MySQL Server 間が自由にやり取りをして問題ないため、bidirectional を利用して TCP パケットをパススルーします。

src/main.rs
    let listener = TcpListener::bind(listen_addr).await?;
    while let Ok((mut inbound, _)) = listener.accept().await {
        let mut outbound = TcpStream::connect(server_addr).await?;

        // handle authentication
        handle_auth(&mut inbound, &mut outbound).await?;
        match tokio::io::copy_bidirectional(&mut inbound, &mut outbound).await {
            Ok((to_egress, to_ingress)) => {
                println!(
                    "Connection ended gracefully ({to_egress} bytes from client, {to_ingress} bytes from server)"
                );
            }
            Err(err) => {
                println!("Error while proxying: {}", err);
            }
        }
    }

動作確認

実際に MySQL CLI client を利用して Proxy を動かしてみます。

なお、今回は mysql_native_password を利用しているため、事前に下記のようなクエリで接続ユーザーが利用する認証方法を mysql_native_password に指定しておきます。

ALTER USER '適切なユーザー名を入れてね' IDENTIFIED WITH mysql_native_password BY '適切なパスワードを入れてね';
flush privileges;

下記の様に、パスワードなしでも

$ mysql -u root -h 127.0.0.1 -P3306  --ssl-mode=DISABLED
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 2
Server version: 5.7.42 MySQL Community Server (GPL)

Copyright (c) 2000, 2023, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
4 rows in set (0.00 sec)

変なパスワードでも接続できます。

$ mysql -u root -h 127.0.0.1 -P3306  --ssl-mode=DISABLED -psutekinapassword
mysql: [Warning] Using a password on the command line interface can be insecure.
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 3
Server version: 5.7.42 MySQL Community Server (GPL)

Copyright (c) 2000, 2023, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
4 rows in set (0.00 sec)

mysql> 

一方で、直接接続にするとパスワードが正しくない情報が確認できます。

$ mysql -u root -h 127.0.0.1 -P3307  --ssl-mode=DISABLED -psutekinapassword
mysql: [Warning] Using a password on the command line interface can be insecure.
ERROR 1045 (28000): Access denied for user 'root'@'172.19.0.1' (using password: YES)

まとめ

不完全ですが、パスワードレス MySQL Proxy を作ってみました。
ここには書いていないのですが、Wireshark とにらめっこして、どのパケットがおかしいのかデバッグしたのですが、MySQL プロトコルについてパケット単位で確認することができて勉強になりました。Rust で MySQL サーバーに接続するときに、sqlx を利用していたのですが、sqlx-mysql クレートに今回と同じような処理が記載されていて、少し MySQL Driver と仲良くなれた気がします。

今後は IAM データベース認証を利用できるようにするため、IAM データベース認証の前提である SSL 接続に対応する必要があります。
また、今回認証プラグインなどを決め打ちにしている箇所も修正する必要があります。
細かいところですが、冒頭で紹介したツールのように、接続先のデータベースの列挙して接続先を選択できるようにするなど、使い勝手を上げていくために引き続き開発していきたいと思います。

Discussion