
CVE-2023-21768の概念実証エクスプロイト。Windowsの補助機能ドライバー(AFD.sys)における任意カーネル書き込みの脆弱性で、I/Oリングを介したローカル権限昇格を可能にします。
Microsoft Security Response Center (MSRC) が公開した CVE-2023-21768 の詳細な説明によると、この脆弱性は Ancillary Function Driver (AFD) に存在し、システム上のファイル名は afd.sys です。AFD モジュールは WinSock API のカーネルエントリポイントです。この分析では、Windows 11 で特権昇格を悪用するためにこれを使用します。
Winbindex から afd.sys の 2 つのバージョンをダウンロードします。1 つはパッチ適用前の直近のバージョン、もう 1 つはパッチ適用後のバージョンです。次に Bindiff を使用してこれら 2 つのバージョンを比較します。

2 つのバージョンを概観すると、AfdNotifyRemoveIoCompletion という 1 つの関数のみに違いがあることがわかります。この関数の 2 つのバージョン間の違いを詳しく見てみましょう。

2 つのバージョン間の違いはそれほど多くありません。パッチ適用後バージョンでは、パラメータを設定して ProbeForWrite 関数を呼び出すためのアセンブリ命令が追加されています。Microsoft の ドキュメント によると、この関数は、アドレスが実際にユーザーモードに属しているか、書き込み権限があるか、そして正しくアラインされているかをチェックするために使用されます。このコードをより詳しく分析します:
afd.sys version 10.0.22621.608

-パッチ適用後 afd.sys version 10.0.22621.1105

どちらも r15_1 の値をチェックし、0 の場合は var_304 の値を struct_1 の指定されたフィールドのポインタに書き込みます。0 でない場合は、ポインタが有効なアドレスを指していることを確認するために ProbeForWrite が呼び出されます。パッチ適用前のバージョンでは、var_304 の値をポインタに書き込むだけで、このチェックが欠落しています。このパッチから、制御された arg3_1->field_18 の値を使ってこのコードを呼び出せる可能性があると推測できます。field_18 にカーネルアドレス値を設定できれば、var_304 をカーネルメモリ領域のアドレスに書き込むことができます。
=> バグの種類: 任意のカーネル Write-Where
次に、バグをトリガーする方法を見つける必要があります。AfdNotifyRemoveIoCompletion 関数は AfdNotifySock 関数内で直接呼び出されます。

同様に、AfdNotifySock のクロスリファレンスを探すと、他の関数から直接呼び出されるわけではありませんが、関数のアドレスが .rdata 内のアドレスに保存されています。

このアドレスは AfdIrpCallDispatch の直前にあります。

バグをトリガーするために、IOCTL_AFD_NOTIFY_SOCK を指定して DeviceIoControl を呼び出すと、AfdNotifySock が呼び出されます。
BOOL DeviceIoControl(
[in] HANDLE hDevice,
[in] DWORD dwIoControlCode,
[in, optional] LPVOID lpInBuffer,
[in] DWORD nInBufferSize,
[out, optional] LPVOID lpOutBuffer,
[in] DWORD nOutBufferSize,
[out, optional] LPDWORD lpBytesReturned,
[in, out, optional] LPOVERLAPPED lpOverlapped
);
各ドライバーには、カーネル内に DRIVER_OBJECT オブジェクトが作成されます。これは次のように定義されています:
typedef struct _DRIVER_OBJECT {
CSHORT Type;
CSHORT Size;
PDEVICE_OBJECT DeviceObject;
ULONG Flags;
PVOID DriverStart;
ULONG DriverSize;
PVOID DriverSection;
PDRIVER_EXTENSION DriverExtension;
UNICODE_STRING DriverName;
PUNICODE_STRING HardwareDatabase;
PFAST_IO_DISPATCH FastIoDispatch;
PDRIVER_INITIALIZE DriverInit;
PDRIVER_STARTIO DriverStartIo;
PDRIVER_UNLOAD DriverUnload;
PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];
} DRIVER_OBJECT, *PDRIVER_OBJECT;
最後の要素 MajorFunction は、カーネルとユーザーモード間の通信を処理するドライバーのディスパッチ関数を格納する配列です。DeviceIoControl の呼び出しに対応するディスパッチ関数は MajorFunction[IRP_MJ_DEVICE_CONTROL] に保存されます。
#define IRP_MJ_DEVICE_CONTROL 0x0e
afd.sys の DriverEntry 関数から、ドライバーがデバイスオブジェクト "\Device\Afd" を作成したことがわかります:

