サービス開発部SRE課の@vvatanabeです。
2021年9月26日、OpenSSH 8.8がリリースされました。大きな変更として挙げられるのは、SHA-1ハッシュアルゴリズムを使用したRSA署名の廃止です。
本記事では、この変更がBacklogに与えた影響、その時現場で起こっていたこと、問題解決のプロセス、なにを教訓にしたのか等、順を追って解説します。
※ 本記事はNuCon 2021で発表した内容をブログ化したものです。
目次
問題の発覚
BacklogのGitへSSHでアクセスできない
TypetalkのBacklog開発者のトピックで、以下のフィードバックが投稿されました。
「OpenSSH 8.8へアップデートすると、BacklogのGitへSSHアクセスできない」という内容でした。
問題の調査
Inside SSH protocol v2
深堀りしていく前に、SSHプロトコルの接続確立の流れをざっくりと紹介します。
以下の図はSSHプロトコル v2の大まかな処理を表すシーケンスです。
SSHプロトコルは、トランスポート層プロトコル、ユーザー認証プロトコル、コネクションプロトコル、ファイル転送プロコトルといった4つの独立したプロトコルに分かれており、階層構造を成すことで、SSHと呼ばれる仕組みを実現しています。BacklogのGitのSSHでは、そのうち、トランスポート層プロトコル、ユーザー認証プロトコル、コネクションプロトコルを使って認証込のリポジトリへのアクセスを実現しています。
参考: [RFC4253] The Secure Shell (SSH) Transport Layer Protocol
トランスポート層プロトコルの処理で、本記事の内容に関連する部分を簡単に説明します。
TCP接続が確立された後、サーバとクライアントの間でSSHバージョン文字列を交換して、サーバーとクライアント間で使用するSSHプロトコルのバージョンを決定します。
次に、クライアントとサーバー間の暗号通信で用いる各アルゴリズムのネゴシエーションを実施して、使用可能な暗号アルゴリズムを交換します。以下は交換するアルゴリズムの種類です。
- Kex algorithms
- Server host key algorithms
- Encryption algorithms a.k.a Cipher
- MAC algorithms
- Compression algorithms
その後、Diffie-Hellman鍵交換方式で暗号通信で使う共通鍵を交換します。
そして、中間者攻撃を防ぐためのホスト認証を行います。サーバーが持つホスト公開鍵と、クライアントのknown_hostsにで保持しているホスト公開鍵を照合して,初回接続以降一致しなければ接続を中断します。
以上の処理を念頭に置いた上で本件について深堀りしていきます。
SSHクライアントのdebugログを読む
まずは、同じOpenSSH 8.8で動作検証を行いました。
$ ssh -V OpenSSH_8.8p1, OpenSSL 1.1.1f 31 Mar 2020
gitコマンドのcore.sshCommandというオプションを使って、使用するsshコマンドのオプションにverboseを追加しています。これでdebugログが出力されるようになります。
以下は部分的に抜粋したログです。
$ git -c core.sshCommand="ssh -vvv -F /dev/null" clone \ foo@foo.git.backlog.com:/BAR/baz.git 〜 省略 〜 debug2: peer server KEXINIT proposal debug2: KEX algorithms: curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group14-sha1 debug2: host key algorithms: ssh-rsa 〜 省略 〜 debug1: kex: algorithm: curve25519-sha256@libssh.org debug1: kex: host key algorithm: (no match) Unable to negotiate with 54.258.105.89 port 22: no matching host key type found. Their offer: ssh-rsa fatal: Could not read from remote repository. Please make sure you have the correct access rights and the repository exists.
以下のログから、ホスト鍵のアルゴリズムとしてはssh-rsaが選択されているように見受けられました。
debug2: host key algorithms: ssh-rsa
その他にも、他にも、一致するホスト鍵タイプが見つからないと言われているようでした。
no matching host key type found. Their offer: ssh-rsa
SSHサーバーのerrorログを読む
次に、サーバー側のログを調査してみると、それらしきエラーログが出力されていました。
ホスト鍵の共通アルゴリズムがない?と読み取れます。
ssh: no common algorithm for host key; client offered: [rsa-sha2-512-cert-v01@openssh.com rsa-sha2-256-cert-v01@openssh.com rsa-sha2-512 rsa-sha2-256 ssh-ed25519-cert-v01@openssh.com ecdsa-sha2-nistp256-cert-v01@openssh.com ecdsa-sha2-nistp384-cert-v01@openssh.com ecdsa-sha2-nistp521-cert-v01@openssh.com sk-ssh-ed25519-cert-v01@openssh.com sk-ecdsa-sha2-nistp256-cert-v01@openssh.com ssh-ed25519 ecdsa-sha2-nistp256 ecdsa-sha2-nistp384 ecdsa-sha2-nistp521 sk-ssh-ed25519@openssh.com sk-ecdsa-sha2-nistp256@openssh.com], server offered: [ssh-rsa]
golang.org/x/cryptoを読む
続いて、エラーログを出力しているSSHサーバーの該当のコードを調査しました。
BacklogのGit SSHサーバーはGoで実装されており、SSHプロトコルの実装はGoの準標準パッケージのgolang.org/x/cryptoのsshパッケージ(以下、crypto/sshと呼ぶ)を使用しています。
以下のコードはcrypto/sshから一部抜粋しています。
handshakeTransport structがトランスポート層プロトコルを実装していました。このstructが持つenterKeyExchange methodで、合意可能な各暗号アルゴリズムを探すfindAgreedAlgorithms関数を実行しています。
// ssh/handshake.go func (t *handshakeTransport) enterKeyExchange(otherInitPacket []byte) error { 〜 省略 〜 var err error t.algorithms, err = findAgreedAlgorithms(isClient, clientInit, serverInit) if err != nil { return err }
Refs: ssh/handshake.go#L555-L558
次に、findAgreedAlgorithms関数では、サーバーとクライアントが共通して使用可能な暗号アルゴリズムを探していました。所謂アルゴリズムネゴシエーションを行っています。
// ssh/common.go func findAgreedAlgorithms(isClient bool, clientKexInit, serverKexInit *kexInitMsg) (algs *algorithms, err error) { result := &algorithms{} result.kex, err = findCommon("key exchange", clientKexInit.KexAlgos, serverKexInit.KexAlgos) if err != nil { return } result.hostKey, err = findCommon("host key", clientKexInit.ServerHostKeyAlgos, serverKexInit.ServerHostKeyAlgos) if err != nil { return }
Refs: ssh/common.go#L156-L167
最後に、findCommon関数では、共通のアルゴリズムが見つかった場合に、一致したアルゴリズムの文字列を返却していました。逆に、共通のアルゴリズムが見つからなかった場合には、対象のエラーが返却されていました。
// ssh/common.go func findCommon(what string, client []string, server []string) (common string, err error) { for _, c := range client { for _, s := range server { if c == s { return c, nil } } } return "", fmt.Errorf("ssh: no common algorithm for %s; client offered: %v, server offered: %v", what, client, server) }
Refs: ssh/common.go#L116-L125
ssh: no common algorithm for host key; client offered:
どうやら、サーバーとクライアント間でホスト鍵のアルゴリズムの合意を得るために、それぞれ共通のアルゴリズムを検索しているのですが、一致するものが存在せずにエラーになっているようです。
今回はそのアルゴリズムのネゴシエーションの段階で失敗していることになります。
OpenSSH 8.8のリリースノートを読む
OpenSSH 8.8のリリースノートを確認すると、OpenSSH 8.8からSHA-1ハッシュアルゴリズムを使ったRSA署名が廃止されていました。おそらく、このRSA署名が廃止されたことで、ホスト鍵のアルゴリズムネゴシエーションに失敗しているのではないかと推測できました。
Potentially-incompatible changes
================================
This release disables RSA signatures using the SHA-1 hash algorithm by default. This change has been made as the SHA-1 hash algorithm is cryptographically broken, and it is possible to create chosen-prefix hash collisions for <USD$50K [1]
これは既にOpenSSH 8.3のリリース時にアナウンスされていたことでした。
Future deprecation notice
=========================
It is now possible[1] to perform chosen-prefix attacks against the SHA-1 algorithm for less than USD$50K. For this reason, we will be disabling the “ssh-rsa” public key signature algorithm by default in a near-future release.
この件は認知していたものの、業務のタスクとして起票できていなかったため、事前に対応できていませんでした。
セキュリティチーム、サポートチームと連携する
原因に当たりがついたところで、セキュリティチームと呼ばれる、ヌーラボが提供する各サービスのセキュリティに関する横断的な対応や支援活動を行っているチームに、本件を共有して対応への協力を依頼しました。
根本的な原因はわかっていますが、実際の影響範囲を明確にするために、サポートするOSと、サポートする全ての鍵、鍵交換アルゴリズムの組み合わせで網羅的にテストを実施しました。
結果、クライアントがどの鍵を使おうと、OpenSSH 8.8ではBacklogのGitへアクセスできないことがわかりました。
また、お客様の問い合わせ窓口となっているカスタマーサポートチームにも、この問題を共有して、暫定的な回避策を確認でき次第、随時共有する旨を伝えました。
暫定的な回避策の調査
一時的にssh-rsaを有効化する
回避策として、SSHの設定ファイル($HOME/.ssh/config, /etc/ssh/ssh_config)を以下のように更新することで、一時的にssh-rsa(SHA-1を用いたRSA署名)を有効化できることを確認しました。
Host foo.backlog.com HostkeyAlgorithms +ssh-rsa PubkeyAcceptedAlgorithms +ssh-rsa
ホストを指定して、ホスト認証とユーザ認証で利用可能なアルゴリズムに廃止されたssh-rsaを追加しています。
ただし、以下のリリースノートにも記載の通り、ssh-rsaを有効にするのは、従来の実装をアップグレードしたり、別の鍵タイプ(ECDSAやEd25519など)使えるようになるまでの応急処置としてのみ行ってほしいとのことで推奨されることではありません。
We recommend enabling RSA/SHA1 only as a stopgap measure until legacy implementations can be upgraded or reconfigured with another key type (such as ECDSA or Ed25519).
あるいはHTTPSプロトコルの使用する
前述の通り、一時的にssh-rsaの有効化は推奨されることではありません。
そのため、お客様へ案内可能な暫定的なワークアラウンドとして、HTTPSを使ったリポジトリへのアクセスも合わせて、カスタマーサポートチームへ共有しました。
恒久的な解決方法の調査
OpenSSH 8.8で廃止されたssh-rsaとは?
OpenSSH 8.8で廃止されたssh-rsaは、RSAの署名方式であるハッシュアルゴリズムのSHA-1を指しています。鍵の形式のRSAそのものが廃止されたわけではありません。
OpenSSH 7.2からRFC8332に従ってハッシュアルゴリズムのSHA-256/512をサポートしているので、クライアントの鍵の形式がssh-rsaであっても、クライアントは意識せずそのまま使用できるはずです。
For most users, this change should be invisible and there is no need to replace ssh-rsa keys. OpenSSH has supported RFC8332 RSA/SHA-256/512 signatures since release 7.2 and existing ssh-rsa keys will automatically use the stronger algorithm where possible.
それでは何故接続できなくなってしまったのか? 恒久的な対応としてなにが必要なのか? 調査を進めました。
golang.org/x/cryptoにおけるRSA署名を調査する
BacklogのGit SSHサーバーが使用しているホスト鍵の形式はRSAです。RFCで定義されているRSAに対応する署名方式のハッシュアルゴリズムは、SHA-1、SHA-256/512となります。
そのため、今回のようにSSHクライアント側でSHA-1が無効になっていても、SHA-256/512をクライアントとサーバーの双方がサポートしていれば、問題なくアルゴリズムのネゴシエーションは完了するはずです。
しかし、SSHクライアントのdebugログを見て分かるように、SSHサーバーは使用可能なホスト鍵アルゴリズムとしてssh-rsa(ハッシュアルゴリズムSHA-1)のみ返しています。
debug2: host key algorithms: ssh-rsa
そこで、SSHサーバー側で使用しているcrypto/sshのコードを確認しました。
以下は、サーバーが対応するホスト鍵のアルゴリズムを返却する該当のコードです。
// ssh/handshake.go // sendKexInit sends a key change message. func (t *handshakeTransport) sendKexInit() error { // 〜 省略 〜 msg := &kexInitMsg{ KexAlgos: t.config.KeyExchanges, CiphersClientServer: t.config.Ciphers, CiphersServerClient: t.config.Ciphers, MACsClientServer: t.config.MACs, MACsServerClient: t.config.MACs, CompressionClientServer: supportedCompressions, CompressionServerClient: supportedCompressions, } io.ReadFull(rand.Reader, msg.Cookie[:]) if len(t.hostKeys) > 0 { for _, k := range t.hostKeys { msg.ServerHostKeyAlgos = append( msg.ServerHostKeyAlgos, k.PublicKey().Type()) } } else { msg.ServerHostKeyAlgos = t.hostKeyAlgorithms } packet := Marshal(msg) // writePacket destroys the contents, so save a copy. packetCopy := make([]byte, len(packet)) copy(packetCopy, packet) if err := t.pushPacket(packetCopy); err != nil { return err } // 〜 省略 〜 }
Refs: ssh/handshake.go#L436-L474
handshakeTransportはトランスポート層プロトコルを実装しており、sendKexInitメソッドでSSH_KEX_INITメッセージをSSHクライアントへ送信しています。SSH_KEX_INITメッセージには、サーバー側で受け入れ可能な各暗号アルゴリズムが含まれています。そのSSH_KEX_INITメッセージにホスト鍵のアルゴリズムを追加する際に、hostKeysという配列を読み取り、各ホスト鍵のPublicKeyインターフェイスが持つTypeメソッドでアルゴリズムの名前を取得しています。
次に、ホスト鍵がRSAの場合のPublicKeyインターフェイスの実装を探しました。以下は該当のコードを一部抜粋したものです。
// ssh/keys.go type rsaPublicKey rsa.PublicKey func (r *rsaPublicKey) Type() string { return "ssh-rsa" }
Refs: ssh/keys.go#L336-L340
ホスト鍵がRSAの場合はrsaPublicKeyが適用され、アルゴリズム名はssh-rsaとなります。そのため、crypto/sshではSHA-256/SHA-512の署名アルゴリズムはRSAのホスト鍵に対してデフォルトでは適用されないようです。
ホスト鍵の差し替えを検討する
ここまでの調査をふまえて最初に検討したのは、ホスト鍵を他の暗号タイプのものと差し替えることでした。具体的にはecdsaやed25519といった暗号タイプです。
しかし、このアプローチだとサーバーのホスト鍵が変わるので、クライアントがknown_hostsに記録している公開鍵と一致せずに、クライアント側で中間者攻撃(Man In The Middle Attack)を警告するメッセージを表示して、接続を中断してしまいます。
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY! Someone could be eavesdropping on you right now (man-in-the-middle attack)! It is also possible that a host key has just been changed.
これでは、ユーザーは自分自身の判断でknown_hostsを編集しなければならず、何より不安を煽ります。いずれにせよ事前にホスト鍵を変更するといったユーザーへのアナウンスが必要となります。
このアプローチは影響範囲が大きいのでいったん保留にしました。
UpdateHostKeyの活用を検討する
本件を報告してくれた開発者から、「UpdateHostKeyの機能をSSHサーバー側でサポートするのはどうか」と、UpdateHostKeyのプロトコルを実装し、検証用のPoCを作ってくれた上で提案がありました。
UpdateHostKeyとは
UpdateHostKeyとは、SSHプロ卜コルの拡張機能の一つです。
この拡張機能により、サーバーが持つ複数のホスト鍵をすべてクライアントへ通知できるようになります。この通知を受け取ったクライアントは、提供されたホスト鍵のうちどの鍵がknown_hostsに存在するかを確認します。これにより、クライアントはサーバーの複数のホスト鍵の中から、それまで認知していない暗号タイプの公開鍵を知ることができ、known_hostsに記録している公開鍵をグレイスフルに更新できるようになります。
詳細はgithub.com/openssh/openssh-portableのPROTOCOLファイルに定義されています。
このプロトコルは、クライアントのUpdateHostKeysを有効にすることで動作します。OpenSSH 8.5からデフォルトで有効になっているようです。
しかし、これはユーザー認証後、つまりアルゴリズムのネゴシエーションが正常に完了してから開始されるものでした。
今回のケースでは、もとのホスト鍵はssh-rsa(SHA-1)なのでトランスポート層プロトコルのアルゴリズムネゴシエーションの時点で失敗してしまいます。
[結論] rsa-sha2-256、rsa-sha2-512をサポートする
ここまでの調査結果から、crypto/sshでホスト鍵のアルゴリズムとしてrsa-sha2-256、rsa-sha2-521をサポートするアプローチを選択しました。
恒久的な対応の実施
golang.org/x/cryptoのIssueを調査する
今回のOpenSSH8.8の変更は、事前アナウンスはあったものの影響範囲は大きいだろうと思い、GoのIssueを検索してみました。案の定、SHA-2のサポートを求めるIssueが複数上がっていました。
- x/crypto/ssh: support RSA SHA-2 host key signatures #37278
- x/crypto/ssh: publicKeyCallback cannot handshake using ssh-rsa keys signed using the ssh-rsa-sha2-256 algorithm #39885
既存のPull Requestを検証する
両者異なるアプローチでPull Requestまで作成されており、とても参考になりました。
ssh: support RSA SHA-2 (RFC8332) signatures [#37278] は、ホスト鍵がRSAの場合に、SHA-1、SHA-256、SHA-512を受け入れ可能なアルゴリズムとしてデフォルトでSSH_KEX_INITメッセージに含める改修でした。
ssh: allow the client public key auth method to use custom algorithms during the ssh handshake [#39885] は、ホスト鍵を表す構造体の署名ハッシュアルゴリズムを、外から特定の署名ハッシュアルゴリズムで上書き可能にする改修でした。
SHA-256 and SHA-512をサポートするための仕様となるRFC8332を確認し、両者のIssueに対するPull Requestブランチをローカルマシンに落として、SSHのテストサーバーを作り動作検証しました。
Refs: [RFC8332] Use of RSA Keys with SHA-256 and SHA-512 in the Secure Shell (SSH) Protocol
しかし、両方のアプローチはともに、ホスト鍵のアルゴリズムのネゴシエーションはクリアできましたが、その後のユーザー認証に失敗しました。
具体的には、SSHクライアントがユーザー認証リクエストとして送信するメッセージのパラメーター(認証メソッド)に、期待している公開鍵認証の値(publickey)が含まれておらず、サーバー側はどのユーザー認証方式を適用すればいいのか決定できない状態になっていました。
RFC8308 server-sig-algsを実装する
引き続きRFC8332を読み進めると、SSHの拡張ネゴシエーションメカニズムの一つである「server-sig-algs」をサーバー側で実装する必要があることがわかりました。
Servers that accept rsa-sha2-* signatures for client authentication SHOULD implement the extension negotiation mechanism defined in [RFC8308], including especially the “server-sig-algs” extension.
Refs: RFC8308 Extension Negotiation in the Secure Shell (SSH) Protocol
「server-sig-algs」とは
「server-sig-algs」は、要約するとサーバーが受け入れ可能な署名ハッシュアルゴリズムの一覧をクライアントへ通知するための仕組みです。
トランスポート層プロトコルのアルゴリズムネゴシエーションに手を入れます。
「server-sig-algs」の処理の流れ
「server-sig-algs」の処理の流れを解説します。
1. サーバーがTCP接続を受け入れ次第、SSHプロトコルが開始されます
2. サーバーとクライアント間でSSHプロトコルのバージョン情報を交換を行います
3. SSH_MSG_KEXINITと呼ばれる、クライアントとサーバー間の暗号アルゴリズムのネゴシエーションが開始されます
4. クライアントは、以下のように、SSH_MSG_KEXINITメッセージのパラメーター(kex_algorithmsフィールド)にext-info-c文字列を含めて送信します。
SSHクライアントのdebugログの例:
debug2: KEX algorithms: curve25519-sha256,curve25519-sha256@libssh.org,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521,diffie-hellman-group-exchange-sha256,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group14-sha256,ext-info-c
5. サーバーは、SSH_MSG_KEXINITメッセージのパラメーターにext-info-c文字列が含まれていることを確認します。
6. ext-info-c文字列が含まれている場合、SSH_MSG_EXT_INFOメッセージにKey-Value形式でExtension名(server-sig-algs)と値(受け入れ可能な署名アルゴリズムのリスト)を含めて、クライアントへ送信します。
# SSH_MSG_EXT_INFOメッセージ byte SSH_MSG_EXT_INFO (value 7) uint32 nr-extensions repeat the following 2 fields "nr-extensions" times: string extension-name string extension-value (binary)
# server-sig-algsのExtension名と値 string "server-sig-algs" name-list public-key-algorithms-accepted
7. クライアントは、上記のSSH_MSG_EXT_INFOメッセージに含まれる受け入れ可能な署名アルゴリズムのリストから、適切な署名形式のアルゴリズムを選択します。
「server-sig-algs」の処理の流れは以上です。
RFC8332を満たす既存のプルリクエストのコミットを、golang.org/x/cryptoをフォークしたリポジトリにgit cherry-pickした上で、上記の「server-sig-algs」の実装を追加でコミットしました。
既存のプルリクエストのコミット
https://github.com/nulab/crypto/commit/eb19239babf33e58d2e75549a4d5441d7b1ceb42
「server-sig-algs」を実装したコミット
https://github.com/nulab/crypto/commit/30812431736a61db92d7e572194d6837789ecbe3
その後、セキュリティチームと統合テストを実施して、問題なく動作していることが確認できたため、無事に本番環境へリリースできました。
ちなみに、後日、git cherry-pickしたコミットを持つ既存のPull Requestは無事マージされていました。
反省と今後の改善
本件で対応が後手になってしまったのは組織として反省すべきところです。この失敗を活かして、再発を防ぐために何ができるのか考えました。
OSSの動向を漏れなく集約して精査するフローを整備する
今や情報サービスにおいてOSSの活用は必要不可欠な時代になりました。使用しているOSSの最新の動向をもれなくキャッチして精査する仕組みが重要だと、あらためて実感しました。
ヌーラボでは、使用しているライブラリや外部サービスのアップデートをチャットBotで自動的にお知らせする仕組みを一部導入しています。
それらの既存の仕組みに倣い、セキュリティ系のツイートやミドルウェアのアップデート情報(RSSフィード等)を適切なトピックに集約して、確実に起票できるようなフローの整備を進めています。
UpdateHostKeyでホスト鍵を追加する
今後、他のアルゴリズムが非推奨になる等の不確実性に、素早く対応できる環境を整える必要がります。
今回は見送りましたが、前述のUpdateHostKeyの機能をサーバー側でサポートする準備も進んでいます。これで、ホスト鍵を入れ替える必要がでてきても、グレイスフルに移行しやすくなります。
現在使用しているのはssh-rsaのホスト鍵ですが、安全性や性能、普及具合を踏まえた上で、ecdsaやed25519のホスト鍵を追加して、段階的にセキュアな実装を目指してくつもりです。
以上、現場で実際に起きた問題と、解決までの道のりをご紹介させていただきました。
本記事を読んで、少しでもヌーラボに興味を持っていただいた方は、是非「ヌーラバーの話を聞いてみたい」からお問い合わせください。
※ このブログはヌーラバーブログリレー2021 1日目の記事です。明日は@muziyoshizさんの記事です。お楽しみに!