
NtCopyFileChunk スタックバッファオーバーフロー PoC
このリポジトリには、実行時にスタック上のOOB書き込みを引き起こし、システムをクラッシュさせるPOCが含まれています。この脆弱性は、カーネルプログラミング、特にオブジェクト管理の複雑さと特異性を示す、非常に興味深く珍しい遺物です。さらに、この脆弱性はWindows 11 24h2、Windows 10 22h2/21h2のカーネルに影響しますが、Windows 11 22h2/23h2には影響しません。これもまた興味をそそります。
2024年11月12日にパッチ適用済み
Windowsカーネルのシステムコール関数NtCopyFileChunkに、スタックベースのバッファオーバーフロー脆弱性(より厳密にはOOB書き込み)が存在します。
NtCopyFileChunkは、単一のシステムコールで2つの操作(ソースファイルの読み取りと宛先ファイルへの書き込み)を実行できます。
NT_COPYFILE_DATA_BUFFERは、コピーに必要なすべてを含む構造体です。この構造体はリバースエンジニアリングによって取得されたものであり、その名前は発明されたものであることに注意してください。したがって、この点を理解しておいてください。
struct NT_COPYFILE_DATA_BUFFER // sizeof=0x48
{
DWORD64 UnknownQword1;
DWORD64 UnknownQword2;
DWORD64 UnknownQword3;
DWORD64 UnknownQword4;
PIRP WriteIrp;
PDEVICE_OBJECT HighestDeviceObject;
PFILE_OBJECT DestFileObject;
PFILE_OBJECT SourceFileObject;
DWORD64 SourceOffsetQuadPart;
};
この関数は擬似コードでは次のようになります:
// Pseudocode for the function nt!NtCopyFileChunk in win11 24h2
__int64 __fastcall NtCopyFileChunk(
void* SourceHandle,
void* DestHandle,
void* UserInputHandleEvent,
struct _IO_STATUS_BLOCK* IoStatusBlock,
ULONG Length,
__int64 SourceOffset,
struct _KTHREAD** DestOffset,
ULONG* SourceKey,
_DWORD* DestKey,
int Flags)
{
[...]
NTSTATUS Status;
char is_alertable_io;
DWORD64 SourceOffsetStack;
struct _KTHREAD* DestOffsetValue;
_OBJECT_HANDLE_INFORMATION* HandleInformation;
NT_COPYFILE_DATA_BUFFER* DataBuffer_3;
PVOID UserInputEventObject;
_FILE_OBJECT* pSourceFileObject;
PIRP WriteIrp;
struct _KEVENT StackEvent; // [1]
[...]
memset(&StackEvent, 0, sizeof(StackEvent));
DataBuffer = (NT_COPYFILE_DATA_BUFFER*)ExAllocatePool2(0x43u, Length + sizeof(NT_COPYFILE_DATA_BUFFER), 'pCoI');
ArbDataBuffer = DataBuffer + sizeof(NT_COPYFILE_DATA_BUFFER); //point after NT_COPYFILE_DATA_BUFFER
// Reference source file by handle
ret = IopReferenceFileObject(SourceHandle, 1u, PreviousMode, (PVOID*)&DataBuffer_2->SourceFileObject, 0);
if (ret < 0)
goto RET;
// Reference destination file by handle
ret = ObReferenceFileObjectForWrite(
(ULONG_PTR)DestHandle,
PreviousMode,
(_FILE_OBJECT*)&DataBuffer->DestFileObject,
(_OBJECT_HANDLE_INFORMATION*)&HandleInformation);
[...]
//Fill ArbDataBuffer with data that we will write to the dest file
ret = IopPopulateCopyWriteWorkerData(
(__int64)DestFileObj,
(__int64)IoStatusBlock,
(__int64)ArbDataBuffer,
Length,
v28,
(__int64)pSourceFileObject,
UserInputHandleEvent_1,
DestOffset,
DestKey,
SHIDWORD(HandleInformation),
(__int64)&DataBuffer_2->WriteIrp);
[...]
if (DestFileObj->Flags & FO_SYNCHRONOUS_IO)
{
// [2]
KeInitializeEvent(&StackEvent, SynchronizationEvent, 0);
// [3]
DataBuffer_3->WriteIrp->UserEvent = &StackEvent; //WriteIrp contains pointer to stack event!
DataBuffer->WriteIrp->Flags |= IRP_MJ_WRITE;
}
else
{
//for asynchronous mode, we don't need that
[...]
}
UserInputEventObject = 0;
// [4]
ret = ObReferenceObjectByHandle(
UserInputHandleEvent,
2u,
(POBJECT_TYPE)ExEventObjectType,
PreviousMode,
&UserInputEventObject,
0);
if (ret >= 0)
{
//If the user has submitted the correct event, we proceed to the main logic for copying one file to another.
//I have omitted that section of code for simplicity.
KeResetEvent((PRKEVENT)UserInputEventObject);
goto NEXT_PATH_TO_READ_FILE_QUERY;
}
RET:
//Here it is! Free the DataBuffer structure (remember WriteIrp, which contains a pointer to the stack event).
// [5]
if (ArbDataBuffer)
IopFreeCopyObjectsFromDataBuffer((__int64)ArbDataBuffer, 1);
if (UserInputEventObject_1)
ObfDereferenceObject(UserInputEventObject_1);
return (unsigned int)ret;
}
[1]で、問題のオブジェクトであるStackEventを確認できます。[2]では、宛先ファイルが同期モードで開かれている場合、カーネルはスタックイベントを使用して、宛先ファイルへの書き込み操作を同期的に待機します。そのために、ユーザーから渡されたイベントではなく、スタックイベントに対してIopWaitForSynchronousIoEvent(擬似コードには示されていません)を使用します。まずカーネルはスタックイベントを待機し、その後でユーザーから渡されたイベントを更新します。同様に、[3]ではWriteIrpのUserEventがスタックイベントを指していることがわかります。しかし、正しいリクエストを形成しても、誤った入力イベントを渡した場合はどうなるでしょうか? [4]では、入力イベントを参照する様子がわかります。ここで無効なハンドル(例えば値1)を渡すことができます。その後、[5]でメモリがクリアされます。ここで最も興味深いことが起こります。
IopFreeCopyObjectsFromDataBuffer関数を分析し、irpがクリアされるときに何が起こるかを見てみましょう。WriteIrp->UserEventを詳しく見てみます。
void __fastcall IopFreeCopyObjectsFromDataBuffer(__int64 ArbDataBuffer, char to_clear_irp)
{
NT_COPYFILE_DATA_BUFFER *DataBuffer;
PFILE_OBJECT SourceFileObject;
PIRP WriteIrp;
PFILE_OBJECT DestFileObject;
DataBuffer = (NT_COPYFILE_DATA_BUFFER *)(ArbDataBuffer - 0x48);
if ( to_clear_irp )
{
WriteIrp = DataBuffer->WriteIrp;
DestFileObject = DataBuffer->DestFileObject;
if ( WriteIrp )
{
IopFreeIrpExtension((__int64)DataBuffer->WriteIrp, 9, 1);
//We are moving deeper, closely monitoring UserEvent
IopExceptionCleanupEx((ULONG_PTR)DestFileObject, WriteIrp, WriteIrp->UserEvent, 0, 0);
return;
}
if ( DestFileObject )
ObfDereferenceObjectWithTag(DataBuffer->DestFileObject, 0x746C6644u);
}
SourceFileObject = DataBuffer->SourceFileObject;
if ( SourceFileObject )
ObfDereferenceObjectWithTag(SourceFileObject, 0x746C6644u);
ExFreePoolWithTag(DataBuffer, 0);
}
LONG_PTR __fastcall IopExceptionCleanupEx(ULONG_PTR DestFileObject, PIRP Irp, PVOID UserEvent, PVOID P, char a5)
{
[...]
if ( Irp )
{
MasterIrp = Irp->AssociatedIrp.MasterIrp;
if ( MasterIrp )
ExFreePoolWithTag(MasterIrp, 0);
[...]
IoFreeIrp(Irp);
}
//Oh, that's it! But how can you decrement the reference counter for a stack object that doesn't have an OBJECT_HEADER?
//Vuln!
if ( UserEvent )
ObfDereferenceObject(UserEvent);
if ( P )
ExFreePoolWithTag(P, 0);
[...]
}
さて、カーネルはUserEventに対してObfDereferenceObjectを実行します。しかし、ここで何が問題なのでしょうか? 私がずっと強調してきたように、これはスタック上にあるKEVENTです。OBJECT_HEADERを持たず、したがって参照カウンタも持ちません。その寿命はスタックフレームによって制限されているからです。しかし、ZwCreateEvent関数を使用すれば、イベントにそのようなヘッダーを作成できます。その場合、メモリはシステムプールに割り当てられ、カウンタがゼロになったときに解放する必要があります。しかし、今回のケースではそれは使用されていません。OBJECT_HEADERは常に各オブジェクトの前に配置されます。これがOOB書き込みが発生する理由です。ObfDereferenceObjectは負のオフセット0x30を参照し、架空の参照カウントをデクリメントするからです。これにより、NtCopyFileChunkスタックフレームのローカル変数にあるものを任意にデクリメントできます。そこにどのローカル変数があるかは完全にランダムです。さらに、カウンタがゼロになると、カーネルはスタックをシステムプールであるかのように解放しようとします...
これは最も重要な疑問です。これにより、このような明白な脆弱性がどうしてこれらの状況で発生し得たのかを理解できます。
Windows 11 23h2のntoskrnl内のNtCopyFileChunkのまったく同じコードを見てみましょう。前述の24h2用NtCopyFileChunkの擬似コードでは、イベントは[2]で初期化されています。
*(_QWORD *)&ObjectAttributes.Length = 48;
memset(&ObjectAttributes.Attributes + 1, 0, 20);
ObjectAttributes.RootDirectory = 0;
ObjectAttributes.Attributes = PreviousMode == 0 ? 0x200 : 0;
ObjectAttributes.ObjectName = 0;
//Not even a stack event! It has an OBJECT_HEADER and stores a reference counter.
status = ZwCreateEvent(&EventHandle, 0x1F0003u, &ObjectAttributes, SynchronizationEvent, 0);
ご覧のとおり、Windows 11 23h2/22h2では、スタックイベントの代わりに、ZwCreateEventとOBJECT_HEADERを介してプールに割り当てられたイベントが使用されています。したがって、ここには脆弱性はありません。ObfDereferenceObjectはそのようなオブジェクトに対して許容される操作です。ちなみに、これはまさに24h2でこの脆弱性をパッチした方法です。問題の根源は、異なるバージョン間でのコードの非同期化にありました。おそらくどこかの段階で、エンジニアはメモリを節約するためにスタック上にイベントを定義することにしました(これは標準的な慣行です)。しかし、IopFreeCopyObjectsFromDataBufferクリーンアップ関数が参照カウントのデクリメントを実行することを忘れていました。もしこれがなければ、23h2でリークが発生していたでしょう。NtCopyFileChunkコードがWindows 11のすべてのバージョンに共通ではないのは驚きです。これはカーネル開発の複雑さを浮き彫りにし、重大なミスを犯すことがいかに簡単であるかを示しています。
マイクロソフト自身の評価によると、この脆弱性の悪用可能性は「More Likely」(可能性が高い)です。この記述は当初私の興味をそそりました。では、真実を確かめてみましょう。
OOBアクセスが発生するスタックフレームに注目しましょう。
struct _KTHREAD* DestOffset; //-0x30
OBJECT_HANDLE_INFORMATION HandleInformation; // -0x28
NT_COPYFILE_DATA_BUFFER* DataBuffer; // -0x20
PVOID UserInputEventObject; // -0x18
_FILE_OBJECT* pSourceFileObject; //-0x10
PIRP WriteIrp; //-0x8
struct _KEVENT StackEvent; // our vuln event
そして、ObfDereferenceObjectがこのスタックフレームにどのようにアクセスするかを見てみましょう。
;rcx = ptr to StackEvent
ObfDereferenceObject proc near
...
;rdi = beginning of OBJECT_HEADER,
;but in our case it is ptr to DestOffset(user controlled)
lea rdi, [rcx-30h]
...
mov rbx, -1
lock xadd [rdi], rbx ; decrement DestOffset
sub rbx, 1
jg short RET ;if refcnt was >= 2, dont delete object, just exit
;The inevitable path to freeing the object, it will crash the system...
mov rcx, [rdi+8] ;rcx = HandleInformation(_OBJECT_HANDLE_INFORMATION struct)
test rcx, rcx
jnz short BSOD ; HandleInformation must be zero, or we will BSOD
test rbx, rbx ;check for negative reference counter
js short BSOD
...more code...
ご覧のとおり、ここでBSODを引き起こす方法はあまりにも多くあります。また、DestOffsetが_OBJECT_HEADER.PointerCountと重なっていることも非常に幸運です。これはNtCopyFileChunk呼び出しの一部(第7パラメータ)です。したがって、2より大きい正のDestOffsetを渡すと、単にDestOffsetをデクリメントするだけで、そこで何も起こらず、関数を冷静に終了します。しかし、DestOffset = 1を渡すと、カーネルはスタックイベントをシステムプールとして解放しようとします。
システムプールが解放される前に、_OBJECT_HEADER.TypeIndexで指定されたオブジェクトのタイプ固有のコールバックが呼び出されます。これはUserInputEventObjectと重なっており、これも制御可能です。つまり、システムをだまして任意のオブジェクトタイプを受け入れさせることができます。これにより、さまざまな可能性が広がります。私たちの主な目標は、フリープール操作の前にRIPを乗っ取ることです。例えば、オブジェクトタイプの混乱を通じて、どこかのコールバックを上書きします。
しかし、私たちの計画を台無しにする非常に厄介なことが1つあります。上で見たように、_OBJECT_HEADER.HandleCountの存在をチェックし、それがゼロでない場合、カーネルはBSODを呼び出します。私たちのケースでは、これはHandleInformationと重なります。そして、これはユーザーが制御できるパラメータではないため、それをゼロにする方法についてさらなる調査が必要です。
//0x8 bytes (sizeof)
struct _OBJECT_HANDLE_INFORMATION
{
ULONG HandleAttributes; //0x0
ULONG GrantedAccess; //0x4
};
結局のところ、この構造体はDestHandleに属していることがわかります。これは、宛先ハンドルをGrantedAccessゼロで開かなければならないことを意味します。また、DestHandleのHANDLE_TABLE_ENTRY.GrantedAccessBitsがゼロでなければならないことも意味します。しかし、書き込み用に開く必要もあるため(ObReferenceFileObjectForWrite)、いずれにしてもゼロにすることはできません。
これにより、私たちは行き詰まりに直面します。