MajorFunction[IRP_MJ_DEVICE_CONTROL] に AfdDispatchDeviceControl が代入されているため、カーネルと通信するために DeviceIoControl を呼び出すと、この関数が呼び出されます。

AFD には、AfdIrpCallDispatch と AfdImmediateCallDispatch という 2 つのディスパッチテーブルがあります。

AfdDispatchDeviceIoControl が IoControlCode から subscript を計算し、AfdIoctlTable から subscript に対応する値を取得して IoControlCode と照合していることが簡単にわかります。

AfdImmediateCallDispatch の開始アドレスと AfdNotifySock が保存されているアドレスの間の距離から、index は 73、control code は 0x12127 であると計算できます。

int main() {
WSADATA WSAData;
SOCKET s;
SOCKADDR_IN sa;
int ierr;
WSAStartup(0x2, &WSAData);
s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
memset(&sa, 0, sizeof(sa));
sa.sin_port = htons(135);
sa.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
sa.sin_family = AF_INET;
ierr = connect(s, (const struct sockaddr*)&sa, sizeof(sa));
char outBuf[100];
DWORD bytesRet;
DWORD inbuf1[100];
memset(inbuf1, 0, sizeof(inbuf1));
DeviceIoControl((HANDLE)s, 0x12127, (LPVOID)inbuf1, 0x30, outBuf, 0, &bytesRet, NULL);
return 0;
}
動作しました!

最初に述べたように、この脆弱性は、検証されていないポインタを構造体を通じて渡せる場合に発生します。この構造体は、DeviceIoControl の lpInBuffer を介してユーザーモードから直接渡されます。その後、4 番目のパラメータとして AfdNotifySock に渡され、3 番目のパラメータとして AfdNotifyRemoveIoCompletion に渡されます。

struct の構成が不明なため、IDA に struct を自動生成させました。次に、この struct にデータを渡し、必要なチェックをバイパスして脆弱なコードに到達する方法を見つける必要があります。まず AfdNotifySock 関数から始めます:

まず、struct のサイズは 0x30 バイトである必要があります。

0 以外にする必要がある値:

もう 1 つ、デバッグ中に、以前の UserBuffer チェックの箇所で fail にジャンプすることがわかったため、DeviceIoControl を呼び出すときにこの値を NULL に設定しました。上記の値を設定した後、check2 を通過できました。

次にバイパスする必要があるチェック:

ObReferenceObjectByHandle が STATUS_SUCCESS を返す場合にのみ、このチェックを通過できます。つまり、有効なハンドルを渡す必要があります。IoCompletionObjectType の作成方法についてはどこにも記載されていないようでした。そこで、https://securityintelligence.com/posts/patch-tuesday-exploit-wednesday-pwning-windows-ancillary-function-driver-winsock/ の分析記事に従いました。NtCreateIoCompletion 関数を使用して IoCompletionObjectType を作成し、そのハンドルを ObReferenceObjectByHandle に渡します。
このチェックをバイパスすると、プログラムのフローはループに入ります。このループ内には fail フローに移行する箇所がないため、dword20 の値を 0x1 に設定してループを抜けました。

ループを抜けると、プログラムは AfdNotifyRemoveIoCompletion を呼び出します。引き続き AfdNotifyRemoveIoCompletion 関数を分析します:

