OpenSSH sshd の GSSAPI 認証における状態の取り違え
10月6日に出た OpenSSH 10.6 に、sshd の GSSAPI(gssapi-with-mic)認証まわりの修正が2件入っています(CVE-2026-106555、CVE-2026-106553)。どちらも原因は同じで、sshd が現在の GSS コンテキストとクライアントの認可情報を別々の変数に違う寿命で持っていたために、失敗・中断した GSSAPI 認証の状態が同じ SSH 接続の後続の試行に残ってしまう、というものです。
該当箇所は 2003-11-17 の gssapi-with-mic 追加コミットから入っており、リリースとしては OpenSSH 3.8p1 以降に存在します。GSSAPIAuthentication はデフォルトで no です。
GSS 状態の持ち方
サーバ側で関係するオブジェクトは2つあります。
Gssctxt: 現在アクティブな GSS コンテキスト。権限分離されたsshdでは、特権モニタープロセスがグローバル変数gsscontextに保持します。gssapi_client:gss-serv.cにある file-static なssh_gssapi_client構造体。クライアントの表示名、エクスポート名、メカニズム、委任クレデンシャルが入ります。ローカルアカウントの認可(ssh_gssapi_userok())やログイン後のクレデンシャル保存は、この構造体を参照します。
新しいサーバコンテキストを作るとき、Gssctxt は作り直されますが gssapi_client はそのままです:
OM_uint32
ssh_gssapi_server_ctx(Gssctxt **ctx, gss_OID oid)
{
if (*ctx)
ssh_gssapi_delete_ctx(ctx);
ssh_gssapi_build_ctx(ctx);
ssh_gssapi_set_oid(*ctx, oid);
return ssh_gssapi_acquire_cred(*ctx);
}
一方、gssapi_client が更新されるのは ssh_gssapi_accept_ctx() の中で、コンテキスト完了時に GSS_C_MUTUAL_FLAG と GSS_C_INTEG_FLAG の両方が立っている場合(または flags == NULL の場合)に限られます:
if (((flags == NULL) || ((*flags & GSS_C_MUTUAL_FLAG) &&
(*flags & GSS_C_INTEG_FLAG))) && (ctx->major == GSS_S_COMPLETE)) {
if (ssh_gssapi_getclient(ctx, &gssapi_client))
fatal("Couldn't convert client name");
}
RFC 4462 §3.1 では、gssapi-with-mic のユーザ認証に相互認証(mutual authentication)は不要であり通常は要求すべきでない、とされています。しかし OpenSSH の実装では、完全性保護あり・相互認証なしで完了したコンテキストはアクティブな Gssctxt にはなるものの、ssh_gssapi_getclient() を呼ばずに抜けます。さらに、MIC 検証の失敗時や ERRTOK 受信時、新しい SSH_MSG_USERAUTH_REQUEST が来たときにも gssapi_client をクリアする処理がありませんでした。
CVE-2026-106555: 前の試行の認可情報が残る
Alice(alice@EXAMPLE.TEST)にはローカルユーザ target へのログイン権限があり、Mallory(mallory@EXAMPLE.TEST)は対象ホストサービスの有効な Kerberos クレデンシャルは持っているが target への権限はない、とします。
- Mallory は何らかの方法で Alice の新鮮な GSS トークンを中継するなどして、
sshd上で Alice の相互認証・完全性保護付き GSS コンテキストを完了させ、その試行自体は MIC の段階で失敗させます。この時点でgssapi_clientには Alice の名前が入っています。 - 同じ SSH 接続・同じ SSH ユーザ名のまま、Mallory は自分のプリンシパルで2回目の
gssapi-with-micを始めます。このとき相互認証を要求せず完全性保護のみを要求するイニシエータを使うと、Gssctxtは Mallory のものに置き換わりますが、gssapi_clientは Alice のまま残ります。 - Mallory が自分のコンテキストで計算した正しい MIC を送ります。
input_gssapi_mic()の処理は次の通りです:
gssctxt = authctxt->methoddata;
if (!GSS_ERROR(mm_ssh_gssapi_checkmic(gssctxt, &gssbuf, &mic)))
authenticated = mm_ssh_gssapi_userok(authctxt->user);
モニター側の mm_answer_gss_checkmic() は現在の gsscontext(Mallory)に対して MIC を検証し、続く mm_answer_gss_userok() は ssh_gssapi_userok() を呼んで gssapi_client.exportedname(Alice)を krb5_kuserok() に渡します:
MIC を検証するコンテキスト: mallory@EXAMPLE.TEST
krb5_kuserok() に渡る名前: alice@EXAMPLE.TEST
Alice の MIC を偽造したり鍵を奪ったりしているわけではなく、Mallory のコンテキストで通した MIC と、前の試行から残っていた Alice の認可情報が組み合わさる形になります。
実際に成立させるための条件はかなり窮屈です。サーバ側で GSSAPIAuthentication が有効になっていること、対象サービス宛ての Alice の新鮮な GSS トークンをどうにかして手に入れること(今回の報告ではその入手手段までは扱っていません)、Mallory 自身も同じサービスのチケットを持っていること、さらに通常の OpenSSH クライアントは常に GSS_C_MUTUAL_FLAG を立てるので自前のイニシエータを用意することが必要になります。2回の試行で SSH ユーザ名とサービス名も一致していなければなりません。
CVE-2026-106553: 失敗した試行の委任クレデンシャルが残る
ssh_gssapi_getclient() は、GSS コンテキストが完了した時点で(MIC 検証や userok より前に)委任されたクレデンシャル ctx->client_creds も gssapi_client に移します。その後 MIC が失敗しても解放されません。そして後続でいずれかの認証方式が成功すると、sshd-session.c は options.gss_authentication が有効でありさえすれば ssh_gssapi_storecreds() を呼び出しており、成功した方式が GSSAPI だったかどうかや、保存しようとしているクレデンシャルが受理された試行のものかを確認していませんでした。
そのためコード上は、失敗した GSSAPI 試行で残った委任クレデンシャルが、同じ接続で別の認証方式によりログインした後のセッションに書き出され得ます。こちらについてはソースコード上で経路を追ったのみで、実際にクレデンシャルキャッシュが作られるところまでの動的検証は行っていません。
再現確認
使い捨ての MIT Kerberos レルム(TEST.REALM)を作り、OpenSSH 10.4p1 のサーバ側 GSS オブジェクトをリンクした小さな C のテストプログラムで状態のズレを確認しました。OpenSSH 側への変更は、外から中身を表示するために gssapi_client の static を外した1箇所だけです。実行結果の抜粋:
[3] Alice context (mutual+integ)
accept: major=0x0 flags=0x1b6 complete=1
[after Alice (mutual+integ)]
displayname: alice@TEST.REALM
OK: gssapi_client = alice (correct)
[4] Mallory context (integ only, NO mutual)
accept: major=0x0 flags=0x134
[after Mallory (integ only)]
displayname: alice@TEST.REALM
==> CONFIRMED: gssapi_client still = ALICE
==> Non-mutual Mallory did NOT overwrite Alice's identity
[5] MIC test: Mallory signs, server verifies with Mallory's ctx
MIC verified against Mallory's server context: OK
userok would check: alice@TEST.REALM
このプログラムは実際の OpenSSH の関数と MIT Kerberos のライブラリを呼んでいますが、稼働中の sshd に SSH パケットを送って最後まで通したものではありません。mm_ssh_gssapi_checkmic() から ssh_gssapi_userok() までの繋がりはソースコードを読んで確認しました。
10.6 での修正
最初に確認を求められたパッチは、クリーンアップ用の関数を追加して input_userauth_request() から呼ぶ形でした。ただ、現在の権限分離の構造では input_userauth_request() を実行するのは非特権の sshd-auth プロセスであるのに対し、ssh_gssapi_accept_ctx() と ssh_gssapi_userok() を実行するのは sshd-session 内の特権モニターです。sshd-auth 側で gssapi_client を消しても、そちらのプロセスにある使われていないコピーがゼロになるだけで、モニター側の gssapi_client には Alice が残ります。
その旨を返信したところ、9月16日に次の2つのコミットが入りました:
670510d:ssh_gssapi_cleanup_global_client()を追加し、input_userauth_request()とmonitor.cのmm_answer_gss_setup_ctx()の両方から呼ぶように変更。Mallory が2回目の GSS コンテキストを立ち上げる時点でモニター側のgssapi_clientが消去されるため、後のuserokではクライアント情報が空になり "No suitable client data" で失敗します。6f9cd19:ssh_gssapi_clientにuserokフラグを追加し、認可成功時に立て、失敗時にはクレデンシャルを破棄。ssh_gssapi_storecreds()もこのフラグが立っていなければ何もしなくなりました。
変わっていない点
報告の中で触れたうち、以下の2点は従来のままです:
ssh_gssapi_accept_ctx()がssh_gssapi_getclient()を呼ぶ条件には、引き続きGSS_C_MUTUAL_FLAGが残っています。相互認証はイニシエータがアクセプタを認証するかどうかのフラグであって、アクセプタ側がクライアント名を得られたかどうかとは本来別ですが、10.6 ではコンテキスト開始時にクリーンアップされるため、非相互認証のコンテキストはgssapi_clientが空のまま認証失敗になります(安全側には倒れますが、RFC 4462 で許容されている非相互認証でのログインは引き続き通りません)。monitor.cのmm_answer_gss_accept_ctx()は、コンテキストがGSS_S_COMPLETEになった時点でMONITOR_REQ_GSSCHECKMICとMONITOR_REQ_GSSUSEROKの両方を許可しており、MIC 成功後に初めてGSSUSEROKを解禁する形にはなっていません。通常の非特権プロセスは順番通りに要求を送るので、ここが問題になるのは非特権プロセスがすでに掌握されている場合だけです。
タイムライン
| 日付 | 内容 |
|---|---|
gssapi-with-mic のコミットでフラグ判定と MIC パスが入る(3.8p1 でリリース) | |
| openssh@openssh.com へレポートとローカル検証コードを添付して報告 | |
Damien Miller より修正案パッチ付きの返信。クリーンアップがモニターではなく sshd-auth 側で動いている点を返信 | |
コミット 670510d と 6f9cd19 が upstream に反映 | |
| OpenSSH 10.6 / 10.6p1 リリース、CVE-2026-106555 と CVE-2026-106553 公開 | |
| 本メモを公開 |
報告者: Keita Sode (SYZD Research)。修正および公開確認にご対応いただいた Damien Miller 氏に感謝します。