[Research] 해키보이즈 막내들이 말아주는 Windows LPE 버그헌팅 체험기 Part 3 (KR)

Introduction

안녕하세요. 2025년이 끝난 지 얼마 지나지 않았다 생각했는데 벌써 1월이 거의 다 끝나가네요. 다들 새해 잘 보내셨나요?

이번에 첫 연구 글로 막내즈의 Part 3 글을 맡게 된 Libera입니다. 저는 Windows Zero-day 취약점”이라는 단어 하나에 강하게 이끌려 이번 프로젝트의 팀장으로 참여하게 되었습니다. 제로데이를 찾아내고 싶다는 그 열정이 저를 여기까지 이끌었는데요.

오늘 Part 2에서는 프로젝트 결과물 중 하나인 Kernel Driver 벡터를 중점으로, 제가 찾아낸 취약점들의 종류와 상세 내용을 소개하려 합니다. 그럼, 저희가 커널 드라이버에서 어떤 취약점들을 발견했는지 지금부터 본격적으로 시작해 볼까요?

Target & Summary

오늘 제가 소개해 드릴 취약점은 MSR R/W Primitive를 이용한 LPE와 Arbitrary Port I/O 취약점이 왜 DoS로 그쳤는가를 이야기해 볼까 합니다.

1. Attack Surface

취약점들을 보기 전에 먼저 확인해야 할 것들부터 이야기해 볼까요? Part 1과 Part 2에서 이야기했지만, 시간이 좀 걸렸으니 짧게 진행해 보겠습니다!

타겟 프로그램을 설치 했을 때 FolderChangesView를 통해 프로그램 설치 시 같이 설치되는 드라이버들을 확인하고, DriverView를 통해 프로그램 설치 혹은 실행 시 자동으로 로드되는 드라이버들을 확인합니다.

다음으로 드라이버의 권한을 확인해 봐야 하는데요. 권한을 확인하기 위해서는 DeviceName을 알아야 합니다. IDA로 드라이버 파일을 열고 DriverEntry를 찾아 DeviceName을 확인할 수 있었습니다. 그리고, 겸사겸사 MajorFunction[14]도 있는지 확인해 주면 좋습니다 MajorFunction[14]는 Windows 커널의 DRIVER_OBJECT 구조체의 IRP_MJ_DEVICE_CONTROL을 처리하는 함수 포인터를 가리킵니다. 즉, 유저모드 애플리케이션이 DeviceIoControl API를 호출했을 때 이를 처리하는 Dispatch Routine이 있는 걸 알 수 있습니다. 이제 다시 권한을 확인해 볼까요?

WinObj64.exe를 사용해서 권한을 확인해 보면 Everyone RW 권한이 있는 걸 확인할 수 있습니다! 즉, medium user가 이 드라이버에 DeviceIoControl 요청을 할 수 있다는 것이죠.

이렇게 권한과 MajorFunction[14]까지 확인했다면 본격적으로 취약점을 분석해 볼까요?

2. MSR R/W Primitive를 통한 LPE

Untrusted Pointer Dereference 취약점을 악용해 MSR R/W Primitive를 확인하고, 이를 통해 LPE를 달성했습니다. 벤더사에 제보하였으나 회신이 없어 부득이하게 특정 부분들은 마스킹 처리 후 기재할 테니 양해 부탁드립니다. 😭

2-1. Static Analysis

User가 DeviceIoControl API를 호출해 이 드라이버에 요청을 보내게 되면 트리거 되는 Dispatch Routine(MajorFunction[14])인 sub_1400010E0 함수를 확인합니다.

2-1-1. sub_1400010E0() 함수 - Dispatch Routine

__int64 __fastcall sub_1400010E0(__int64 a1, IRP *IRP)
{
...
  if ( MajorFunction != 14 )
    goto LABEL_54;
  IOCTL = CurrentStackLocation->Parameters.DeviceIoControl.IoControlCode;
  if ( IOCTL <= 0x9C000000 )
  {
		...
	}
  else
  {
    if ( IOCTL != 0x9C000000 )
    {
      if ( IOCTL == 0x9C------ )
      {
        // ReadMsr
        v5 = ((__int64 (__fastcall *)(PVOID, PVOID))sub_140001380)(
                   IRP->AssociatedIrp.SystemBuffer,
                   IRP->AssociatedIrp.SystemBuffer);
      }
      else if ( IOCTL == 0x9C====== )
      {
        //WriteMsr
        v5 = sub_1400013C0(IRP->AssociatedIrp.SystemBuffer, p_Information);
      }
      goto LABEL_54;
    }
    *(_DWORD *)IRP->AssociatedIrp.SystemBuffer = dword_140003000;
    *p_Information = 4;
  }
LABEL_53:
...
}

