
NtCopyFileChunk 스택 버퍼 오버플로우 POC
이 저장소에는 실행 시 스택 OOB 쓰기를 트리거하여 시스템을 충돌시키는 POC가 포함되어 있습니다. 이 취약점은 커널 프로그래밍, 특히 객체 관리의 복잡성과 특이성을 보여주는 매우 흥미롭고 드문 유물입니다. 또한 이 취약점은 Windows 11 24h2, Windows 10 22h2/21h2의 커널에 영향을 미치지만 Windows 11 22h2/23h2에는 영향을 미치지 않으며, 이는 더욱 흥미를 더합니다.
2024년 11월 12일에 패치됨
Windows 커널 시스템 콜 함수 NtCopyFileChunk에 스택 기반 버퍼 오버플로 취약점(더 기술적으로는 OOB 쓰기)이 존재합니다.
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;
};
함수는 의사 코드에서 다음과 같습니다:
// win11 24h2의 nt!NtCopyFileChunk에 대한 의사 코드
__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); // NT_COPYFILE_DATA_BUFFER 이후를 가리킴
// 핸들로 소스 파일 참조
ret = IopReferenceFileObject(SourceHandle, 1u, PreviousMode, (PVOID*)&DataBuffer_2->SourceFileObject, 0);
if (ret < 0)
goto RET;
// 핸들로 대상 파일 참조
ret = ObReferenceFileObjectForWrite(
(ULONG_PTR)DestHandle,
PreviousMode,
(_FILE_OBJECT*)&DataBuffer->DestFileObject,
(_OBJECT_HANDLE_INFORMATION*)&HandleInformation);
[...]
// 대상 파일에 쓸 데이터로 ArbDataBuffer를 채움
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가 스택 이벤트에 대한 포인터를 포함!
DataBuffer->WriteIrp->Flags |= IRP_MJ_WRITE;
}
else
{
//비동기 모드의 경우에는 필요하지 않음
[...]
}
UserInputEventObject = 0;
// [4]
ret = ObReferenceObjectByHandle(
UserInputHandleEvent,
2u,
(POBJECT_TYPE)ExEventObjectType,
PreviousMode,
&UserInputEventObject,
0);
if (ret >= 0)
{
//사용자가 올바른 이벤트를 제출한 경우, 한 파일을 다른 파일로 복사하는 주요 로직으로 진행합니다.
//단순화를 위해 해당 코드 부분은 생략했습니다.
KeResetEvent((PRKEVENT)UserInputEventObject);
goto NEXT_PATH_TO_READ_FILE_QUERY;
}
RET:
//여기입니다! DataBuffer 구조체를 해제합니다 (스택 이벤트에 대한 포인터를 포함하는 WriteIrp를 기억하세요).
// [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);
//더 깊이 들어가며 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);
}
//오, 이거구나! 그런데 OBJECT_HEADER가 없는 스택 객체의 참조 카운터를 어떻게 감소시킬 수 있을까?
//취약점!
if ( UserEvent )
ObfDereferenceObject(UserEvent);
if ( P )
ExFreePoolWithTag(P, 0);
[...]
}
자, 커널은 UserEvent에 대해 ObfDereferenceObject를 수행합니다. 그런데 무엇이 문제일까요? 문제는 제가 계속 강조해 온 것처럼 이것이 스택에 위치한 KEVENT라는 점입니다. 이것은 OBJECT_HEADER가 없으므로 참조 카운터도 없습니다. 그 이유는 수명이 스택 프레임으로 제한되기 때문입니다. 그러나 ZwCreateEvent 함수를 사용하면 이벤트에 대한 헤더를 생성할 수 있습니다. 그러면 메모리가 시스템 풀에 할당되고 카운터가 0이 될 때 해제되어야 합니다. 그러나 우리의 경우에는 이것이 사용되지 않습니다. OBJECT_HEADER는 항상 각 객체 앞에 위치합니다. 이것이 OOB 쓰기가 발생하는 이유입니다. ObfDereferenceObject는 음수 오프셋 0x30을 참조하여 가상의 참조 카운터를 감소시키기 때문입니다. 이를 통해 NtCopyFileChunk 스택 프레임의 지역 변수에 있는 임의의 값을 감소시킬 수 있습니다. 어떤 지역 변수가 있을지는 완전히 무작위입니다. 더욱이 카운터가 0이 되면 커널은 스택을 시스템 풀인 것처럼 해제하려고 시도합니다...
이것이 가장 중요한 질문입니다. 이러한 상황에서 어떻게 이렇게 명백한 취약점이 발생할 수 있었는지 이해할 수 있게 해줍니다.
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;
//스택 이벤트조차 아닙니다! OBJECT_HEADER가 있으며 참조 카운터를 저장합니다.
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; // 취약점 이벤트
그리고 ObfDereferenceObject가 우리의 스택 프레임에 어떻게 액세스하는지 살펴봅시다.
;rcx = StackEvent에 대한 포인터
ObfDereferenceObject proc near
...
;rdi = OBJECT_HEADER의 시작,
;하지만 우리의 경우에는 DestOffset(사용자 제어)에 대한 포인터입니다.
lea rdi, [rcx-30h]
...
mov rbx, -1
lock xadd [rdi], rbx ; DestOffset 감소
sub rbx, 1
jg short RET ;refcnt >= 2이면 객체를 삭제하지 않고 종료
;객체를 해제하는 불가피한 경로, 시스템이 충돌합니다...
mov rcx, [rdi+8] ;rcx = HandleInformation(_OBJECT_HANDLE_INFORMATION 구조체)
test rcx, rcx
jnz short BSOD ; HandleInformation이 0이어야 하며, 그렇지 않으면 BSOD 발생
test rbx, rbx ;음수 참조 카운터 확인
js short BSOD
...추가 코드...
보시다시피 BSOD를 유발하는 방법이 너무 많습니다. 또한 DestOffset이 NtCopyFileChunk 호출의 7번째 매개변수인 _OBJECT_HEADER.PointerCount와 겹친다는 점은 매우 운이 좋습니다. 따라서 양의 DestOffset을 2보다 큰 값으로 전달하면 DestOffset만 감소될 뿐 아무 일도 일어나지 않고 함수를 정상적으로 종료합니다. 그러나 DestOffset = 1을 전달하면 커널이 스택 이벤트를 시스템 풀로 해제하려고 시도합니다.
시스템 풀이 해제되기 전에 _OBJECT_HEADER.TypeIndex에 지정된 객체 유형에 특화된 콜백이 호출됩니다. 이는 우리가 제어하는 UserInputEventObject와 겹치므로 시스템이 우리가 원하는 모든 객체 유형을 수용하도록 속일 수 있습니다. 이는 다양한 가능성을 열어줍니다. 우리의 주요 목표는 객체 유형 혼동을 통해 free pool 작업 전에 RIP를 가로채거나 어딘가에서 콜백을 덮어쓰는 것입니다.
하지만 우리의 계획을 무효화하는 매우 불쾌한 점이 하나 있습니다. 위에서 볼 수 있듯이 _OBJECT_HEADER.HandleCount의 존재 여부를 확인하며, 0이 아니면 커널이 BSOD를 호출합니다. 우리의 경우 이것은 HandleInformation과 겹칩니다. 그리고 이것은 사용자 제어 매개변수가 아니므로, 이를 0으로 만드는 방법에 대한 추가 연구가 필요합니다.
//0x8 바이트 (sizeof)
struct _OBJECT_HANDLE_INFORMATION
{
ULONG HandleAttributes; //0x0
ULONG GrantedAccess; //0x4
};
알고 보니 이 구조체는 DestHandle에 속합니다. 즉, GrantedAccess가 0인 대상에 대한 핸들을 열어야 합니다. 또한 HANDLE_TABLE_ENTRY.GrantedAccessBits가 0이어야 함을 의미합니다. 그러나 쓰기 위해 열어야 하므로(ObReferenceFileObjectForWrite) 어떤 경우에도 0이 될 수 없습니다.
이는 막다른 골목으로 이어집니다.