此仓库包含一个POC,执行时会触发栈上越界写入,导致系统崩溃。该漏洞是一个非常有趣且罕见的遗物,展示了内核编程(尤其是对象管理)的复杂性和特殊性。此外,该漏洞影响Windows 11 24h2、Windows 10 22h2/21h2的内核,但不影响Windows 11 22h2/23h2,这更增添了其趣味性。
于2024年11月12日修补
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]中,可以看到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始终位于每个对象之前。这就是为什么会发生越界写入,因为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所有版本通用。这凸显了内核开发的复杂性,并展示了犯下关键错误是何等容易。
根据微软自身的评估,利用此漏洞的可能性为“较可能”。这一描述最初激起了我的兴趣。那么,让我们找出真相。
让我们注意发生越界访问的栈帧。
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...