sub_1400010E0에는 여러 가지 조건문들이 있습니다. MajorFunction[14]일 때 트리거 되는 함수 이지만 내부에서 MajorFunction[i]의 몇 번 인덱스 인지에 따라 다시 한번 기능이 다르게 만들어져 있습니다.

DeviceIoControl API로 보낸 요청은 14 index로 분기하고, 이 index 내부에서 원하는 기능으로 분기하기 위해 IOCTL Code를 확인해야 합니다. 0x9C------0x9C====== 가 저희가 트리거 해야 할 취약점이 있는 IOCTL Code입니다.

I/O Control Codes (IOCTL Code)란
유저모드와 드라이버간의 통신 식별자로 사용되 같은 스택의 드라이버간에 통신을 위해서 사용됩니다. IOCTL Code는 IRP를 이용해서 전달되어 집니다.
유저모드 어플리케이션일 경우 DeviceIoControl API를 호출해 드라이버에 IOCTL Code를 전달합니다. 드라이버는 전달 받은 IOCTL Code에 따라 해당하는 기능을 수행합니다.
대부분의 드라이버는 조건문을 사용해 IOCTL Code에 따라 필요한 기능을 수행합니다.

2-1-2. sub_140001380() 함수 - ReadMsr Primitive

if ( IOCTL == 0x9C------ )
      {
        // ReadMsr
        v5 = ((__int64 (__fastcall *)(PVOID, PVOID))sub_140001380)(
                   IRP->AssociatedIrp.SystemBuffer,
                   IRP->AssociatedIrp.SystemBuffer);
      }

IOCTL Code 0x9C------를 전송하면 sub_140001380 함수가 트리거됩니다. 이때 인자로 전달되는 SystemBuffer는 유저모드에서 보낸 데이터가 저장된 커널 버퍼를 가리킵니다.


__int64 __fastcall sub_140001380(unsigned int *SystemBuffer, unsigned __int64 *SystemBuffer2, _DWORD *a3)
{
  unsigned __int64 v3; // rax

  v3 = __readmsr(*SystemBuffer);
  *SystemBuffer2 = ((unsigned __int64)HIDWORD(v3) << 32) | (unsigned int)v3;
  *a3 = 8;
  return 0;
}

sub_140001380함수를 보면 유저모드에서 보낸 데이터를 아무런 검증 없이 __readmsr함수의 인자로 사용함을 확인할 수 있습니다.

2-1-3. sub_1400013C0() 함수 - WriteMsr Primitive

else if ( IOCTL == 0x9C====== )
      {
        //WriteMsr
        v5 = sub_1400013C0(IRP->AssociatedIrp.SystemBuffer, p_Information);
      }

IOCTL Code 0x9C======를 전송하면 sub_1400013C0 함수가 트리거됩니다. 이때 인자로 전달되는 SystemBuffer 또한 유저모드에서 보낸 데이터가 저장된 커널 버퍼를 가리킵니다.

__int64 __fastcall sub_1400013C0(__int64 SystemBuffer, _DWORD *a2)
{
  __writemsr(*(_DWORD *)SystemBuffer, *(_QWORD *)(SystemBuffer + 4));
  *a2 = 0;
  return 0;
}

sub_1400013C0 함수를 보면 유저모드에서 보낸 데이터를 아무런 검증 없이 __writemsr함수의 인자로 사용함을 알 수 있습니다.

이렇게 2가지의 IOCTL Code를 통해 각 MSR R/W 함수에 원하는 값을 전달할 수 있음을 확인했습니다. 이제 이 MSR이 무엇인지 알아보고 왜 이 함수를 이용하면 Exploit이 가능한지 알아보죠!

2-2. How to Exploit using MSR Register

