
CVE-2024-38063 - IPv6経由でカーネルをリモートから悪用する
ドライバーファイル全体で変更されたのは正確に1箇所だけで、結局それが本当にバグでした。
パッチ適用前後のtcpip.sysのbindiff概要。
ドライバー全体で変更されたのは単一の関数だけでした。通常なら、注目すべき関数を特定するだけで20以上の関数変更を調べるのに丸一日かかることもありますが、今回は違いました。

Ipv6pProcessOptions() パッチ適用前。
Ipv6pProcessOptions() . パッチ適用後。
変更されたのは単一の関数だけでなく、コードの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が呼び出されたときに発生します。
ここから状況は明白なものから異常に困難なものへと変わりました。ただし、その大部分は、私が持つ2つの脳細胞のうちの1つがひどいコロナ感染と戦うことに専念していたためだと思います。コードの一部を理解し、眠りに落ち、そして自分が解明したことを忘れてしまう、という日々を数日過ごしました。全体のプロセスには、何が起こっているのかを解明するためにtcpip.sysの一部をリバースエンジニアリングする1週間以上が必要でした。しかし、Axelのブログ記事が非常に役立ちました。
Axelがリバースエンジニアリングした関数と構造、およびそれらが渡される他の関数を見ると、 Ipv6pProcessOptions() に渡される唯一の引数が、この記事で定義されているのと同じ packet_t 構造体であることが明らかです。つまり、 Ipv6pProcessOptions に渡され、 IppSendErrorList によって反復処理されるポインターは、パケットの連結リストです。
そこで、 Ipv6pProcessOptions() にブレークポイントを設定してリストを検査しました。

list->NextエントリはNULLです。
ブレークポイントがヒットするたびに、リストには1つのパケットしか含まれていませんでした。なぜそうなるのか、そしてリストを実際にリストにするにはどうすればよいのかを解明するのに、認めたくないほど長い時間を費やしました。最初に考えたのはIPv6フラグメンテーションでした。IPv6では送信者が大きなパケットを複数の小さなパケットに分割でき、それらをリストにまとめておくのは理にかなっています。
広範なリバースエンジニアリングの後、私の仮定が正しいことを確認しましたが、フラグメントリストはここで扱っているリストとは関係ありません。
実際には、完全に偶然に答えを見つけました。たまにリストが複数項目になることがありましたが、その理由は不明でした。何度も堂々巡りをした後、カーネルのブレークポイントがトリガーされるとカーネル全体が一時停止し、ネットワークアダプターがパケットを蓄積することに気づきました。カーネルが再開すると、これらのパケットはきれいなリストとしてスタックを下ってtcpip.sysに渡されます。これは、パケットがカーネルの一時停止中に送信されたが、次のブレークポイントがヒットする前に処理されなかった場合にのみ発生しました。
この動作はおそらくパフォーマンス最適化であり、低スループットではカーネルはパケットを個別に処理しますが、高ボリュームではパケットはリストに整理されてバッチ処理されます。リストはおそらく、処理を高速化するためにプロトコルや送信元アドレスなどの要因に基づいて分離されているため、私たちのリストには送信したIPv6パケットのみが含まれるはずです。
高スループット時にパケットがリストに結合されることが分かったので、最も簡単なオプションが何であるかは明らかです。皮肉なことに、私たちのDoS PoCはDoS状態をトリガーするためにDoSを使用する必要があります。システムにIPv6パケットのバーストを大量に送信すれば、 IppSendErrorList() に渡される大きなリストを取得できるはずです。
最初は、どれだけ多くのパケットを送信しても、カーネルを一時停止した場合にしかリストをn > 1にできませんでした。しかし…Python(非常に遅い)をVM(2倍非常に遅い)内で使用しているため、いくつかの設定を調整する必要がありそうです。攻撃システムで発生しているVM-ception(VMの中のVM)に対抗するために、ターゲットVMをシングルCPUコアのみを使用するように再構成することにしました。

いいですね! パケットリストが多くのエントリを含むリストになりました!
IppSendError() が何をするのか、そして問題がどの部分にあるのかを解明する必要があります。広範なリバースエンジニアリングの後、IppSendErrorが何をするのかがかなり明確になりました。通常の状況では、net_buffer_list->Statusを0xC000021B(STATUS_DATA_NOT_ACCEPTED)に設定してパケットを無効化するだけです。次に、エラーのあるパケットに関する情報を含むICMPエラーを送信者に送り返します。
IppSendErrorの関連する2つの部分。
最初に確認したのは、tcpip.sysにnet_buffer_list->Status値を無視する関数があるかどうかでした。これにより、ドライバーが未定義または予期しない状態のパケットを処理することになり、うまくいけば悪用条件につながるでしょう。

パケットの処理を担当するメインループ。
すべての解析関数を呼び出すループがエラーチェックで囲まれているため(エラーコードが設定されるとどこにも進めないことを意味します)、これは間違った深みにはまるルートだと考えました。代わりに、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パーサーの部分を見つけることだけです。

