
NtCopyFileChunk stack buffer overflow POC
This repository contains a POC that triggers a stack OOB write when executed, causing the system to crash. This vulnerability is an extremely interesting and rare relic that demonstrates the complexities and peculiarities of kernel programming, especially object management. Furthermore, the vulnerability affects the kernel of Windows 11 24h2, Windows 10 22h2/21h2, but not Windows 11 22h2/23h2, which only adds to the interest.
Patched on November 12, 2024
A stack-based buffer overflow vulnerability(more technically, OOB write) exists in the Windows kernel syscall function NtCopyFileChunk.
NtCopyFileChunk allows two operations to be performed in a single syscall: reading the source file and writing to the destination file.
NT_COPYFILE_DATA_BUFFER is a structure that contains everything necessary for copying. Please note that this structure was obtained through reverse engineering, and its name was invented. Therefore, be aware of this.
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;
};
The function looks something like this in pseudocode:
// 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;
}
In [1], we can see StackEvent, which is our problem object. In [2], if the destination file was opened in synchronous mode, the kernel uses a stack event to wait synchronously for the write operation to the destination file. To do this, it uses IopWaitForSynchronousIoEvent (not shown in the pseudocode) on stack event rather than the event passed by the user. First, the kernel waits for the stack event, and only then updates the event passed by the user. Similarly, in [3], you can see that UserEvent of WriteIrp points to the stack event. However, what if we form the correct request but pass an incorrect input event? In [4], we can see how it references the input event, where we can pass an invalid handle (for example, the value 1). And then memory is cleared in [5]. This is where the most interesting thing happens.
Let's analyze the IopFreeCopyObjectsFromDataBuffer function and see what happens when irp is cleared. Let's take a closer look at 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);
[...]
}
Okay, the kernel performs ObfDereferenceObject on UserEvent. But what is wrong here? The thing is, as I have been emphasizing all this time, that this is a KEVENT located on the stack. It does not have an OBJECT_HEADER and, consequently, a reference counter, because its lifetime is limited by the stack frame. But the ZwCreateEvent function would allow you to create such a header for the event, because then the memory is allocated in the system pool, and it needs to be freed when the counter drops to zero. However, this is not used in our case. OBJECT_HEADER is always located before each object. This is why OOB write occurs, because ObfDereferenceObject refers to a negative offset 0x30 and decrements a fictitious reference count. This allows you to arbitrarily decrement something located in the local variables of the NtCopyFileChunk stack frame. It's completely random what local variables will be there. Moreover, if the counter drops to zero, the kernel will try to free the stack as if it were a system pool...
This is the most crucial question. It will enable us to understand how such an obvious vulnerability could have arisen in these circumstances.
Let's look at the exact same piece of code inside NtCopyFileChunk inside ntoskrnl in Windows 11 23h2. In the aforementioned pseudocode for NtCopyFileChunk for 24h2, the event is initialized at [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);
As we can see, in Windows 11 23h2/22h2, instead of a stack event, an event allocated in the pool via ZwCreateEvent and OBJECT_HEADER is used. Therefore, there is no vulnerability here. ObfDereferenceObject is a permissible operation on such an object. Incidentally, this is exactly how they patched this vulnerability in 24h2. The root of the problem lay in the desynchronization of code in different versions. Perhaps at some stage, engineers decided to save memory and define an event on the stack (this is standard practice). But they forgot that the IopFreeCopyObjectsFromDataBuffer cleanup function performs a reference count decrement, because if this did not happen, there would be a leak in 23h2. It is surprising that the NtCopyFileChunk code is not common to all versions of Windows 11. This highlights the complexity of kernel development and shows how easy it is to make critical mistakes.
According to Microsoft's own assessment, exploitation of this vulnerability is 'More Likely'. This statement initially piqued my interest. So let's find out the truth.
Let's pay attention to the stack frame on which OOB accesses occur.
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
And how ObfDereferenceObject accesses to our stack frame.
;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...
As you can see, there are too many ways to cause a BSOD here. We are also very lucky that DestOffset overlaps with _OBJECT_HEADER.PointerCount, which is part of the NtCopyFileChunk call (7th parameter). Thus, if we pass any positive DestOffset greater than 2, we simply decrement DestOffset, and this will not do anything there, we will calmly exit the function. But if we pass DestOffset = 1, then the kernel will try to free our stack event as a system pool.
Before the system pool is freed, a callback specific to the type of the object specified in _OBJECT_HEADER.TypeIndex is called, which overlaps with UserInputEventObject, which we also control, meaning that we can trick the system into accepting any object type we want. This opens up a whole range of possibilities. Our main goal is to hijack RIP before the free pool operation, for example, through object type confusion, to overwrite the callback somewhere.
But there is one very unpleasant thing that cancels our plans. As you can see above, there is a check for the presence of _OBJECT_HEADER.HandleCount, and if it is non-zero, the kernel calls BSOD. In our case, this overlaps with HandleInformation. And since this is not a user-controlled parameter, further research is needed on how to make it equal to zero.
//0x8 bytes (sizeof)
struct _OBJECT_HANDLE_INFORMATION
{
ULONG HandleAttributes; //0x0
ULONG GrantedAccess; //0x4
};
As it turns out, this structure belongs to DestHandle. This implies that we must open a handle on the destination with zero GrantedAccess. This also means that HANDLE_TABLE_ENTRY.GrantedAccessBits of our DestHandle must be zero. However, since we must also open it for writing (ObReferenceFileObjectForWrite), it cannot be zero in any case.
This leads us to a dead end.