출처 : [IIia dafchev blog](https://idafchev.github.io/blog/)

출처 : IIia dafchev blog

MSR(Model Specific Register)은 특정 프로세서 모델에 특화된 기능을 제어하기 위해 도입되었습니다. 원칙적으로는 모델 간 호환성이 보장되지 않으나, 시스템 콜 핸들러 등 핵심적인 기능을 담당하는 일부 MSR은 아키텍처 MSR(Architectural MSRs)로 분류되어 지속적으로 지원되고 있습니다.

여기서 저희가 주목해야 하는 기능은 LSTAR(Long-mode System call Target Address Register) MSR입니다. 이 레지스터는 syscall 명령어 실행 시 CPU가 이동할 커널 레벨의 시스템 콜 핸들러(KiSystemCall64) 주소를 담고 있습니다. 운영체제는 시스템 호출 핸들러의 주소를 이 레지스터에 저장하고 syscall 명령어가 실행되면 CPU는 Ring0 모드(커널 모드)로 전환하고 명령어 포인터(RIP)는 LSTAR 레지스터에 저장된 값으로 설정됩니다.

Windows 10 중반까지는 KASLR이 있어도 NtQuerySystemInformation API 등을 이용해 커널 주소를 Leak 할 수 있었습니다. 하지만 이후에 일반 유저 권한으로는 더 이상 주소를 반환하지 않도록 패치되어 Exploit을 하기 위해 Leak Primitive가 필요해졌습니다. 이전에 팀원이 KASLR에 대해 작성한 글이 있으니 자세한 내용이 궁금하시면 이쪽에서 확인해 보세요!

IA32_LSTAR 레지스터에는 윈도우 커널이 부팅 시점에 시스템 콜을 처리하는 핵심 함수인 KiSystemCall64의 실제 주소를 넣어두고 있습니다. 따라서 __readmsr함수를 통해 IA32_LSTAR 레지스터를 읽어 KiSystemCall64의 실제 주소를 찾고, Offset을 통해 커널 베이스 주소를 알아낼 수 있습니다.

void __writemsr(
   unsigned long Register,  // [ECX] 선택할 MSR 레지스터
   unsigned __int64 Value   // [EDX:EAX] 선택한 레지스터에 쓸 값
);

__writemsr함수의 경우 첫 인자로 지정한 MSR 레지스터에 2번째 인자의 값을 Write하게 됩니다. 앞서 살펴보았듯 syscall 명령이 실행되면 LSTAR 레지스터에 저장된 값으로 RIP가 설정됩니다. 따라서, LSTAR 레지스터를 쉘 코드의 시작 주소로 덮어쓰게 되면 syscall 호출 시 공격자의 쉘 코드가 Ring0(커널 모드) 권한으로 실행 됩니다.

이제 어떻게 Exploit을 할지 정해졌으니 실제로 내가 입력한 값들이 제대로 들어가는지 확인해 볼까요?

2-3. Dynamic Analysis

__readmsr 함수를 먼저 보면 인자로 IA32_LSTAR 를 의미하는 c0000082 가 들어간 것을 확인할 수 있습니다.

__writemsr 함수의 인자로 첫 인자로 IA32_LSTAR 가, 2번째 인자로는 제가 임의로 넣은 FFFF값이 들어간 것을 확인할 수 있습니다.

이제 임의로 넣은 이 값을 쉘 코드의 시작 주소로 넣어주면 Exploit에 성공하게 됩니다!!

2-4. 주의할 점

LSTAR 레지스터에 쉘 코드 시작 주소를 넣는 것으로 Exploit이 되면 참 좋겠지만 성공적인 Exploit을 위해서 저희가 해줘야 하는 추가 작업이 총 3가지가 더 있습니다. 여기서 나온 보호 기법의 자세한 내용이 궁금하다면 L0ch의 Kernel Mitigation 글을 읽어 보시면 좋을 거 같습니다!

첫째, SMEP 우회입니다. 최신 윈도우는 SMEP/SMAP 보호 기법이 적용되어 있어 유저 영역의 쉘 코드를 바로 실행할 수 없습니다. 위에서 설명한 대로 LSTAR를 Shell code 시작 주소로 덮어쓴다고 해서 바로 실행되지 않습니다. LSTAR를 Stack Pivot 가젯으로 덮어써서 실행 흐름을 가져온 뒤, ROP Chain을 통해 CR4 레지스터의 20번째 비트(SMEP 비트)를 비활성화해야 합니다.

둘째, 권한 상승입니다. MSR Read Primitive로 얻은 커널 주소를 기반으로 오프셋을 계산해 커널 베이스 주소와 System 프로세스의 토큰을 찾습니다. 이후 현재 프로세스의 토큰 위치에 System 프로세스의 토큰을 덮어써서 System 권한을 얻습니다.

마지막 셋째, 시스템 복원(Cleanup)입니다. 변경된 LSTAR값과 CR4의 SMEP 비트 설정, 그리고 망가진 스택 프레임을 복구해야 합니다. 복구 하지 않고 종료된다면 다음 시스템 콜이 호출되는 순간 BSOD가 발생합니다. 그래서 변경했던 레지스터들을 반드시 복원해줘야 안정적인 LPE(권한상승)을 달성할 수 있습니다!

3. DoS 취약점

BSOD가 발생하는 취약점은 여러개를 찾았는데요. 그 중 Arbitrary Port I/O 취약점에 대해서 어떤 결과가 발생하고, 왜 LPE까지는 불가능 했는지를 알아보겠습니다.

3-1. Arbitrary Port I/O 취약점 Trigger & Root Cause

switch ( IoControlCode )
{
  case 0xC350A0D8:
    __outbyte(SystemBuffer_Value, *((_BYTE *)SystemBuffer_Addr + 4));
    return 0;
  case 0xC350A0DC:
    __outword(SystemBuffer_Value, *((_WORD *)SystemBuffer_Addr + 2));
    return 0;
  case 0xC350A0E0:
    __outdword(SystemBuffer_Value, SystemBuffer_Addr[1]);
    return 0;
}

위 코드는 드라이버의 Dispatch Routine 함수의 일부분 입니다. 특정 IOCTL Code로 트리거를 할 수 있고, 각각 __outbyte, __outword, __outdword 함수를 트리거 합니다. 3가지 함수의 인자 모두 공격자가 제어할 수 있게 되어 있어 인자를 제어하면 BSOD가 발생합니다.

0xCF9 Port에 0xFE 데이터를 전송하게 되면 강제 재부팅이 발생합니다. 그리고 이 포트에 0xFE가 아닌 이상한 데이터를 전송하게 되면 BSOD가 발생합니다. 0xCF9 포트는 인텔 칩셋 스펙에 정의된 Reset Control Register이고, 0xFE는 2진수로 1111 1110입니다. 인텔 데이터 시트를 확인해 보면 이 레지스터의 비트 동작을 알 수 있습니다.

  • Bit 1 (System Reset) : 1로 설정 시 시스템 리셋을 트리거 합니다.
  • Bit 2 (Reset CPU) : 1로 설정시 CPU 리셋을 수행합니다.

즉, 0xFE를 전송하면 이 2 비트를 모두 활성화 시키는 명령이 전송되고 OS의 정상적이 종료 절차를 무시하고 즉시 강제 재부팅이 진행됩니다.

이번에는 단순히 읽고 쓰는 함수인 __outbyte 함수 등을 사용했을 때 왜 BSOD가 발생하는지 알아볼까요?

3-1-1. in, out 함수

__inbyte, __inword, __indword, __outbyte, __outword, __outdword 함수들은 운영체제의 API를 거치는게 아니라 기계어로 바로 직역 됩니다. in함수의 경우 인자로 받은 포트에서 함수에 맞는 크기만큼 데이터를 읽습니다. DX 레지스터에 포트번호를 넣고 IN명령어로 읽어 AX에 저장합니다.

out함수는 인자로 받은 포트에 인자로 받은 데이터를 씁니다. 포트 번호와 데이터를 레지스터에 넣고 OUT 명령어로 전송해 지정된 포트번호에 데이터를 씁니다.

3-1-2. Isolated I/O와 BSOD 원인

x86 아키텍처는 메모리 주소 공간(Memory Space)과 I/O 주소공간(I/O Space)이 물리적으로 분리되어 있고 이를 Isolated I/O 라고 합니다. Memory Map은 우리가 흔히 생각하는 Ram공간으로 MOV 명령어로 접근합니다. I/O Map은 IN,OUT 함수가 사용하는 공간입니다. 0x0000~0xFFFF범위인데 이 공간은 메인보드에 연결된 하드웨어 장치의 레지스터와 직결됩니다. 그래서 __outword(0x64, data)를 실행하면 메모리 100번지에 data를 쓰는게 아닌 0x64 포트를 가진 하드웨어 칩에게 전기 신호를 보내는 것 입니다.

문제는 이 신호가 중요하거나 민감한 하드웨어(Ex. PCI 버스 설정 등)로 보내질 때 발생합니다. 공격자가 임의의 값을 중요한 포트에 덮어쓰면, 하드웨어 설정이 꼬이거나 CPU가 처리하지 못하는 예외가 발생해 OS가 시스템 보호를 위해 블루스크린을 띄우게 됩니다.

3-2. 왜 LPE가 안되는가?

과거에는 PCI Configuration Space의 BAR(Base Address Register)를 조작해 장치의 MMIO(Memory Mapped I/O)주소를 커널의 중요 데이터 구조체 가 위치한 물리 주소로 겹치게 만드는 방식으로 Exploit을 했습니다. 하지만 이를 막기 위한 보호 기법들이 적용되면서 이런 접근이 차단 되었습니다.

3-2-1. OS 메모리 관리자 및 하드웨어 상태의 비동기화

근본적으로 막히게 된 이유는 하드웨어 레벨의 주소 변경이 OS의 메모리 관리 시스템에 즉시 반영되지 않게 되었기 때문입니다.

공격자가 OUT 명령어로 PCI 장치의 BAR 레지스터값을 변경해 물리주소 0xA0000000에서 커널 중요 구조체가 있는 0x10000000로 변경했다고 가정해 봅시다. 윈도우 커널은 부팅 시점이나 드라이버 로드 시점에 이미 해당 장치의 물리 주소에 대한 PTE를 생성하고 가상 주소를 할당합니다. 공격자가 하드웨어 레지스터(BAR)를 변경해도 OS의 메모리 관리자(MMU)가 관리하는 페이지 테이블의 매핑 정보가 변경되거나 수정되지 않기 때문에 문제가 발생합니다.

드라이버가 기존 가상 주소로 접근하면 메모리 관리자(MMU)는 여전히 0xA0000000인 원래 주소로 트랜잭션을 보내지만 장치는 공격자가 0x10000000으로 변경했기에 원래 주소로 보낸 트랜잭션에 아무 장치도 응답하지 않고 결국 Master Abort가 발생하거나 쓰레기 값을 읽게 되어 BSOD가 발생하게 됩니다.

3-2-2. VBS와 SLAT에 의한 물리 메모리 격리

VBS 환경에서는 하이퍼바이저가 물리 메모리 접근을 통제합니다. VBS가 활성화되면 OS가 접근하는 물리 주소는 실제 하드웨어의 기계 물리 주소와 다릅니다. 이 변환을 CPU의 하드웨어 가상화 기능인 SLAT이 담당하게 됩니다.

공격자가 BAR을 조작해 커널 코드 영역이나 중요 데이터 영역으로 장치 메모리를 매핑하려고 하면 하이퍼바이저는 EPT 위반을 감지합니다. 하이퍼바이저는 MMIO로 설정된 물리 페이지가 실행 가능한 코드 영역이나 읽기 전용 데이터 영역과 겹치는 것을 허용하지 않고 이를 감시합니다.

즉, 하드웨어 레지스터를 조작해도 하이버파이저 계층에서 접근권한을 거부해 애초에 물리 메모리 덮어 쓰기가 불가능하게 되었습니다.

3-2-3. DMA Protection 및 IOMMU

공격자가 CPU가 아닌 DMA를 이용해 메모리를 덮어 쓰려 할 때 작동하는 방어 기법입니다. IOMMU는 디바이스가 접근하는 메모리 주소를 가상화 합니다. 물리주소에 Write을 해달라고 요청을 해도 IOMMU가 해당 장치는 이 영역에만 Write을 할 수 있다 라는 식으로 주소를 재매핑하거나 차단을 해버립니다. 이것을 DMA Remapping이라고 합니다.

또, 윈도우는 부팅 과정에서 신뢰할 수 없는 외부 장치나 초기화 되지 않은 장치의 DMA 포트를 닫아버립니다. 드라이버가 정상적으로 로드되고 IOMMU 매핑이 완료 되기 전까지는 해당 장치가 시스템 메모리에 접근할 수 없습니다. 이것은 Pre-boot DMA Protection이라고 합니다.

마치며

이번 Part3를 마지막으로 화이트햇 스쿨 3기에서 진행한 “Windows Kernel Driver & Named Pipe LPE 버그헌팅” 프로젝트에 대해 소개해 보았습니다. 총 6명의 인원으로 3개월 동안 열심히 프로젝트를 진행한 결과를 이렇게 공유하면서 마무리 할 수 있어서 뿌듯하고 기쁘네요. ☺️

이번 시리즈가 취약점 분석을 시작하는 분들께 도움이 되었으면 하며 이번 시리즈는 이렇게 마무리 해보도록 하겠습니다! 다음 연구글로 어떤 내용을 쓸지 아직은 모르지만 관심있게 지켜봐 주시면 감사하겠습니다! 감사합니다.

Reference



본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.