
POC переполнения буфера стека NtCopyFileChunk
Этот репозиторий содержит POC, который при выполнении вызывает запись за пределами стека (OOB write), приводящую к краху системы. Эта уязвимость — чрезвычайно интересный и редкий артефакт, демонстрирующий сложность и особенности программирования ядра, особенно управления объектами. Кроме того, уязвимость затрагивает ядро Windows 11 24h2, Windows 10 22h2/21h2, но не Windows 11 22h2/23h2, что только добавляет интереса.
Исправлено 12 ноября 2024 года
Уязвимость переполнения буфера на основе стека (технически — внеплановая запись, OOB write) существует в функции системного вызова ядра Windows NtCopyFileChunk.
NtCopyFileChunk позволяет выполнить две операции в одном системном вызове: чтение исходного файла и запись в файл назначения.
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] видно, что UserEvent структуры WriteIrp указывает на стековое событие. Однако что будет, если сформировать правильный запрос, но передать неправильное входное событие? В [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);
[...]
}
Итак, ядро выполняет ObfDereferenceObject над UserEvent. Но что здесь не так? Дело в том, что, как я подчёркивал всё это время, это KEVENT, расположенный в стеке. У него нет OBJECT_HEADER и, следовательно, счётчика ссылок, потому что его время жизни ограничено стековым кадром. А вот функция ZwCreateEvent позволила бы создать такой заголовок для события, потому что тогда память выделяется в системном пуле, и её нужно освобождать, когда счётчик падает до нуля. Однако в нашем случае это не используется. OBJECT_HEADER всегда располагается перед каждым объектом. Именно поэтому и происходит OOB write: ObfDereferenceObject обращается к отрицательному смещению 0x30 и уменьшает фиктивный счётчик ссылок. Это позволяет произвольно уменьшать что-то, расположенное в локальных переменных стекового кадра NtCopyFileChunk. Совершенно случайно, какие локальные переменные там будут. Более того, если счётчик упадёт до нуля, ядро попытается освободить стек так, как будто это системный пул...
Это самый важный вопрос. Он позволит нам понять, как такая очевидная уязвимость могла возникнуть в этих обстоятельствах.
Давайте посмотрим на тот же фрагмент кода внутри NtCopyFileChunk в ntoskrnl в Windows 11 23h2. В упомянутом выше псевдокоде NtCopyFileChunk для 24h2 событие инициализируется в [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. Это подчёркивает сложность разработки ядра и показывает, как легко допустить критическую ошибку.
Согласно собственной оценке Microsoft, эксплуатация этой уязвимости оценивается как «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-й параметр). Таким образом, если мы передадим любой положительный DestOffset больше 2, мы просто уменьшим DestOffset, и это ничего там не изменит — мы спокойно выйдем из функции. Но если передать DestOffset = 1, ядро попытается освободить наше стековое событие как системный пул.
Перед освобождением системного пула вызывается callback, специфичный для типа объекта, указанного в _OBJECT_HEADER.TypeIndex, который перекрывается с UserInputEventObject, которым мы также управляем. Это означает, что мы можем обмануть систему и заставить её принять любой нужный нам тип объекта. Это открывает целый ряд возможностей. Наша главная цель — перехватить RIP до операции освобождения пула, например, через object type confusion, чтобы перезаписать callback в каком-нибудь месте.
Но есть одна очень неприятная вещь, которая отменяет наши планы. Как вы можете видеть выше, существует проверка наличия _OBJECT_HEADER.HandleCount, и если он ненулевой, ядро вызывает BSOD. В нашем случае он перекрывается с HandleInformation. И поскольку это не параметр, контролируемый пользователем, необходимы дальнейшие исследования того, как сделать его равным нулю.
//0x8 bytes (sizeof)
struct _OBJECT_HANDLE_INFORMATION
{
ULONG HandleAttributes; //0x0
ULONG GrantedAccess; //0x4
};
Как выясняется, эта структура принадлежит DestHandle. Это подразумевает, что мы должны открыть дескриптор назначения с нулевым GrantedAccess. Это также означает, что HANDLE_TABLE_ENTRY.GrantedAccessBits нашего DestHandle должен быть нулевым. Однако, поскольку мы также должны открыть его для записи (ObReferenceFileObjectForWrite), он в любом случае не может быть нулевым.
Это приводит нас в тупик.