
これはCVE-2024-6387用に書いたPOCです。
Qualys Security Advisory
regreSSHion: OpenSSHサーバーにおけるRCE、glibcベースのLinuxシステム上 (CVE-2024-6387)
概要 SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3 (Debian 3.0r6, 2005年)
必要なのは信仰の跳躍だけ
-- The Interrupters, "Leap of Faith"
予備的注記: OpenSSHは世界で最も安全なソフトウェアの一つです。この脆弱性は、ほぼ完璧な実装における一度のミスです。その多層防御設計とコードは模範であり、インスピレーションを与えるものであり、OpenSSHの開発者の模範的な仕事に感謝します。
私たちはOpenSSHサーバー(sshd)において、シグナルハンドラの競合状態の脆弱性(CVE-2024-6387)を発見しました。クライアントがLoginGraceTime秒(デフォルト120秒、古いOpenSSHバージョンでは600秒)以内に認証しない場合、sshdのSIGALRMハンドラが非同期に呼び出されます。しかし、このシグナルハンドラは非同期シグナルセーフではないさまざまな関数(例: syslog())を呼び出します。この競合状態は、sshdのデフォルト設定に影響します。
調査の結果、この脆弱性は実際には2006年にMark Dowd氏によって報告されたCVE-2006-5051(「OpenSSH 4.4以前のシグナルハンドラ競合状態により、リモート攻撃者がサービス拒否(クラッシュ)を引き起こし、任意のコードを実行する可能性がある」)の回帰であることがわかりました。
この回帰は2020年10月(OpenSSH 8.5p1)に、コミット752250c(「OpenSSHのための改訂されたログ基盤」)によって導入されました。このコミットは、sshdのSIGALRMハンドラから直接呼び出される関数sigdie()から"#ifdef DO_LOG_SAFE_IN_SIGHAND"を誤って削除しました。言い換えれば:
この脆弱性はglibcベースのLinuxシステム上でリモートから悪用可能であり、syslog()自体が非同期シグナルセーフでない関数(例: malloc()やfree())を呼び出します。これは認証されていないリモートコード実行をroot権限で引き起こします。sshdの特権コードに影響し、サンドボックス化されておらず、完全な権限で実行されるためです。私たちは他のlibcやオペレーティングシステムについては調査していません。ただし、OpenBSDは明らかに影響を受けません。なぜなら、そのSIGALRMハンドラはsyslog_r()を呼び出しますが、これはOpenBSDが2001年に発明したasync-signal-safeなsyslog()のバージョンだからです。
この脆弱性をリモートから悪用するために(私たちの知る限り、CVE-2006-5051はこれまでに正常に悪用されたことはありません)、私たちは2001年にMichal Zalewski氏によって発表された先見性のある論文「Delivering Signals for Fun and Profit」からインスピレーションを得ました。
https://lcamtuf.coredump.cx/signals.txt
それでも、私たちは直ちに3つの主要な問題に直面しました。
これらの3つの問題に集中し、最新のオペレーティングシステムの保護機構(特にASLRとNX)にすぐに立ち向かわなくて済むように、まずi386上の古いOpenSSHバージョンをエクスプロイトし、その後その経験に基づいて最近のバージョンをエクスプロイトすることにしました。
最初に、"SSH-2.0-OpenSSH_3.4p1 Debian 1:3.4p1-1.woody.3"("debian-30r6-dvd-i386-binary-1_NONUS.iso"から)を取り上げました。これは、デフォルトで権限分離が有効になっている最初のDebianバージョンであり、当時の重大な脆弱性(特にCVE-2003-0693とCVE-2002-0640)に対してパッチが適用されています。
このバージョンをリモートから悪用するために、free()の呼び出しをSIGALRMで中断し(sshdの公開鍵解析コード内)、ヒープを矛盾した状態にし、SIGALRMハンドラ内の別のfree()呼び出し中にこの矛盾した状態を悪用します。
実験では、この競合状態に勝つには平均で約10,000回の試行が必要でした。つまり、600秒(LoginGraceTime)あたり10接続(MaxStartups)が受け入れられる場合、リモートルートシェルを取得するには平均で約1週間かかります。
2番目に、"SSH-2.0-OpenSSH_4.2p1 Debian-7ubuntu3"("ubuntu-6.06.1-server-i386.iso"から)を取り上げました。これは、CVE-2006-5051(「OpenSSH 4.4以前のシグナルハンドラ競合状態」)の影響を受ける最後のUbuntuバージョンです。
このバージョンをリモートから悪用するために、pam_start()の呼び出しをSIGALRMで中断し、PAMの構造の1つを矛盾した状態にし、SIGALRMハンドラ内のpam_end()呼び出し中にこの矛盾した状態を悪用します。
実験では、この競合状態に勝つには平均で約10,000回の試行が必要でした。つまり、120秒(LoginGraceTime)あたり10接続(MaxStartups)が受け入れられる場合、リモートルートシェルを取得するには平均で約1~2日かかります。
最後に、"SSH-2.0-OpenSSH_9.2p1 Debian-2+deb12u2"("debian-12.5.0-i386-DVD-1.iso"から)を取り上げました。これは現在のDebian安定版であり、CVE-2006-5051の回帰の影響を受けます。
このバージョンをリモートから悪用するために、malloc()の呼び出しをSIGALRMで中断し(sshdの公開鍵解析コード内)、ヒープを矛盾した状態にし、SIGALRMハンドラ内の別のmalloc()呼び出し中(より正確にはsyslog()内)にこの矛盾した状態を悪用します。
実験では、この競合状態に勝つには平均で約10,000回の試行が必要でした。したがって、120秒(LoginGraceTime)あたり100接続(MaxStartups)が受け入れられる場合、約3~4時間です。最終的に、リモートルートシェルを取得するには平均で約6~8時間かかります。なぜなら、glibcのアドレスを正しく推測できるのは半分の時間だけだからです(ASLRのため)。
この研究はまだ進行中です。
amd64での作業を開始してから数日後、sshdのSIGALRMハンドラにおけるデッドロックに関する以下のバグレポート(OpenSSHの公開Bugzilla内)に気付きました。
https://bugzilla.mindrot.org/show_bug.cgi?id=3690
したがって、私たちはすぐにOpenSSHの開発者に連絡し(このデッドロックが悪用可能な脆弱性によって引き起こされていることを知らせるため)、amd64の作業を保留し、このアドバイザリの作成を開始しました。
しかし、それは私らしくない、私は解放されている
-- The Interrupters, "Haven't Seen the Last of Me"
このOpenSSHバージョンのSIGALRMハンドラはpacket_close()を呼び出し、それはbuffer_free()を呼び出し、さらにxfree()、そしてfree()を呼び出します。free()はasync-signal-safeではありません。
そこで、このDebianのglibc(2.2.5)のmallocコードを調べ始めました。free()への最初の呼び出しがSIGALRMによって中断され、SIGALRMハンドラ内の2回目のfree()呼び出し(上記の341-344行)中に悪用される可能性があるかどうかを確認するためです。このglibcのmallocは、2000年にSolar Designer氏が先駆的に開発したunlink()テクニックに対して強化されていないため、chunk_free()(free()から内部的に呼び出される)の中にすぐに興味深いコードパスを見つけました。
このコードパスを悪用するために、sshdのヒープを以下のレイアウトに配置します(chunk_X、chunk_Y、chunk_Zはmalloc()されたメモリチャンクであり、p、s、f、bはそれぞれprev_size、size、fd、bkフィールドです)。
-----|---+---------------|---+---------------|---+---------------|----- ... |p|s|f|b| chunk_X |p|s|f|b| chunk_Y |p|s|f|b| chunk_Z | ... -----|---+---------------|---+---------------|---+---------------|----- |<------------->| ユーザーデータ
まず、free(chunk_Y)の呼び出しが3246行以降、3251行以前にSIGALRMによって中断された場合、chunk_Yはすでに解放済みとしてマークされています(3246行でchunk_ZのPREV_INUSEビットがクリアされるため)が、まだ二重リンクリストにリンクされていません(3251行)。つまり、chunk_Yのfdおよびbkポインタには依然としてユーザーデータ(攻撃者制御のデータ)が含まれています。
次に、(SIGALRMハンドラ内で)packet_close()がfree(chunk_X)を呼び出すと、(chunk_Yが解放済みとしてマークされているため)3230-3244行のコードブロックが実行され、chunk_Yがunlink()されます(3241行)。いわゆるaa4bmoプリミティブ(ほぼ任意の4バイトミラーリング上書き)です。なぜなら、chunk_Yのfdおよびbkポインタが依然として攻撃者制御のままだからです。unlink()テクニックとaa4bmoプリミティブの詳細については、以下を参照してください。
https://www.openwall.com/articles/JPEG-COM-Marker-Vulnerability#exploit http://phrack.org/issues/61/6.html#article
最後に、このaa4bmoプリミティブを使用して、glibcの__free_hook関数ポインタ(この古いDebianバージョンにはASLRもNXもありません)をヒープ内のシェルコードのアドレスで上書きし、packet_close()内の次のfree()呼び出し時にリモートコード実行を達成します。
今や彼らは支配し、完全にコントロールしている
-- The Interrupters, "Liberty"
sshdに対してこの攻撃を仕掛けるために、sshdのDSA公開鍵解析コード内のfree()呼び出し(つまり、以下の144行目が私たちのfree(chunk_Y)です)を中断し、packet_close()内のfree()呼び出しの1つ(つまり、上記の341-344行のいずれかが私たちのfree(chunk_X)です)で悪用します。
しかし、最初はこの競合状態に勝つことができませんでした(つまり、144行目のfree()呼び出しを適切なタイミングで中断できませんでした)。最終的に、競合状態に勝つ確率を大幅に向上させられることに気付きました。DSA公開鍵の解析コードでは、free()を4回呼び出すことができ(以下の704-707行)、さらにsshdでは6回のユーザー認証試行(AUTH_FAIL_MAX)が許可されています。これらの24個のfree()呼び出しのいずれかが適切なタイミングで中断されれば、後でSIGALRMハンドラ内でリモートコード実行を達成できます。
この改善により、約1か月後にようやく競合状態に勝ちました。嬉しくて(ルートシェルダンスをしました)、しかしまだ改善の余地があると感じました。
心配しないで、待っていればいい
-- The Interrupters, "Haven't Seen the Last of Me"
そこで、以下の3つのタイミング戦略を実装しました。
最後の瞬間まで(かなり大きい)DSA公開鍵パケットをsshdに送信するのを待たない。代わりに、LoginGraceTimeのずっと前に、パケット全体から1バイト(最後のバイト)を引いたものを送信し、最後の瞬間に最後のバイトを送信することで、ネットワーク遅延の影響を最小限に抑えます(さらに、Nagleアルゴリズムを無効にします)。
中央値の往復時間を追跡し(sshdから応答が返ってくるパケットを定期的に送信することにより)、接続がsshdによって閉じられると予想される瞬間(基本的にsshdのバナー最初のバイトを受信した時刻にLoginGraceTimeを加えたもの)と、実際にsshdによって接続が閉じられた瞬間との差を追跡し、それに応じてタイミング(つまり、DSAパケットの最後のバイトを送信する瞬間)を調整します。
これらの時間差により、クロックスキューとネットワーク遅延を追跡でき、これらは時間とともに予測可能なパターンを示します。線形回帰とスプライン回帰を試しましたが、結局、最新の測定値を単に再利用すること以上にうまくいくものはありませんでした。おそらく、深層学習によってさらに良い結果が得られるかもしれません。これは興味のある読者のための課題として残します。
さらに重要なことに、sshdからの非自発的なフィードバックを通じてタイミングをゆっくりと調整することにより、競合状態に勝つ確率をさらに高めます。
このフィードバックにより、私たちが「大きな」競合ウィンドウと呼ぶものを狙うことができます。これに当たっても競合状態に勝つことは保証されませんが、この大きなウィンドウの中には24個の「小さな」競合ウィンドウ(24個のfree()呼び出し内部)が含まれており、これらに当たれば競合状態に勝つことが保証されます。
これらの改善により、この競合状態に勝つには平均で約10,000回の試行が必要です。つまり、600秒(LoginGraceTime)あたり10接続(MaxStartups)が受け入れられる場合、リモートルートシェルを取得するには平均で約1週間かかります。
太陽が昇り始めると私は眠る
-- The Interrupters, "Alien"
このOpenSSHバージョンのSIGALRMハンドラは、もはやpacket_close()を呼び出しません。さらに、このUbuntuのglibc(2.3.6)は、malloc系関数に入るときに常に必須のロックを取得します(sshdのようにシングルスレッドであっても)。これにより、malloc関数への呼び出しを中断し、後でこれらの関数への別の呼び出し中に悪用することができなくなります(常にデッドロックします)。別の解決策を見つけなければなりません。
CVE-2006-5051はGSSAPIにおけるダブルフリーに言及していますが、GSSAPI(またはKerberos)はデフォルトで有効になっていないため、あまり魅力的ではありません。一方、PAMはデフォルトで有効であり、pam_end()はsshdのSIGALRMハンドラから呼び出されます(そしてもちろん、async-signal-safeではありません)。そこで、適切なタイミングでSIGALRMによって中断された場合にPAMの内部構造を矛盾した状態にし、SIGALRMハンドラ内のpam_end()中に悪用できるPAM関数を探しました。pam_set_data()を見つけました。
33 int pam_set_data( 34 pam_handle_t *pamh, .. 37 void (*cleanup)(pam_handle_t *pamh, void *data, int error_status)) 38 { 39 struct pam_data *data_entry; .. 57 } else if ((data_entry = malloc(sizeof(*data_entry)))) { .. 65 data_entry->next = pamh->data; 66 pamh->data = data_entry; .. 74 data_entry->cleanup = cleanup; -----------------------------------------------------------------------If this function is interrupted by SIGALRM after line 66 but before line 74, then data_entry is already linked into PAM's structures (pamh), but its cleanup field (a function pointer) is not yet initialized (since the malloc() at line 57 does not initialize its memory). If we are able to control cleanup (through leftovers from previous heap allocations), then we can execute arbitrary code when pam_end() (inside the SIGALRM handler) calls _pam_free_data() (at line 118):
これは非常に単純なエクスプロイトだったでしょうが、残念ながら、私たちはpam_set_data()がPAMモジュールからのみ呼び出せることを完全に見落としていました。もしSIGALRMで割り込むと、pamh->caller_isは依然として_PAM_CALLED_FROM_MODULEのままであり、その場合pam_end()はただちに戻り、_pam_free_data()を呼び出しません。振り出しに戻りました。
諦めるなんて、私たちのすることじゃない
-- The Interrupters, "Title Holder"
下記601行目で、sshdがグローバルなsshpam_handleポインタへのポインタを直接pam_start()に渡していることに気づきました(pam_start()は接続ごとに1回呼ばれます)。
そこで、pam_start()自体を調べることにしました。SIGALRMで割り込まれると、sshpam_handleが指す構造体が不完全な状態のままになり、その後SIGALRMハンドラ内で"pam_end(sshpam_handle, sshpam_err)"が呼ばれた際に、その状態を悪用できる可能性があります。
32行目で、pam_start()は直ちにsshdのsshpam_handleをcalloc()で確保したメモリチャンクに設定します。これは安全です。calloc()はメモリをゼロ初期化するからです。一方、_pam_add_handler()(pam_start()によって複数回呼ばれる)が、874行目後、886行目前でSIGALRMに割り込まれた場合、malloc()された構造体がpamhにリンクされますが、そのnextフィールドはまだ初期化されていません。もしnextを(以前のヒープアロケーションの残骸を通じて)制御できるなら、pam_end()の呼び出し中(SIGALRMハンドラ内)、下記1020行目(および1017行目)でfree()に任意のポインタを渡すことができます。
このUbuntuのglibcのmallocは、古いunlink()テクニックに対してすでに強化されているため、任意のfree()をMalloc MaleficarumのHouse of Mind(fastbin版)に変換することにしました。つまり、自分自身のNON_MAIN_ARENAチャンクをfree()し、偽のアリーナをsshdの.got.plt(このUbuntuのsshdはASLRは有効だがPIEは無効)にポイントさせ、_exit()のエントリをヒープ内のシェルコードのアドレスで上書きします(このUbuntuのヒープはデフォルトでまだ実行可能です)。Malloc Maleficarumの詳細については以下を参照:
https://seclists.org/bugtraq/2005/Oct/118
すべてを困難な方法で学んだ
-- The Interrupters, "The Hard Way"
sshdに対してこの攻撃を仕掛けるにあたり、当初は3つの問題に直面しました。
House of Mindでは、偽のアリーナへのポインタをヒープのアドレス0x08100000に格納する必要があります。しかし、そのような高位アドレスに攻撃者が制御するデータを格納できるでしょうか? sshdはユーザー認証のごく初期にpam_start()を呼び出すため、ユーザー名以外は何も制御できません。幸い、長さ約128KB(DEFAULT_MMAP_THRESHOLDより短い)のユーザー名を使用すれば、アドレス0x08100000に独自のデータを格納できます。
偽のNON_MAIN_ARENAチャンクのサイズフィールドは大きすぎてはいけません(free()のセキュリティチェックを通過するため)。つまり、NULLバイトを含む必要があります。しかし、長いユーザー名はNULL終端文字列であり、NULLバイトを含めることはできません。幸い、_pam_free_handlers_aux()がfree()する構造体をゼロクリアする(上記1019行目)ことを思い出しました。そこで、まず偽のチャンクのサイズフィールドをそのようなmemset(0)で「パッチ」し、その後でfree()します。
偽のNON_MAIN_ARENAチャンクのfree()の前に、何度かのfree()呼び出し(上記1017行目と1020行目)を生き延びる必要があります。これらのfree()を、偽のIS_MMAPPEDチャンクを指すようにすることでno-opに変換します。free()はmunmap_chunk()を呼び出し、それがmunmap()を呼び出しますが、これらの偽のIS_MMAPPEDチャンクはアライメントがずれているため失敗します。これにより事実上no-opとなります。このUbuntuのglibcではassert()違反は強制されないためです。
最後に、長いユーザー名を使用することで、20個の異なる構造体の初期化されていない可能性のあるnextフィールドを制御することもできます(長いユーザー名の一時的なコピーの残骸を通じて)。pam_start()は_pam_add_handler()を複数回呼び出すため、大きな競合ウィンドウの中に20個の小さな競合ウィンドウが存在することになります。
彼らが以前使ったのと同じトリック
-- The Interrupters, "Divide Us"
Ubuntu 6.06.1に対するこの攻撃では、Debian 3.0r6に対して使用したタイミング戦略を単純に再利用しました。競合状態に勝つには平均して約10,000回の試行が必要であり、120秒(LoginGraceTime)あたり10接続(MaxStartups)が許可されるため、リモートルートシェルを取得するには平均して約1〜2日かかります。
注意:このUbuntuのglibcは、mallocファミリーの関数に入るときに常に必須のロックを取得するため、不運な攻撃者はルートシェルを取得する前に、10のMaxStartups接続すべてでデッドロックする可能性があります。私たちはこの問題を回避する試みは行っていません。なぜなら、最終目標はとにかく現代的なOpenSSHバージョンを攻撃することだったからです。
さあ準備はできた、悪魔に立ち向かえ
-- The Interrupters, "Be Gone"
このOpenSSHバージョンのSIGALRMハンドラは、packet_close()もpam_end()も呼び出しません。実際には、syslog()という一つの興味深い関数だけを呼び出します。
そこで、2つの重要な疑問が生じます。このDebianのglibc(2.36)のsyslog()は、malloc()やfree()のような非同期シグナル安全でない関数を呼び出すのでしょうか? そして、もしそうなら、このglibcはmallocファミリーの関数に入るときにまだ必須のロックを取得するのでしょうか?
注:これらのmalloc()について(その順序もサイズも内容も)何も制御できないため、166行目の"rce"を非常に必要な良い兆しとして受け取りました。
そして、2番目の疑問に対する答えは「いいえ」です。2017年10月以降、glibcのmalloc関数は、シングルスレッドの場合(sshdのように)ロックを取得しなくなりました。
https://sourceware.org/git?p=glibc.git;a=commit;h=a15d53e2de4c7d83bda251469d92a3c7b49a90db https://sourceware.org/git?p=glibc.git;a=commit;h=3f6bb8a32e5f5efd78ac08c41e623651cc242a89 https://sourceware.org/git?p=glibc.git;a=commit;h=905a7725e9157ea522d8ab97b4c8b96aeb23df54
さらに、このDebianバージョンは、以下の素晴らしいブログ記事(それぞれJustin Miller氏とMathias Krause氏による)で説明されているASLRの弱点を抱えています。
https://zolutal.github.io/aslrnt/ https://grsecurity.net/toolchain_necromancy_past_mistakes_haunting_aslr
具体的には、i386上のsshdの場合、すべてのメモリマッピングは通常通りランダム化されます(sshdのPIE、ヒープ、ほとんどのライブラリ、スタック)が、glibc自体は常にアドレス0xb7200000または0xb7400000のいずれかにマッピングされます。言い換えれば、半分の確率でglibcのアドレスを正しく推測できます(ASLRを打ち破るための小さな代償です)。私たちのエクスプロイトでは、glibcが0xb7400000にマッピングされていると仮定します。これは0xb7200000よりわずかに一般的だからです。
次の疑問は、glibcのmalloc関数内のどのコードパスが、適切なタイミングでSIGALRMに割り込まれると、ヒープを不完全な状態にし、SIGALRMハンドラ内のmalloc()呼び出しの一つで悪用可能になるか、ということです。
いくつかの興味深い(そして驚くべき!)コードパスを発見しましたが、私たちが選んだものは絶対アドレスではなく相対的なサイズのみを扱うものでした(例えば、unlink_chunk()内の様々なコードパスとは異なります)。この違いは、将来のamd64エクスプロイトにとって決定的に重要になるかもしれません。malloc()内のこのコードパスは、大きな空きチャンク(victim)を2つの小さなチャンクに分割します。最初のチャンクはmalloc()の呼び出し元に返され(4345行目)、2番目のチャンク(remainder)は空きチャンクの未整理リストにリンクされます(4324-4327行目)。
このコードパスが4327行目後、4339行目前でSIGALRMに割り込まれた場合、この分割のremainderチャンクはすでに空きチャンクの未整理リストにリンクされていますが(4324-4327行目)、そのサイズフィールド(mchunk_size)はまだ初期化されていません(4339行目)。
もし以前のヒープアロケーションの残骸を通じてそのサイズフィールドを制御できるなら、このremainderチャンクを本来より大きくし、他のヒープチャンクと重なるようにすることができます。そして、この拡大された重なり合うremainderチャンクが(SIGALRMハンドラ内で)最終的にmalloc()されて書き込まれるときに、ヒープメモリを破壊できる可能性があります。
最後の疑問は、SIGALRMハンドラ内のmalloc()呼び出しについて何も制御できない場合、sshdが(sshsigdie()内で)_exit()を呼び出す前に、任意のコード実行を達成するためにヒープ内で何を上書きできるか、ということです。
__tzfile_read()(SIGALRMハンドラ内)は、ヒープ内にFILE構造体をmalloc()するため(上記166行目)、FILE構造体は任意のコード実行のために長年にわたって悪用されてきた歴史があるため、このFILE構造体を標的にヒープ破壊を仕掛けることにしました。しかし、これは言うは易く行うは難しです。私たちのヒープ破壊は非常に限られており、FILE構造体は長年にわたって大幅に強化されてきたからです(例えば、IO_validate_vtable()やPTR_DEMANGLE()など)。
最終的に、以下の手法を考案しました(これはi386のglibcに特有のようです。amd64のglibcは_vtable_offsetをまったく使っていないように見えます)。
要約すると、fopen()がmalloc()したFILE構造体の1バイト(_vtable_offset)を上書きするだけで、__fread_unlocked()の中で独自の__fct関数ポインタを呼び出し、任意のコードを実行できます。
完璧にしたかった、しわひとつなく
-- The Interrupters, "In the Mirror"
sshdの特権子プロセスに対してこの攻撃を仕掛けるにあたり、まず以下のようなヒープレイアウトを想像してみましょう(「XXX」はヒープに穴を開けるための「バリア」チャンクです。例えば、小さなメモリリークチャンクなど)。
---|----------------------------------------------|---|------------|---
| XXX | large hole | XXX | small hole | XXX |
|---|---|---|---|---|
| ~8KB | 320B |
---|-----------------------|----------------------|---|------------|---
| XXX | large allocated chunk | free remainder chunk | XXX | small hole | XXX |
|---|---|---|---|---|---|
| ~4KB | ~4KB | 320B |
しかし、このmalloc()が4327行目後、4339行目前でSIGALRMに割り込まれた場合、この分割のremainderチャンクはすでに空きチャンクの未整理リストにリンクされていますが、そのサイズフィールドは(以前のヒープアロケーションの残骸を通じて)私たちの制御下にあり、この人為的に拡大されたremainderチャンクは後続の小さな穴と重なります。---|-----------------------|----------------------|---|------------|--- XXX| large allocated chunk | real remainder chunk |XXX| small hole |XXX ---|-----------------------|----------------------|---|------------|--- | ~4KB |<------------------------------------->| artificially enlarged remainder chunk
SIGALRMハンドラがsyslog()を呼び出し、次に__tzfile_read()を呼び出すと、 fopen()がFILE構造体のために小さな穴(small hole)をmalloc()し、 __fread_unlocked()が4KBの読み取りバッファをmalloc()し、 その結果、拡大された残りのチャンク(remainder chunk)が2つに分割される (4KB読み取りバッファと小さな残りのチャンク)。
---|-----------------------|----------------------|---|------------|--- XXX| large allocated chunk | |XXX| FILE |XXX ---|-----------------------|----------------------|---|--|---------|--- | ~4KB |<--------------------------->|<------->| 4KB read buffer remainder
そのため、この小さな残りのチャンクの内部ヘッダでFILE構造体の一部を上書きする。 より正確には、このヘッダのbkフィールド(空きチャンクの未ソートリストへのポインタ、 0xb761d7f8)の3バイト目でFILEの_vtable_offsetを上書きする (つまり、_vtable_offsetを0x61で上書きする)。
次に、「理論」のサブセクションで説明したように、__fread_unlocked()は _IO_file_underflow()の代わりに_IO_wfile_underflow()を呼び出す。 そして、_IO_wfile_underflow()は(独自の_codecvtポインタを介して) 独自の__fct関数ポインタを呼び出し、任意のコードを実行する。
注意:制御された_codecvtポインタから制御された__fct関数ポインタへ 確実に遷移する方法はまだ説明していない。これは後で説明するが、 その前に、より差し迫った問題を解決しなければならない。
実際、古いOpenSSHバージョンでの作業から、大きなレースウィンドウに 小さなレースウィンドウが1つしか含まれていない場合、このシグナルハンドラの 競合状態に勝つことは決してできないことを学んだ。そこで、以下のヒープレイアウトに 基づいて、次の戦略を実装した。
---|------------|---|------------|---|------------|---|------------|---
| XXX | large hole 1 | XXX | small hole 1 | XXX | large hole 2 | XXX | small hole 2 | ... |
|---|---|---|---|---|---|---|---|---|
SIGALRMが送信される直前にsshdに送信する最後のパケットは、sshdに 次の一連のmalloc()呼び出しを強制する: malloc(~4KB), malloc(304), malloc(~4KB), malloc(304), など。
1/ 最初のmalloc(~4KB)がlarge hole 1を2つに分割する。
この最初の分割が適切なタイミングでSIGALRMによって中断された場合、 SIGALRMハンドラ内のfopen()がFILE構造体のためにsmall hole 1をmalloc()し、 上記のように任意のコード実行が達成される。
そうでない場合、最初のmalloc(304)でsmall hole 1を自分でmalloc()し、次のステップへ進む。
2/ 2番目のmalloc(~4KB)がlarge hole 2を2つに分割する。
この2番目の分割が適切なタイミングでSIGALRMによって中断された場合、 SIGALRMハンドラ内のfopen()がFILE構造体のためにsmall hole 2をmalloc()し、 上記のように任意のコード実行が達成される。
そうでない場合、2番目のmalloc(304)でsmall hole 2を自分でmalloc()し、以下同様。
sshdのヒープ内にこのような大小の穴のペアを27組作ることができた (28組だとPACKET_MAX_SIZEの256KBを超えてしまう)。 これにより、大きなレースウィンドウ内に27個の小さなレースウィンドウが含まれることになる! この複雑なヒープレイアウトを実現するのは非常に困難で時間がかかったが、 ハイライトは以下の2点である。
このヒープレイアウトを確実に実現するために、sshdに5つの異なる公開鍵パケットを送信する (パケットa/からd/はSIGALRMのかなり前に送信可能。パケットe/の大部分も SIGALRMのかなり前に送信可能だが、その最後の1バイトは最後の瞬間に送信しなければならない)。
a/ さまざまなtcacheチャンクをmalloc()してfree()し、制御していないヒープ割り当てが これらのtcacheチャンクに割り当てられ、慎重に計画したヒープレイアウトに干渉しないようにする。
b/ さまざまなサイズのチャンクをmalloc()してfree()し、27組の大小の穴と、 それに対応する「バリア」チャンクを作成する。
c/ ~4KBのチャンクと320Bのチャンクをmalloc()してfree()し、次のことを行う。
d/ 非常に大きな文字列(約256KB)をmalloc()してfree()し、大小の穴が 未ソートの空きチャンクリストから削除され、それぞれのmallocビンに 配置されるようにする。
e/ sshdに最後の一連のmalloc()呼び出し(malloc(~4KB), malloc(304), malloc(~4KB), malloc(304), ...)を強制し、27個の小さなレースウィンドウを 開く。
注意深い読者は、_codecvtの問題にまだ対処していないことに気づいたかもしれない (文字通りにも比喩的にも)。実際、_codecvtは構造体(_IO_codecvt)へのポインタであり、 その構造体には構造体(__gconv_step)へのポインタが含まれており、 その中には任意のコードを実行できる__fct関数ポインタが含まれている。 _codecvtを介して__fctを確実に制御するために、_codecvtをglibcの mallocビンの1つにポイントする。mallocビンにはヒープ内の空きチャンクの1つへの ポインタが便利に含まれており、そのチャンクには任意のglibcコードへの 独自の__fct関数ポインタが含まれている (glibcがアドレス0xb7400000にマッピングされていると仮定しているため、 これらのglibcアドレスはすべて既知である)。
時間が足りなくなってきた
-- The Interrupters, "As We Live"
3番目のエクスプロイトを実装するにつれて、以前の2つのOpenSSHバージョンに対して 使用したタイミング戦略を単純に再利用できないことが明らかになった。 新しい競合状態で勝利することは決してできなかった。 最終的に、その理由がわかった。
sshdが5番目で最後の公開鍵(上記のパケットe/)を解析するのに時間がかかる (約10ms)。言い換えれば、大きなレースウィンドウが大きすぎる (27個の小さなレースウィンドウは干し草の山の中の針のようなもの)。
最近(OpenSSH 7.8p1)導入されたuser_specific_delay()は、 最後の公開鍵パケットに対するsshdの応答を最大約9ms遅延させるため、 フィードバックベースのタイミング戦略を無効にする。
その結果、まったく異なるタイミング戦略を開発した。
時々、最後の公開鍵パケットに小さな誤りを加えて送信し、 sshkey_from_blob() が公開鍵を解析する直前(下記138-142行目)で エラー応答を発生させる。
時々、最後の公開鍵パケットに別の小さな誤りを加えて送信し、 sshkey_from_blob() が公開鍵を解析した直後(下記151-155行目)で エラー応答を発生させる。
これら2つの応答時間の差が、sshdが最後の公開鍵を解析するのにかかる時間であり、 これにより最後のパケットの送信タイミングを正確に計ることができる (sshdが非特権子プロセスで公開鍵を解析し、特権子プロセスに送信し、 そこで解析を開始するのに十分な時間を確保し、その後にSIGALRMが届くようにする)。
この戦略の変更により、競合状態に勝つには平均で約10,000回の試行が必要となる。 つまり、120秒(LoginGraceTime)あたり100接続(MaxStartups)が受け入れられる場合、 競合状態に勝つには平均で約3~4時間、リモートルートシェルを取得するには 約6~8時間かかる(ASLRのため)。
明日の計画は何だ?
-- The Interrupters, "Take Back the Power"
Rocky Linux 9(Red Hat Enterprise Linux 9派生)の"Rocky-9.4-x86_64-minimal.iso"を ターゲットにすることにした。その理由は2つある。
そのOpenSSHバージョン(8.7p1)はこのシグナルハンドラの競合状態に対して脆弱であり、 かつglibcは常に2MBの倍数にマッピングされる (前述の「理論」サブセクションで議論したASLRの弱点のため)。 これにより、部分的なポインタ上書きがより強力になる。
このglibcバージョン(2.34)のsyslog()関数 (async-signal-unsafeであるが、sshdのSIGALRMハンドラによって呼び出される)は、 内部的に __open_memstream() を呼び出し、ヒープ内にFILE構造体をmalloc()する。 また、calloc()、realloc()、free()も呼び出す (これにより、切望していた自由度が得られる)。
ヒープ破損をプリミティブとし、ヒープ内にmalloc()された2つのFILE構造体、 およびglibcのアドレスの21ビットが固定されていることから、 このシグナルハンドラの競合状態はamd64で悪用可能であると信じている (おそらく6~8時間ではなく、1週間以内には成功するだろう)。 時が経てばわかるだろう。
補足:Ubuntu 24.04ではsshdの子プロセスに対するASLRの再ランダム化が行われない (ブート時に1度だけランダム化される)ことを発見した。これは以下のパッチによるもので、 sshdのrexec_flagをオフにしている。これは一般には悪いアイデアだが、 このシグナルハンドラの競合状態の特定のケースでは、 sshdが悪用されるのを防ぐ効果がある。 SIGALRMハンドラ内のsyslog()は、それが最初のsyslog()呼び出しではないため、 malloc関数を一切呼び出さない。
https://git.launchpad.net/ubuntu/+source/openssh/tree/debian/patches/systemd-socket-activation.patch
嵐は去り、過ぎ去った
-- The Interrupters, "Good Things"
2024年6月6日、このシグナルハンドラの競合状態は、 コミット81c1099("Add a facility to sshd(8) to penalise particular problematic client behaviours") によって修正された。このコミットでは、async-signal-unsafeなコードを sshdのSIGALRMハンドラからsshdのリスナープロセスに移動し、 同期的に処理できるようにした。
https://github.com/openssh/openssh-portable/commit/81c1099d22b81ebfd20a334ce986c4f753b0db29
この修正は大きなコミット(81c1099)の一部であり、 さらに大きな多層防御コミット(03e3de4 "Start the process of splitting sshd into separate binaries") の上に成り立っているため、バックポートが困難な可能性がある。 その場合、シグナルハンドラの競合状態自体は、 sshsigdie()関数からasync-signal-unsafeなコードを削除またはコメントアウトすることで 修正できる。例えば以下のように。
sshsigdie(const char *file, const char *func, int line, int showfunc, LogLevel level, const char *suffix, const char *fmt, ...) { #if 0 va_list args;
va_start(args, fmt);
sshlogv(file, func, line, showfunc, SYSLOG_LEVEL_FATAL,
suffix, fmt, args);
va_end(args);
最後に、sshdをアップデートまたは再コンパイルできない場合、 設定ファイルでLoginGraceTimeを0に設定するだけで、 このシグナルハンドラの競合状態を修正できる。 これにより、sshdはサービス拒否攻撃(すべてのMaxStartups接続の枯渇)に対して 脆弱になるが、本アドバイザリで提示されたリモートコード実行からは安全になる。
OpenSSHの開発者の皆様の卓越したご尽力と、本リリースにおける緊密な連携に感謝します。 また、distros@openwallにも感謝します。 最後に、本アドバイザリをSophia d'Antoineに捧げます。
2024-05-19: OpenSSHの開発者に連絡。パッチとパッチレビューの反復が続く。
2024-06-20: distros@openwallに連絡。
2024-07-01: 協調公開日。
| ~8KB |
| 320B |
| ~8KB |
| 320B |