まず、プログラムは struct の別のフィールドをチェックし、0 以外でなければなりません。次に、その値は 0x20 で乗算され、struct の別のフィールドとともに ProbeForWrite 関数のパラメータとして使用されます。ここでは、書き込み権限を持つユーザーモードメモリ領域のアドレスと dwLen = 1 を指定するだけで十分です。バグをトリガーする前の最後のチェックは、IoRemoveCompletion 関数の戻り値が STATUS_SUCCESS であることです。調査したところ、NtRemoveIoCompletion 関数は呼び出されると IoRemoveCompletion 関数を呼び出すことがわかりました。この ドキュメント によると、NtRemoveIoCompletion は「waiting call」として機能し、指定された Io Completion Object 内に 1 つ以上の完了レコードがあると終了します。レコードは I/O 処理が完了したときに追加されます。
NtRemoveIoCompletion(
IN HANDLE IoCompletionHandle,
OUT PULONG CompletionKey,
OUT PULONG CompletionValue,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN PLARGE_INTEGER Timeout OPTIONAL );
さらに、オプションのパラメータである Timeout があり、タイムアウト値に達すると関数は終了します。ただし、timeout = 0 を設定するだけでは関数は戻り値を返さず、タイムアウトエラーコードを返します。NtSetIoCompletion 関数を使用して、IoCompletionObjectType 内の保留中の IO カウンタを 1 増やし、タイムアウト前に NtRemoveIoCompletion 関数を終了させることができます。何度か試したところ、書き込まれる値は常に 0x1 でした。
カーネルモードのアドレスに 0x1 の値を書き込めることを利用して、この脆弱性を使用して、I/O リング(Microsoft が発表した新しい I/O メカニズム)を活用することにより、任意のアドレスの読み取り/書き込みを完全に行うことができます。Yarden Shafir 氏はこの方法について非常に詳細な分析記事を書いており、ここ から読むことができます。アプリケーションが実行できる操作の 1 つは、将来の I/O 操作のためにすべてのバッファを割り当ててから、それらを I/O リングに登録することです。事前登録されたバッファは、I/O オブジェクトを介して参照されます:
typedef struct _IORING_OBJECT
{
USHORT Type;
USHORT Size;
NT_IORING_INFO UserInfo;
PVOID Section;
PNT_IORING_SUBMISSION_QUEUE SubmissionQueue;
PMDL CompletionQueueMdl;
PNT_IORING_COMPLETION_QUEUE CompletionQueue;
ULONG64 ViewSize;
ULONG InSubmit;
ULONG64 CompletionLock;
ULONG64 SubmitCount;
ULONG64 CompletionCount;
ULONG64 CompletionWaitUntil;
KEVENT CompletionEvent;
UCHAR SignalCompletionEvent;
PKEVENT CompletionUserEvent;
ULONG RegBuffersCount;
PVOID RegBuffers;
ULONG RegFilesCount;
PVOID* RegFiles;
} IORING_OBJECT, *PIORING_OBJECT;
この記事で取り上げているような脆弱性によって、RegBuffersCount フィールドと RegBuffers フィールドを更新/編集できる場合、標準の I/O リング API を使用してカーネルメモリを読み書きできます。ただし、NtQuerySystemInformation 関数を使用するには Medium IL 権限が必要です。Low IL から LPE するには、カーネルアドレスをリークする何らかの方法が必要です。
IoRing->RegBuffers がユーザー制御の fakeBuffer を指すようになると、通常の I/O リング操作を使用して、fake 内のインデックスをバッファとして指定することにより、任意のアドレスへの読み取りと書き込みを実行できます:
詳細については、上記のリンクにある Yarden Shafir 氏の分析記事を読むことができます。
上記の PoC コードで IO Ring オブジェクトを作成して書き込もうとしたところ、DeviceIOControl 呼び出し後に Windows がクラッシュした /_ \ ため、Nt 関数を直接呼び出す方法 を使用しました (˘・_・˘)
ProbeForWrite を呼び出すコードを追加しました