
IPv6経由でのカーネルのリモートエクスプロイト
IPv6経由でカーネルをリモートから悪用する
CVE-2024-38063 - IPv6経由でカーネルをリモートから悪用する マーカス・ハッチンス (Marcus Hutchins)
8月13日に最新のWindowsパッチがリリースされて以来、私はtcpip.sys(TCP/IPパケットを処理するカーネルドライバ)の深い部分を調査してきました。CVSSスコア9.8の脆弱性がWindowsカーネルの最も到達しやすい部分にあるというのは、まったく見逃せないものでした。これまでIPv6(またはそれを解析するドライバ)をほとんど見たことがなかったため、この脆弱性をリバースエンジニアリングするのは非常に困難になるだろうと分かっていましたが、良い学習経験になるはずです。
大部分において、tcpip.sysはほとんど文書化されていません。以前のバグに関するエクスプロイトの解説記事をいくつか見つけました(ここ、ここ、ここ)が、それ以外はほとんどありません。私の英語のGoogle検索で一番上の結果が中国語で書かれているのを見たとき、すぐに自分はまったく手に負えない領域に踏み込み、ひどい目に遭うだろうと悟りましたが、学ばねばなりません。Google翻訳は平凡な出来でしたが、その投稿はIPv6フラグメンテーションの仕組みについて非常に詳細な洞察を提供してくれ、良いスタートを切ることができました。
その後、いくつかの関数名をGoogle検索しているときに、Axel Souchet(別名0vercl0k)による同じ2021年の脆弱性の別の分析に出くわしました。それはtcpip.sysの内部構造をさらに深く掘り下げており、いくつかの未文書化構造体を定義するのに十分な情報を与えてくれました。 最も簡単なパッチ解析
通常、パッチをリバースエンジニアリングして、どのコード変更が脆弱性に対応するのかを突き止めるだけでも、何日も何週間もかかることがあります。しかし、このケースでは瞬時でした。実際、あまりにも簡単だったため、ソーシャルメディア上の複数の人々が私に「間違っている、バグは別の場所にある」と言いました。私は彼らの言うことを聞いて、丸一日を間違ったドライバのリバースエンジニアリングに無駄にしたのでしょうか?それは永遠に謎のままかもしれません。
ドライバファイル全体で変更が行われたのは正確に1か所だけであり、結局のところそれが実際にバグでした。
パッチインストール前後のtcpip.sysのbindiff概要。
ドライバ全体で変更された関数はたった1つだけです。通常、私は20以上の異なる関数の変更を丸一日かけて調べ、どれが注目すべきものかを特定しますが、今回はそうではありませんでした。
パッチ適用前のIpv6pProcessOptions()。
パッチ適用後のIpv6pProcessOptions()。
変更されたのは1つの関数だけでなく、たった1行のコードでした。
非常に長い名前の Feature_2660322619__private_IsEnabledDeviceUsage_3() 関数は、Microsoftが部分的なパッチロールバックを有効にするために時々追加するものです。この呼び出しはグローバルフラグまたはレジストリ設定の存在を確認し、設定されている場合、関数はfalseを返し、パッチ版ではなく元のコードが実行されます。
Microsoftがこれを行う理由は、セキュリティパッチが意図せず何かを壊すことがあるためです。この設定により、管理者が毎月のパッチロールアップ全体をアンインストールしてシステムセキュリティを大幅に弱めることなく、単一の脆弱性のパッチを解除できます。
これを考慮すると、このパッチが行っているのは IppSendErrorList() の呼び出しを IppSendError() に置き換えるだけであり、問題が何らかのリストに関連していることが示唆されます。史上最も簡単なパッチ差分(少なくとも私はそう思っていました)。
脆弱性は任意、エクスプロイトは必須
パッチをリバースエンジニアリングして変更されたコードを見つけるのは、課題の半分に過ぎません(この場合は0.1%未満)。残りのプロセスは、コードベースの十分な部分をリバースエンジニアリングして何が起きているのかを理解し、どのような種類の脆弱性がパッチされたのか、ターゲットコードに到達するためのリクエストをどのように構築するか、そしてどの状態が悪用可能な条件につながるかを把握することで構成されます。
最初の部分は非常に簡単です。変更は Ipv6pProcessOptions() にあり、これはIPv6であり、オプションの処理が関与していることを示しています。そこで、RFCをすぐに調べると、IPv6オプションとは何か、そしてそれがどこにあるのかが正確にわかります。
Wikipediaのデスティネーションオプションヘッダのレイアウト。
なるほど。私たちが探しているのは、メインのIPv6ヘッダの直後に位置するデスティネーションオプションヘッダのようです。Pythonライブラリの 'scapy' を使ってテスト用のIPv6パケットを作成しましょう。
注:なりすましIPアドレスを使用したDDoS攻撃を軽減するため、Windowsは生のIPパケットを構築する機能を制限しています。そのため、私はLinuxを使用して概念実証を開発することにしました。Linuxはユーザーが生のレイヤ2およびレイヤ3パケットを構築して送信することを許可していますが、Pythonスクリプトをrootとして実行する必要があります。
import sys import struct from scapy.all import *
def send_ipv6_option_packet(dest_ip): ethernet_header = Ether() ip_header = IPv6(dst=dest_ip) options_header = IPv6ExtHdrDestOpt() sendp(ethernet_header / ip_header / options_header)
if len(sys.argv) < 2: print('Use: python3 script.py <target_ipv6_address>') exit(-1)
send_ipv6_option_packet(sys.argv[1])
tcpip!Ipv6pProcessOptions にブレークポイントを設定し、スクリプトを実行した後、脆弱な関数に到達するために必要なのは、空のオプション構造体を持つIPv6パケットを送信することだけであることが明らかになりました。次に、構造体にいくつかの無効なオプションを追加して、IppSendErrorList() の呼び出しに到達できるかどうかを試しました。
簡単なコードレビューにより、ほぼすべての無効なオプション書式設定が IppSendErrorList の呼び出しをトリガーできることが示されました。そこで、無効な長さ(65535バイト未満)のジャンボパケットオプションを使用することにしました。
options_header = IPv6ExtHdrDestOpt(options=[Jumbo(jumboplen=0x1337)])
では、IppSendErrorList() は実際に何をするのでしょうか?コードは非常にシンプルです。
IppSendErrorList関数全体。
コードはリンクリストを反復処理し、リスト内の各アイテムに対して IppSendError() を呼び出します。ここでも星が一致し、これまでは簡単に進んでいます。もし IppSendErrorList が単にリスト内の各アイテムに対して IppSendError を呼び出すだけで、パッチが IppSendErrorList の呼び出しを IppSendError に置き換えるなら、問題は IppSendError が最初以外のリストアイテムに対して呼び出されたときに発生します。
では、これは何のリストであり、どうやって作成するのでしょうか? 彼はリストを作っている、彼はそれをチェックしている…52,567回
ここから状況が明白から異常に困難へと変わりました。ただし、この大部分は、私の利用可能な脳細胞のうちの1つが深刻な新型コロナウイルス感染症と戦うのに忙しかったためだと思います。コードの一部を理解しては眠りに落ち、何を解明したのか忘れてしまうという日々を数日過ごしました。プロセス全体には、何が起こっているのかを理解するためにtcpip.sysの一部をリバースエンジニアリングするのに1週間以上かかりました。しかし、Axelのブログ投稿は非常に役立ちました。
Axelがリバースエンジニアリングした関数と構造体、およびそれらが渡される他の関数を見ると、Ipv6pProcessOptions() に渡される唯一の引数は、記事で定義された同じ packet_t 構造体であることが明らかです。基本的に、Ipv6pProcessOptions に渡され、IppSendErrorList によって反復されるポインタは、パケットのリンクリストです。
そこで、Ipv6pProcessOptions() にブレークポイントを設定し、リストを検査しました。
list->Next エントリは NULL です。
ブレークポイントがヒットするたびに、リストには1つのパケットしか含まれていませんでした。なぜリストが実際にリストにならないのか、そしてどうすればリストにできるのかを理解するのに、認めたくないほど長い時間を費やしました。最初に考えたのはIPv6フラグメンテーションです。IPv6では、送信者が大きなパケットを別々の小さなパケットに分割することができ、それらをリストで一緒に保持するのは理にかなっています。
広範囲にわたるリバースエンジニアリングの後、私の仮定が正しいことを確認しましたが、フラグメントリストはここで扱っているものとは関係ありません。
実際、私はまったく偶然に答えを見つけました。時々リストが生成されることがありましたが、理由は不明でした。多くの堂々巡りの後、カーネルブレークポイントがトリガーされると、カーネル全体が一時停止され、ネットワークアダプタがパケットを蓄積することに気づきました。カーネルが再開すると、これらのパケットはきれいなリストとしてスタックを下ってtcpip.sysに渡されます。これは、カーネルが一時停止されている間にパケットが送信され、次のブレークポイントがヒットする前に処理されなかった場合にのみ発生しました。
この動作はおそらくパフォーマンス最適化であり、スループットが低いときはカーネルがパケットを個別に処理しますが、ボリュームが高いときはパケットがリストに編成され、バッチで処理されます。ほとんどの場合、リストはプロトコルや送信元アドレスなどの要因に基づいて分離され、処理を高速化します。したがって、私たちのリストには送信したIPv6パケットのみが含まれるはずです。 おいおい、お前はDoSが好きなんだろう?
パケットが高スループット時にリストに統合されることがわかったので、最も簡単なオプションは明らかです。皮肉なことに、私たちのDoS PoCはDoS状態をトリガーするためにDoSを使用する必要があります。IPv6パケットのバーストでシステムをフラッディングすれば、IppSendErrorList() に渡される大きなリストを得られるはずです。
最初は、どれだけのパケットを送信しても、カーネルを一時停止しない限りリストを n > 1 にすることができませんでした。しかし… Python(非常に遅い)をVM(二重に非常に遅い)で使用しているため、おそらくいくつかの設定を調整する必要があります。私の攻撃システムで起こっているVM-ception(VMの中のVM)に対抗するために、ターゲットVMを1つのCPUコアのみを使用するように再構成することにしました。
素晴らしい!パケットリストが多くのエントリを含むリストになりました!
どうやらVMの中のVMはDoSには最適な選択肢ではなかったようです。しかし、最終的にはなんとか動作させました。あとは、IppSendError() が何をするのか、そして問題がどの部分にあるのかを解明するだけです。
さらにリバース…永遠に…
広範囲にわたるリバースエンジニアリングの後、IppSendError が何をするのかがはるかに明確になりました。通常の状況では、net_buffer_list->Status を 0xC000021B(STATUS_DATA_NOT_ACCEPTED)に設定することでパケットを無効にします。次に、誤ったパケットに関する情報を含むICMPエラーを送信者に返信します。
IppSendErrorの2つの関連部分。
最初に試みたのは、net_buffer_list->Status の値を無視するtcpip.sys内の関数がないかどうかを確認することでした。これにより、未定義または予期しない状態のパケットがドライバによって処理され、うまくいけば悪用条件につながるでしょう。
パケット処理を担当するメインループ。
すべての解析関数を呼び出すループはエラーチェックでラップされているため(エラーコードが設定されるとどこにも進めない)、これは間違った方向性だと考えました。代わりに、IppSendError に戻り、エラーコードを設定する前にパケット状態を変更するコードパスがないかどうかを確認し、それが競合状態につながるかどうかを調べることにしました。
さらに多くのリバースエンジニアリングの後、IppSendError の非常に下部にある次のコードを見つけました。
IppSendError内でpacket_sizeをゼロに設定するコードパス。
IppSendErrorList(したがって IppSendError)が always_send_icmp 引数をtrueに設定して呼び出されると、リスト内のすべてのパケットにICMPエラーを送信しようとするように見えます。
その後、神のみぞ知る理由で、packet->packet_size フィールドがゼロに設定されるコードブロックに到達します。
always_send_icmp をtrueに設定するには、'Option Type' の値を0x80より大きい任意の数値に設定して、オプションヘッダ処理で特定のエラーを引き起こすだけで済みます。
def build_malicious_option(next_header, header_length, option_type, option_length): dest_options_header = 60
options_header = struct.pack('BBBB', next_header, header_length, option_type, option_length) + b'1337'
return Ether(dst=mac_addr) / IPv6(dst=ip_addr, nh=dest_options_header) / raw(options_header)
packet = build_malicious_option(next_header=59, header_length=0, option_type=0x81, option_length=0) sendp(packet)
しかし、packet_size をゼロに設定するとパーサーが壊れるのではないでしょうか?
パケット処理を担当するメインループからの抜粋。
パケットハンドラは、プリパース中に設定された packet->next_header の値に基づいてVTable関数を呼び出すだけであり、この値は変更されません。これにより、パケット処理は続行され、どの処理が行われるかを制御することも可能になります。
packet->next_header の値はIPv6パケットの'Next Header'フィールドから取得されるため、任意の有効なIPv6ヘッダ値に設定でき、ループは対応するパーサーを呼び出します。これにより、多くの潜在的な攻撃対象領域が得られます。
IPv6パケットフォーマット。
あとは、packet_size フィールドを使って何かおかしなことをする、到達可能なIPv6パーサーの部分を見つけるだけです。
フラグメンテーションに戻る
最初に調査しようと思った場所はIPv6フラグメントパーサーでした。なぜなら、そこが古いCVE-2021-24086の脆弱性があった場所だからです。そのため、さらに奇抜なコードを見つけるのに適した場所に思えました。
うーん…非常に近いが、まだ遠い。
ここには確かに脆弱性がありますが、RCEではありません。
基本的に、ほとんどのCPUでは、レジスタは循環します。レジスタを最大値を超えてインクリメントすると、ゼロに戻ります。同様に、最小値を下回ってデクリメントすると、最大値に戻ります。これらはそれぞれ整数オーバーフローおよび整数アンダーフローと呼ばれます。この動作は符号付き整数では少し異なりますが、ここでは扱っていません。
最初の行 fragment_size = LOWORD(packet->packet_size) - 0x30 は、次のアセンブリコードで構成されています。
フラグメントサイズを計算するアセンブリコード。
AXはEAXレジスタの下位16ビットです。EAXレジスタは32ビットですが、AXは独自の16ビットレジスタであるかのように動作するため、オーバーフローやアンダーフローはAXに限定され、EAXレジスタの残りの部分には影響しません。これは非常に便利です。なぜなら、EAXレジスタのアンダーフローは値が40億になり、4GBのメモリ割り当てを試みることになり、おそらく失敗するからです。
packet->packet_size の値はゼロなので、このコードはaxをゼロに設定し、そこから0x30を減算します。
通常の条件では、パケットヘッダは0x30バイトなので、packet_size - 0x30 はフラグメントデータのサイズです。