うーん…あと一歩なのに、でも遠い
ここには確かに脆弱性がありますが、RCEではありません。
基本的に、ほとんどのCPUではレジスタは循環式です。レジスタをその最大可能値を超えてインクリメントすると、ゼロに戻ります。同様に、最小可能値を下回ってデクリメントすると、最大可能値に戻ります。これらはそれぞれ整数オーバーフローおよび整数アンダーフローと呼ばれます。この動作は符号付き整数では少し異なりますが、ここでは扱っていません。
最初の行、 fragment_size = LOWORD(packet->packet_size) - 0x30 は、次のASMコードで構成されています:

フラグメントサイズを計算するASMコード。
AXはEAXレジスタの下位16ビットです。EAXレジスタは32ビットですが、AXは独自の16ビットレジスタであるかのように動作するため、オーバーフローやアンダーフローはAXに限定され、EAXレジスタの残りの部分には影響しません。これは非常に便利です。EAXレジスタでアンダーフローが発生すると40億という値になり、4GBのメモリ割り当てが試行されて、おそらく失敗するからです。
packet->packet_sizeの値はゼロ なので、このコードはaxをゼロに設定してから、そこから0x30を減算します。
通常の条件下ではパケットヘッダーは0x30バイトなので、 packet_size - 0x30 はフラグメントデータのサイズです。
私たちの場合、packet->packet_sizeは0なので、そこから1でも減算すると、レジスタは最大可能な16ビット整数値(0xFFFF)に循環します。0x30を減算しているため、AXの値はアンダーフローして MAX_VALUE - 0x2F、つまり0xFFD0 になり、これは65,488です。
残念ながら、メモリ割り当てとデータコピーの両方に同じ計算が使用されているため、バッファオーバーフローは発生しません。 RtlCopyMdlToBuffer() はソースバッファに対しても境界チェックを実行すると思うので、範囲外読み取りも発生しません。しかし、完全に手ぶらで終わるわけではありません。
ExAllocatePoolWithTagPriority() は割り当てられたメモリをゼロにしないため、また RtlCopyMdlToBuffer() は利用可能な実際のデータ量のみをコピーするため、約65KBの初期化されていないカーネルメモリが得られます。メモリアドレスは解放後に再利用されるため、バッファには再割り当て前にそのアドレスに保存されていたものがおそらく入っています。フラグメンテーションを使用して、ICMPエコーリクエストのように私たちに送り返されるパケットを構築できれば、ランダムなカーネルメモリを漏えいさせて、ASLR回避につなげられる可能性があります。
IPv6フラグメントは、次の3つの条件のいずれかが発生するまでメモリに残ります:
フラグメンテーションをひどく失敗させ、システムが停止すべき時だと伝える。
'More'フィールドが0に設定されたフラグメントを送信する。これは最後のフラグメントであることを示し、システムは再構築を開始する。
タイムアウト期間(60秒)が切れる前に最後のフラグメントを送信せず、システムがフラグメントを破棄する。
Ipv6pReassemblyTimeout()は条件3の下で呼び出されるので、これをどのように悪用できるか調べてみましょう。
Ipv6pReassemblyTimeout() は条件3の下で呼び出されるので、これをどのように悪用できるか調べてみましょう。

これはまさに私たちが必要としているものです!

割り当てサイズの計算を担当するアセンブリコード。
ここで見られるように、計算の最初の部分(fragment_list->net_buffer_length + reassembly->packet_length + 8)は16ビットのDXレジスタを使用して行われます。
先ほど覚えているかもしれませんが、reassembly->packet_lengthをアンダーフローさせて0xFFD0にしました。したがって、DXレジスタは8バイトを加算した後、0xFFD8になります。fragment_list->net_buffer_lengthが0x27(39バイト)より大きい場合、DXはオーバーフローしてゼロにリセットされます。
fragment_list->net_buffer_lengthは0x38バイト程度になるはずなので、DXレジスタがオーバーフローして8になります。0x28バイトが加算された後、わずか48バイトのメモリ割り当てになります。
後続の memmove() 呼び出しはサイズにそのままのreassembly->packet_length値を使用するため、65,488バイトがreassembly->payloadから30バイトのバッファにコピーされることになります。さらに素晴らしい点は、コピーされるデータの多くがフラグメントペイロード(私たちが制御でき、任意の形式の任意のデータにできる)から来るため、かなり制御しやすいカーネルプールベースのバッファオーバーフローが得られることです。
脆弱性をトリガーする可能性を得るには、IppSendErrorListが呼び出された時点で、連結リスト内の不正な形式のオプションパケットの後に1つ以上のフラグメントパケットが配置されている必要があります。しかし、私のテストからすると、これだけでは悪用が保証されないようです。他にも満たす必要のある条件があると思います。IppSendError内の同期コードにより、競合状態にも勝たなければならないのではないかと推測していますが、確認はしていません。
さらに、コードはreassembly->fragment_sizeもアンダーフローした16ビット整数(65,488)に設定するため、バッファオーバーフローを引き起こすために使用できる可能性のある2つの別々の変数が得られました。
解決策(または少なくともその1つ)は Ipv6pReassemblyTimeout() です。初期のフラグメント処理ではオーバーフローを引き起こせませんが、クリーンアップ中には引き起こせるようです。