[도닦기] 7단계: Lock the Pages, Break the Kernel (KR)

안녕하세요. Hackyboiz의 banda입니다.

지난 연구글

https://hackyboiz.github.io/2026/06/06/OUYA77/Wipeload_step1/kr/

https://hackyboiz.github.io/2026/06/21/ji9umi/Wipeload_step2/kr/

https://hackyboiz.github.io/2026/06/26/ji9umi/Wipeload_step3/kr/

https://hackyboiz.github.io/2026/07/13/OUYA77/Wipeload_step4/kr/

https://hackyboiz.github.io/2026/07/19/gongjae/Wipeload_step5/KR/

https://hackyboiz.github.io/2026/08/01/gongjae/Wipeload_step6/KR/

앞서 마스터 시푸 사부님들의 큰 활약으로 WASM 트릭을 이용해 V8 Sandbox를 벗어나 native code를 실행한 뒤, ALPC UAF를 통해 Chrome Sandbox까지 탈출하면서, Medium Integrity 권한의 코드를 실행할 수 있게 되는데요. Chaining 관점에서 마지막 경계는 Medium IL 실행 컨텍스트로 옮겨진 뒤에도 남아 있는, UserMode와 KernelMode 사이의 간극일 겁니다.

7단계와 8단계에서는 Chaining의 마지막 관문인 Kernel EoP를 완성해보겠습니다. 이번 7단계에서는 CVE-2023-29360의 핵심이 되는 MDL 자료구조와 동작을 살펴보고, 이어지는 8단계에서는 이를 이용해 실제 Kernel EoP를 완성해볼 예정이니 기대해주세요! 과연 그의 제자인 포는 사부님의 뒤를 이어 SYSTEM 권한에 도달할 수 있을지.. 함께 확인해보도록 하시죠.

1. Memory Descriptor List (MDL) Overview

MDL은 커널 상에서 어떤 가상 주소(VA) 버퍼가 실제 어떤 물리 페이지(Physical pages)로 구성되어 있는지 정보를 담는 커널 구조체입니다.

일반적으로 커널모드 드라이버는 유저모드 메모리에 직접 접근이 어려운데요. 유저모드 버퍼 주소는 단순한 포인터 값만으로 안전하게 보관하고 사용할 수 없기 때문입니다. 커널 드라이버가 유저모드 버퍼에 접근하면 아래와 같은 문제가 생깁니다.

  1. 유저모드 가상 주소는 프로세스마다 다르게 해석되는데, 드라이버가 현재 프로세스의 유저 버퍼 포인터 값만 저장해두었다가 나중에 다른 프로세스 컨텍스트에서 접근하면 잘못된 프로세스 주소 공간을 해석하거나 잘못된 메모리를 참조할 수 있습니다.
  2. 유저 페이지는 항상 RAM에 고정되어 있지 않고 OS에 의해 디스크로 내려갈 수 있는데, 드라이버단에서 유저 버퍼 포인터만 저장해두고 접근하면 페이지가 메모리에 없어 page fault가 발생할 수 있습니다.

따라서 드라이버가 유저 버퍼를 처리하는 방법은 크게 두 가지로 볼 수 있습니다. 하나는 유저모드 버퍼 내용을 커널 버퍼에 복사해서 사용하는 방식이고, 다른 하나는 바로 지금부터 살펴볼 MDL 방식입니다.


1.1 일반적인 경우(MDL을 사용하지 않는 경우)

일반적인 경우, 즉 MDL을 직접 사용하지 않는 경우에는 유저 버퍼를 커널 버퍼로 복사해서 처리합니다. IOCTL에서는 보통 METHOD_BUFFERED를 사용하여 I/O Manager가 SystemBuffer라는 커널 버퍼를 만들고 데이터를 복사해줍니다.

ProbeForRead(userBuf, size, 1);
RtlCopyMemory(kernelBuf, userBuf, size);

또는 METHOD_NEITHER처럼 유저 포인터가 직접 전달되는 경우, 드라이버가 ProbeForReadRtlCopyMemory를 사용해 유저 주소를 검증한 뒤 커널 버퍼로 복사할 수 있습니다. 이 방식들은 유저 버퍼를 계속 직접 참조하는 것이 아니라, 필요한 데이터를 커널 메모리로 가져온 뒤 처리됩니다.


1.2 MDL을 사용하는 경우

MDL의 경우, 앞의 사례와 다르게 유저 버퍼 내용을 커널 버퍼로 새로 복사하는게 아니라, 유저 버퍼가 실제로 매핑된 RAM 물리 페이지를 드라이버가 직접 사용할 수 있게 만들어 복사를 피합니다.

User VA: 0x000001A2F0001000
-> Physical pages: PFN 1234, PFN 1235, PFN 1236

위의 코드로 살펴보면 유저 프로그램을 통해 malloc, VirtualAlloc 등으로 버퍼가 만들어지면 내부적으로는 여러 물리 메모리 페이지와 연결됩니다. MDL은 이 유저 버퍼가 PFN 1234, PFN 1235, PFN 1236 페이지로 구성되어 있다는 정보와 , 버퍼의 시작 주소 및 크기를 기록하는 구조체입니다.

즉, MDL은 유저 버퍼의 내용을 복사해 보관하는 구조가 아니라, 해당 버퍼를 구성하는 물리 페이지를 설명하기 위한 메타데이터에 해당합니다. 이후 드라이버는 이 MDL을 바탕으로 페이지를 Lock하고, 필요한 경우 커널 주소 공간에 다시 매핑해 사용할 수 있습니다.


1.3 MDL 생성 흐름

  1. 유저모드 버퍼 할당
BYTE *buf = VirtualAlloc(NULL, 0x1000, MEM_COMMIT, PAGE_READWRITE);

MDL 생성 흐름을 살펴보면, 먼저 위와 같이 유저모드 프로그램이 VirtualAlloc을 통해 드라이버에 전달할 버퍼를 가상 주소 공간에 할당했다고 해봅시다. buf에는 유저모드 VA가 들어가겠죠?

  1. MDL 생성: IoAllocateMdl
PMDL IoAllocateMdl(
  [in, optional]      __drv_aliasesMem PVOID VirtualAddress,
  [in]                ULONG                  Length,
  [in]                BOOLEAN                SecondaryBuffer,
  [in]                BOOLEAN                ChargeQuota,
  [in, out, optional] PIRP                   Irp
);

PMDL mdl = IoAllocateMdl(userBuffer, bufferSize, FALSE, FALSE, NULL);

이후 IoAllocateMdl()는 특정 가상 주소 범위에 대한 메타데이터와 PFN 배열 공간을 담은 MDL 커널 구조체를 만드는 과정이 수행됩니다. 특히 IoAllocateMdl() 수행 직후에는 PFN 배열이 아직 채워지지 않은 PFN 배열 공간만 생성이 된 상태입니다.

typedef struct _MDL {
  struct _MDL      *Next;
  CSHORT           Size;
  CSHORT           MdlFlags;
  struct _EPROCESS *Process;
  PVOID            MappedSystemVa;
  PVOID            StartVa;
  ULONG            ByteCount;
  ULONG            ByteOffset;
} MDL, *PMDL;

IoAllocateMdl()로 생성된 MDL 구조체에는 버퍼 범위를 설명하기 위한 주요 필드들이 포함됩니다. StartVa는 버퍼가 포함된 첫 번째 페이지의 시작 가상 주소를, ByteOffset은 버퍼 시작 위치의 페이지 내부 오프셋을, ByteCount는 버퍼 길이를 나타냅니다. MdlFlags는 페이지 잠금 여부나 시스템 주소 매핑 여부 등 MDL의 현재 상태를 나타내는 플래그 필드입니다.

  1. 물리 페이지 고정: MmProbeAndLockPages
void MmProbeAndLockPages(
  [in, out] PMDL            MemoryDescriptorList,
  [in]      KPROCESSOR_MODE AccessMode,
  [in]      LOCK_OPERATION  Operation
);

MmProbeAndLockPages(mdl, UserMode, IoReadAccess);

IoAllocateMdl()을 통해 MDL 구조체를 생성한 뒤에는 일반적으로 MmProbeAndLockPages를 통해서 물리페이지를 고정하는 과정이 이어집니다. 유저모드 버퍼는 언제든 페이지 아웃될 수 있기 때문에, 커널이 접근하기 전 반드시 해당 페이지를 물리 메모리에 고정하는 과정이 필요합니다. 위 Locking 과정을 통해 MDL이 가리키는 모든 페이지가 RAM에 상주하게 되고, MDL 내부에 PFN 배열이 채워지며, 이후 드라이버는 이 MDL을 기반으로 안전하게 메모리에 접근할 수 있게 됩니다.

__try {
    MmProbeAndLockPages(
        mdl,
        UserMode,
        IoReadAccess
    );
}
__except (EXCEPTION_EXECUTE_HANDLER) {
    status = GetExceptionCode();
}

그런데 유저가 전달한 주소를 그대로 믿고 Page를 Lock하면 안되겠죠? 일반적인 드라이버는 잘못된 주소나 권한이 맞지 않는 버퍼를 처리하는 과정에서 예외가 발생할 수 있으므로, 위 코드처럼 MmProbeAndLockPage() 호출을 예외 처리 범위 안에서 수행해야 하는데요.

MmProbeAndLockPages()는 전달된 가상 주소가 실제로 접근 가능한지와 Operation에 맞는 읽기·쓰기 권한이 있는지를 검사합니다. 검증에 성공하면 해당 주소 범위의 Physical Page를 Lock하고, MDL의 PFN 배열을 실제 페이지 번호로 채웁니다.

즉, 이 함수는 Page를 RAM에 고정하는 역할 뿐 아니라, 유저가 전달한 주소가 MDL 처리 대상으로 적절한지 확인하는 중요한 검증 지점입니다.

  1. 커널 VA 재매핑: MmMapLockedPagesSpecifyCache
PVOID MmMapLockedPagesSpecifyCache(
    PMDL MemoryDescriptorList,
    KPROCESSOR_MODE AccessMode,
    MEMORY_CACHING_TYPE CacheType,
    PVOID RequestedAddress,
    ULONG BugCheckOnFailure,
    ULONG Priority
);

그렇게 앞서 MmProbeAndLockPages()까지 수행하면, MDL 안에는 유저 버퍼가 실제로 어떤 물리 페이지들로 구성되어있는지 기록되고, RAM에서 빠지지 않도록 페이지들이 lock됩니다. 이후 MmMapLockedPagesSpecifyCache를 통해 MDL에 기록된 locked physical page들을 새로운 가상 주소에 매핑합니다. 여기서는 AccessModeKernelMode인 경우를 기준으로, MDL의 PFN 배열이 가리키는 Physical Page들을 커널 주소 공간에 Kernel VA로 다시 매핑하는 흐름을 살펴보겠습니다.

중요한 점은 이 과정에서 유저 버퍼 전체를 별도의 커널 버퍼로 미리 복사하지 않는다는 점입니다. 드라이버는 기존 유저 버퍼가 사용하던 동일한 Physical Page를 가리키는 Kernel VA를 얻고, 이를 통해 원래 버퍼의 내용을 참조할 수 있습니다.

예를 들어 드라이버가 해당 내용을 다른 버퍼나 장치 I/O 영역으로 옮겨야 한다면, 아래와 같이 매핑된 Kernel VA를 RtlCopyMemory()의 Source로 사용할 수 있습니다.

RtlCopyMemory(dst, kernelVa, length);

위와 같이 드라이버는 매핑된 KernelVa를 Source로 사용하여, 장치 I/O 처리에 필요한 데이터를 다른 버퍼로 복사할 수 있습니다.

  1. MDL 매핑 해제: MmUnmapLockedPages
MmUnmapLockedPages(kernelVa, mdl);
MmUnlockPages(mdl);
IoFreeMdl(mdl);

위 과정을 통해 MDL을 생성하고 사용한 뒤에는 반드시 lock을 해제하고 free하는 과정이 필요하며, 역순인 위의 과정으로 해제해야 합니다. 먼저 MmUnmapLockedPages(kernelVA, mdl)을 통해 MmMapLockedPagesSpecifyCache()로 만든 커널 가상 주소 매핑을 제거합니다. 이후 MmUnlockPages(mdl)을 통해 MmProbeAndLockPages()로 고정했던 물리 페이지들의 lock을 해제합니다. 마지막으로 IoFreeMdl(mdl)을 통해 IoAllocateMdl()로 할당했던 MDL 구조체를 해제하고, MDL에 들어있던 메타데이터와 PFN 배열 공간을 반환합니다.

즉, 정리하면 MDL의 정리 과정은 매핑 해제 → Page Unlock → MDL 해제 순서로 진행됩니다. 매핑이 남아 있는 상태에서 MDL을 먼저 해제하면 Kernel VA가 이미 반환된 MDL의 PFN 정보를 계속 참조할 수 있고, Page Lock이 남아 있으면 해당 Physical Page가 불필요하게 RAM에 고정됩니다.

2. MDL ABUSE CASE STUDY

앞서 살펴본 것처럼 드라이버는 MDL을 통해 유저 버퍼를 구성하는 Physical Page를 Lock하고, 필요에 따라 그 Page들을 새로운 가상 주소에 매핑할 수 있습니다! 특히 Locked MDL을 유저모드에 매핑하면, 유저 프로세스 주소 공간에는 기존 버퍼와 같은 Physical Page를 가리키는 새로운 User VA가 만들어집니다.

지금까지 MDL이 생성되고 매핑된 뒤 해제되는 라이프타임을 살펴보셨는데요, 글을 읽어보시고 뭔가 이 과정에서 잘못 관리되면 취약점이 발생할 만한 지점이 보이지 않으시나요..? 맞습니다. MDL은 유저가 전달한 버퍼를 기반으로 만들어지고, Physical Page를 Lock한 뒤 서로 다른 가상 주소 공간에 매핑됩니다. 따라서 생성한 MDL이 어떤 Frame과 연결되는지, 매핑된 주소와 MDL을 언제 해제하는지, Lock되지 않은 MDL이 매핑 경로에 들어갈 수 있는지를 잘못 처리한다면 강력한 메모리 손상 primitive로 이어질 수 있습니다.

타이렁과 뜨거운 한판 승부를 벌이기 전 마지막 단련을 위해 동료들이 도착했습니다. MDL 관리가 잘못 될 때 어떤 형태의 취약점으로 이어질 수 있을지.. 한번 확인해보시죠!

이제 앞서 살펴본 MDL 개념을 바탕으로, 제가 과거에 발견한 Case 1 Lifetime Bug MDL 취약점과 과거에 나타났던 MDL 관련 CVE를 함께 살펴보며 MDL Abuse가 어떤 형태로 나타나는지 알아보겠습니다! Case 2의 CVE-2024-38237, CVE-2025-21375의 경우 Frame Header와 MDL의 연결이 어긋나는 MDL Mismatch 사례입니다. Case 3의 CVE-2024-38238은 MDL을 Lock하지 않은 상태로 매핑 경로에 전달하는 Forgotten Lock 사례입니다. 해당 Case 2와 Case 3의 경우 MDL 취약점 유형을 분류하기 위해 DEVCORE의 연구글 사례를 참고하였습니다.


2.1 CASE: MDL Lifetime Bug Pattern

먼저 첫 번째 취약점 유형은 MDL 객체의 Lifetime이 동기화 없이 처리되어 나타나는 Double Free 취약점이며, 넓은 범주에서 보면 MDL이 해제되고 재사용되는 과정에서 나타날 수 있는 race condition 계열의 취약점입니다.

취약한 드라이버의 MDL Register/Unregister Flow를 확인해보면, Register 요청에서는 사용자에게 매핑된 VA와 그 VA를 설명하는 MDL pointer를 내부 slot table에 저장합니다. 이후 Unregister 요청에서는 사용자가 다시 전달한 VA를 기준으로 slot을 찾아 해당 MDL을 Free하는 흐름으로 동작하고 있었습니다.

따라서 정상적인 흐름에서는 하나의 mapped VA에 대해서 unregister가 한 번만 수행되어야 했습니다. 즉, 특정 slot이 unregister 대상으로 선택되면 해당 slot은 더이상 유효하지 않도록 표시되어야 하며, 이후 같은 mapped VA로 다시 unregister 요청이 들어오더라도 같은 MDL point가 다시 Free되어서는 안됩니다.

특히 이 slot을 무효화하는 시점과 MDL을 Free 하는 과정 사이에서 동기화가 없다면, 두 요청이 동시에 같은 slot을 아직 유효한 것으로 확인할 수 있습니다. 그 결과, 동일한 MDL을 각각 Free하는 Double Free로 이어질 수 있습니다.

slot.flag = 1; // 유효한 registration entry 
slot.mapped_va = 0x12340000; // user-mode에 반환된 mapped VA 
slot.mdl = 0xffff...; // 해당 mapping을 설명하는 MDL pointer

예를들어 각각의 slot은 위와 같이 유효 여부, 사용자에게 반환한 Mapped VA, 그리고 해당 Mapping을 설명하는 MDL Pointer를 함께 저장합니다. 이때 slot.flag == 1은 해당 slot이 유효한 registration entry임을 의미하고, slot.flag == 0은 이미 해제되었거나 더이상 쓸 수 없는 slot을 나타냅니다.

만약 특정 slot이 unregister 대상으로 선택되면 드라이버는 먼저 해당 slot을 더 이상 사용할 수 없도록 해당 슬롯의 flag를 0으로 clear하고, 같은 slot에 저장된 mapped VA와 MDL pointer을 이용해서 mapping을 해제해야 합니다. 이후 같은 VA로 다시 Unregister 요청이 들어오더라도, 이미 무효화된 Slot은 다시 선택되어서는 안 됩니다.

문제는 취약한 함수의 코드를 확인해보면, slot 검색, slot flag clear, MDL unmap/free 과정이 하나의 lock으로 보호되지 않는다는 점을 알 수 있는데요. 즉, slot.flag == 1인지 확인하는 시점과 slot.flag = 0으로 변경하는 시점 사이에 다른 Thread가 같은 VA로 Unregister 요청을 보낸다면, 두 Thread 모두 동일한 Slot을 아직 유효한 것으로 판단해버릴 수 있습니다.😱 이후 한 Thread가 Slot을 무효화하고 MDL을 해제해도, 다른 Thread는 이미 확보한 같은 MDL 포인터를 사용해 다시 Unmap하고 Free하는 과정을 수행하게 될 수 있죠.

그 결과 하나의 MDL이 두 번 해제되는 Double Free가 발생할 수 있습니다.

Thread A:

  1. mapped VA와 일치하는 slot 검색
  2. slot.flag = 0으로 변경
  3. MmUnmapLockedPages()
  4. IoFreeMdl()

Thread B:

  1. 같은 mapped VA로 unregister 요청
  2. slot.flag == 0 확인
  3. slot 검색 실패
  4. MDL free 경로로 진입하지 않음

먼저 정상적으로 동기화된 Unregister 흐름을 한번 살펴보겠습니다. Thread A가 slot을 선택한 뒤에 slot이 무효화되기 때문에, Thread B가 이후 같은 slot을 검색하더라도 flag == 0을 확인해 slot을 사용할 수 없습니다. 즉, 하나의 MDL에 대해서 Unregister와 Free가 한 번만 수행이 됩니다.

Thread A:

  1. slot.flag == 1 확인
  2. mapped_va 일치 확인
  3. slot 선택

Thread B:

  1. Thread A가 flag를 clear하기 전에 진입
  2. slot.flag == 1 확인
  3. mapped_va 일치 확인
  4. 같은 slot 선택

하지만 취약한 함수에서는 Slot 검색부터 flag 변경, MDL 해제까지의 과정이 하나의 Lock으로 보호되지 않습니다. 두 Thread가 거의 동시에 같은 Mapped VA로 Unregister 요청을 보내면, 두 Thread 모두 slot.flag == 1인 동일한 Slot을 선택할 수 있습니다.

이후 두 Thread는 같은 MDL Pointer를 사용해 각각 MmUnmapLockedPages()IoFreeMdl()을 호출하게 되고, 결과적으로 Race Condition을 통해 하나의 MDL이 두 번 해제되는 Double Free가 발생합니다.

(bsod 발생..!)


2.2 Case: MDL Mismatch Bug Pattern

CVE-2024-38237, CVE-2025-21375

두 번째 case는 과거 ksthunk.sysks.sysKSSTREAM_HEADER_OPTIONSF_PERSIST_SAMPLE (0x8000) 플래그를 서로 반대로 처리하면서 발생하는 MDL Mismatch 유형의 취약점입니다.

먼저 Kernel Streaming은 UserMode에서 영상이나 오디오 프레임을 요청하면, 요청마다 KSSTREAM_HEADER을 전달하는 특성을 가지고 있습니다. 이 구조체에는 실제 데이터 버퍼의 주소와 처리할 프레임 크기, 프레임 속성을 지정하는 플래그가 포함됩니다.

typedef struct {
  ULONG    Size;
  ULONG    TypeSpecificFlags;
  KSTIME   PresentationTime;
  LONGLONG Duration;
  ULONG    FrameExtent;
  ULONG    DataUsed;
  PVOID    Data;
  ULONG    OptionsFlags;
  ULONG    Reserved;
} KSSTREAM_HEADER, *PKSSTREAM_HEADER;

이후 ksthunk.sysks.sys는 이 KSSTREAM_HEADER를 기준으로 각 Frame에 사용할 MDL을 준비하고 연결합니다. 문제는 두 드라이버가 같은 플래그를 서로 다르게 해석하면서, Header가 의도한 버퍼와 다른 MDL에 연결될 수 있다는 점입니다. 여기서 특히 중요한 필드는 다음과 같습니다.

Data→ 장치가 데이터를 기록할 유저 버퍼 주소
FrameExtent→ 해당 프레임에서 처리할 버퍼의 크기
OptionsFlags → 프레임 처리 방식과 MDL 캐싱 여부를 지정하는 플래그

이때 Kernel Streaming은 Data가 가리키는 유저 버퍼를 실제 장치 I/O에 사용하기 위해 MDL을 사용하고, 이후 해당 MDL과 Header 정보는 커널의 KS Frame에 연결됩니다.

struct _KSPFRAME_HEADER
{
  _LIST_ENTRY ListEntry;
  _KSPFRAME_HEADER *NextFrameHeaderInIrp;
  void *Queue;
  _IRP *OriginalIrp;
  _MDL *Mdl;
  _IRP *Irp;
  KSPIRP_FRAMING_ *IrpFraming;
  KSSTREAM_HEADER *StreamHeader;
  void *FrameBuffer;
  KSPMAPPINGS_TABLE *MappingsTable;
  unsigned int StreamHeaderSize;
  unsigned int FrameBufferSize;
  void *Context;
  int RefCount;
  void *OriginalData;
  void *BufferedData;
  int Status;
  unsigned __int8 DismissalCall;
  _KSPFRAME_HEADER_TYPE Type;
  _KSPSTREAM_POINTER *FrameHolder;
  unsigned int OriginalOptionsFlags;
  _KSPMDLCACHED_STREAM_POINTER *MdlCaching;
};

이 구조체에서 버그와 직접적으로 관련된 필드 관계는 아래와 같습니다.

KSFrame::StreamHeader - FrameExtent : 프레임 처리 크기
KSFrame::FrameBuffer : 장치 데이터가 기록될 버퍼
KSFrame::Mdl : FrameBuffer의 실제 물리 페이지와 크기를 설명함

정상적인 상황에서는 KSSTREAM_HEADER와 MDL이 같은 순서로, 같은 크기를 기준으로 연결되어야 합니다.

Header 1 (0x1000)  → MDL 1 (0x1000 버퍼)
Header 2 (0x20000) → MDL 2 (0x20000 버퍼)

즉, Header가 0x20000 바이트를 처리한다고 하면, 연결된 MDL 역시 0x20000 바이트를 설명하는 버퍼여야 합니다.

하지만 32비트 프로세스가 Kernel Streaming 요청을 보내면, 요청은 ksthunk.sys를 먼저 거친 뒤 ks.sys로 전달됩니다. 이때, KSSTREAM_HEADER_OPTIONSF_PERSIST_SAMPLE (0x8000) 플래그가 설정된 Frame에서 두 드라이버의 MDL 처리 방식이 서로 달라지는 문제가 발생할 수 있습니다.

KSSTREAM_HEADER_OPTIONSF_PERSIST_SAMPLE ON
  ksthunk.sys: skip
  ks.sys:      allocate

KSSTREAM_HEADER_OPTIONSF_PERSIST_SAMPLE OFF
  ksthunk.sys: allocate
  ks.sys:      skip

만약 단일 Frame만 처리하면 최종적으로 필요한 MDL 하나가 준비되므로 문제가 없지만, 다수의 Frame인 KSSTREAM_HEADER을 하나의 요청에 넣으면..? Header 배열의 순서와 IRP 내부 MDL chain 순서가 어긋날 수 있습니다.

Headers (user-controlled array)

headers[0]
  FrameExtent  = 0x1000
  OptionsFlags = KSSTREAM_HEADER_OPTIONSF_PERSIST_SAMPLE

headers[1]
  FrameExtent  = 0x20000
  OptionsFlags = 0

즉, 크기와 플래그가 다른 두 개의 KSSTREAM_HEADER를 하나의 요청에 포함하는 상황인데요. 이 경우 ksthunk.sysKSSTREAM_HEADER_OPTIONSF_PERSIST_SAMPLE 플래그가 설정된 첫 번째 헤더의 MDL 생성을 건너뛰고, 대신 플래그가 없는 두 번째 헤더에 대해 0x20000 크기 버퍼를 설명하는 MDL을 생성합니다.

IRP->MdlAddress
MDL A
buffer size = 0x20000
Next = NULL

이후 ks.sys가 첫 번째 Header에 플래그가 설정된 것을 확인하고, 해당 헤더의 0x1000 크기 버퍼를 설명하는 MDL을 새로 생성합니다.

IRP->MdlAddress
MDL A (0x20000) -> MDL B (0x1000) -> NULL

하지만 새로 생성된 MDL B는 기존에 존재하던 MDL A 뒤에 연결됩니다. 따라서 Header 배열은 headers[0] → headers[1] 순서이지만, 최종 MDL Chain은 headers[1]용 MDL A → headers[0]용 MDL B 순서가 되는 문제가 발생합니다.

KS Frame 0
  StreamHeader->FrameExtent = 0x1000
  Mdl                       = MDL A (0x20000)

KS Frame 1
  StreamHeader->FrameExtent = 0x20000
  Mdl                       = MDL B (0x1000)

headers[0] : 0x1000
headers[1] : 0x20000

MDL A (0x20000, headers[1] 용)
MDL B (0x1000, headers[0] 용)

그리고 이후 ks.sys는 Header 배열과 MDL Chain을 각각 앞에서부터 순서대로 꺼내 KS Frame에 연결합니다. 이때 Header와 MDL의 원래 대응 관계를 다시 확인하지 않기 때문에, 각 KS Frame에는 서로 다른 Header용 정보가 뒤바뀐 채 연결됩니다.

첫 번째 KS Frame은 FrameExtent0x1000headers[0]의 정보를 사용하지만, 실제로는 headers[1]용인 MDL A와 연결됩니다. 반대로 두 번째 KS Frame은 FrameExtent0x20000headers[1]의 정보를 사용하면서, 실제 크기가 0x1000headers[0]용 MDL B와 연결됩니다.

즉, 첫 번째 Frame에서는 크기가 더 큰 버퍼를 사용하므로 바로 문제가 드러나지 않을 수 있지만, 두 번째 Frame은 0x1000 바이트 버퍼에 0x20000 바이트를 처리하려는 상태가 되는 것인데요!🤯 이 Mismatch가 최종적으로 Buffer Overflow로 이어집니다.

KS Frame 0 
FrameExtent = 0x1000 
MDL-described buffer = 0x20000

KS Frame 1 
FrameExtent = 0x20000 
MDL-described buffer = 0x1000

Frame 0과 Frame 1 중 여러분이 보시기에 어떤 것이 더 수상한가요? 위 상태에서 실제 Overflow를 만드는 것은 바로 KS Frame 1입니다. StreamHeader->FrameExtent0x20000 바이트를 처리하도록 지정하지만, 연결된 MDL B가 설명하는 실제 버퍼는 0x1000 바이트에 불과합니다.

결과적으로 Kernel Streaming Worker는 MDL이 설명하는 유효 범위를 넘어 데이터를 기록하게 되고, 인접한 커널 메모리가 손상됩니다. 서로 다른 Header를 위해 생성된 MDL의 순서가 뒤바뀌면서, 잘못된 Header와 MDL이 하나의 KS Frame에 연결되어 발생하는 것이 MDL Mismatch 취약점입니다.


2.3 Case: Forgotten Lock / Uninitialized MDL Bug Pattern

CVE-2024-38238

마지막으로 세 번째 Case인데요. 앞서 정상적인 MDL 처리 과정에서 MmMapLockedPagesSpecifyCache()가 호출되기 전에 MmProbeAndLockPages()가 수행되어야 했던 점 기억하시나요? 이 순서가 지켜지지 않으면 MDL의 PFN 배열은 올바르게 초기화되지 않은 상태로 남을 수 있습니다.

CVE-2024-38238 case는 ks.sys가 MDL을 생성한 뒤 MmProbeAndLockPages()를 호출하지 않은 상태에서 해당 MDL을 매핑하면서 발생하는 Forgotten Lock 유형의 취약점입니다. 앞선 케이스는 MDL Mismatch가 Header와 MDL의 연결 관계가 바뀌는 문제였다면, Forgotten Lock은 하나의 MDL Chain 안에 Locked MDL과 Unlocked MDL이 함께 존재하는데도 드라이버가 이를 정상적인 Chain으로 간주하는 문제입니다.

(여기서 MDL Chain은 Next Pointer로 여러 MDL을 연결한 구조를 뜻합니다. 하나의 IRP가 여러 Frame을 처리할 때, 각 Frame을 설명하는 MDL은 IRP->MdlAddress부터 연결된 형태로 관리될 수 있습니다.)

ks.sys의 Header 순회 로직

ks!CKsMdlcache::MdlCacheHandleThunkBufferIrp

취약한 MDL 생성 및 Lock 흐름을 한번 확인해봅시다. 먼저 WoW64 Kernel Streaming 요청은 ksthunk.sys의 전처리를 거친 뒤 ks.sys의 위의 함수로 전달됩니다. 이후 함수 내부에서는 KSSTREAM_HEADER 배열을 순회하면서 각 Header의 OptionsFlags를 확인하는데요.

while ( TotalSize >= 0x38 )

함수를 자세하게 확인해보면 While 루프를 통해 요청에 포함된 KSSTREAM_HEADER를 순서대로 처리하는 것을 확인할 수 있습니다. 여기서 0x38은 64비트 KSSTREAM_HEADER의 크기입니다.

if ( (OptionsFlag & 0x8000) == 0 )
  return (unsigned int)KsProbeStreamIrp(irp, a3, 0);

플래그가 설정되어 있다면, 함수는 현재 Header를 위한 MDL을 생성한 뒤에 다음 KSSTREAM_HEADER 처리를 계속 처리합니다. 반대로 플래그가 설정되어 있지 않으면 KsProbeStreamIrp()를 호출하고 함수에서 바로 반환하는 흐름을 가지고 있습니다. 문제는 이 조기 반환이 발생하면 이후 Header 처리뿐 아니라, 함수 하단에서 수행되는 MDL Lock 루프에도 도달하지 못하게 됩니다.

MDL Lock 루프의 위치

만약 Header 순회가 정상적으로 끝났다면, 함수 하단에서 IRP의 MDL Chain을 순회하면서 MmProbeAndLockPages()를 호출합니다.

따라서, 정상적인 처리 순서는 아래 흐름을 따른다는 것을 알 수 있습니다.

  1. Header 배열 순회
  2. 필요한 MDL 생성
  3. Header 순회 완료
  4. IRP의 MDL Chain 순회
  5. MmProbeAndLockPages()

그런데 혹시 문제가 짐작이 가시나요? MmProbeAndLockPages() 루프가 Header 순회 뒤에 위치합니다. 만약 앞서 Header를 처리하면서 IoAllocateMdl()을 통해 MDL을 생성했더라도, 다음 Header에서 Cache Flag가 없는 경우 아래 플래그 검증 코드가 실행됩니다.

if ( (OptionsFlag & 0x8000) == 0 )
  return (unsigned int)KsProbeStreamIrp(irp, a3, 0);

그런데 문제는, 두 번째 Header에서는 OptionsFlags0일 경우 KsProbeStreamIrp() 루프로 조기 반환하게 되고, 함수 하단에 있는 Lock 루프인 MmProbeAndLockPages()에 도달하지 못하게 됩니다.

즉, 앞선 반복에서 생성한 MDL이 MmProbeAndLockPages()를 거치지 않은 상태로 KsProbeStreamIrp()에 전달될 수 있다는 문제가 발생하는 것이죠.

KsProbeStreamIrp MDL 검증 문제

그럼 KsProbeStreamIrp()로 한번 들어가볼까요..? 이 함수는 IRP MDL Chain을 이용해서 Frame Buffer를 커널 주소 공간에 매핑합니다.

코드를 확인해보면 먼저 IRP의 첫 번째 MDL을 가져온 뒤에, 첫 번째 MdlFlags에 대해 0x6 마스크를 검사합니다. 관련 플래그는 아래와 같습니다.

#define MDL_MAPPED_TO_SYSTEM_VA      0x0001
#define MDL_PAGES_LOCKED             0x0002
#define MDL_SOURCE_IS_NONPAGED_POOL  0x0004

0x60x2 | 0x4로, MDL_PAGES_LOCKED, MDL_SOURCE_IS_NONPAGED_POOL 플래그를 포함합니다. 즉, 두 플래그 중 하나라도 존재하면 조건을 통과한다는 뜻입니다. 첫 번째 MDL이 Lock 되었거나 NonPaged Pool을 설명하는 MDL이면 아래의 while 루프에 진입합니다.

하지만 이 검사가 오직 Irp→MdlAddress가 가리키는 첫 번째 MDL에 대해서만 수행하면서, 이후 while 루프에서 각 MDL MDL_PAGES_LOCKED 상태를 다시 확인하지 않습니다.

CurrentMdl = Irp->MdlAddress;

if ( CurrentMdl &&
     (CurrentMdl->MdlFlags & 6) &&
     (ProbeFlags & 0x40) )
{
  while ( CurrentMdl )
  {
    if ( !(CurrentMdl->MdlFlags & 5) )
      MmMapLockedPagesSpecifyCache(CurrentMdl, ...);

    CurrentMdl = CurrentMdl->Next;
  }
}

또한 while문 내부에서는 각 MDL에 대해 0x5 마스크만 검사합니다. 앞서 확인한 플래그 기준으로 이는 MDL_MAPPED_TO_SYSTEM_VA 또는 MDL_SOURCE_IS_NONPAGED_POOL 상태를 뜻하는데요. 이미 System VA가 존재하거나 NonPaged Pool을 설명하는 MDL이라면 기존 MappedSystemVa를 사용하고, 그렇지 않은 경우에는 다음 MmMapLockedPagesSpecifyCache() 호출로 넘어갑니다.

MmMapLockedPagesSpecifyCache(
  CurrentMdl,
  0,
  MmCached,
  0,
  0,
  0x40000010u
);

하지만 여기에서도 MmProbeAndLockPages() 수행 여부는 확인하지 않습니다. 결국 첫 번째 MDL의 상태만 검사한 뒤, 나머지 체인까지 모두 Locked 상태일 것이라고 가정하는 로직이 핵심적인 검증 오류인 것을 알 수 있습니다.

취약점 트리거 구성하기

이제 두 로직을 연결해봅시다. 먼저 취약점 트리거를 위해 동일한 크기의 KSSTREAM_HEADER 두 개를 하나의 요청에 포함합니다.

Headers (user-controlled array)

headers[0]
  FrameExtent  = 0x2000
  Data         = 0x42420000
  OptionsFlags = KSSTREAM_HEADER_OPTIONSF_PERSIST_SAMPLE

headers[1]
  FrameExtent  = 0x2000
  Data         = 0x43430000
  OptionsFlags = 0

핵심은 Locked MDL을 체인 맨 앞에 두고, 그 뒤에 OptionsFlags0이고 Cache Flag가 꺼진 Unlocked MDL을 연결하는 것입니다. 그러면 첫 번째 Header는 ks.sys에서 MDL을 생성한 뒤, 두 번째 Header는 KsProbeStreamIrp() 경로로 조기 반환합니다.

이후 ksthunk.sys는 첫 번째 Header의 MDL 생성을 건너뛰지만, Cache Flag가 없는 두 번째 Header에 대해서는 ks.sys를 대신해 MDL을 생성하고 Lock합니다. 따라서 ksthunk.sys 처리가 끝난 시점에는 두 번째 Header용 MDL 2만 IRP에 연결되어 있는 상태가 됩니다.

IRP->MdlAddress
MDL 2
  Header     = headers[1]
  Data       = 0x43430000
  ByteCount  = 0x2000
  Locked     = TRUE
  PFN[]      = Initialized
  Next       = NULL

그리고 ks.sys 측에서는 첫 번째 Header가 Cache Flag가 설정되어 있으니 IoAllocateMdl을 통해 MDL 1을 생성합니다. 이때, IRP에는 이미 MDL 2가 존재하기 때문에 새로 생성된 MDL 1은 Chain의 뒤에 연결됩니다.

IRP->MdlAddress
->
MDL 2
  headers[1]용
  Locked = TRUE
  PFN[]  = Initialized
->
MDL 1
  headers[0]용
  Locked = FALSE
  PFN[]  = Uninitialized
->
      NULL

이 시점에서 MDL 1은 아직 IoAllocateMdl()만 수행된 상태고, 이후 ks.sys가 두 번째 Header를 처리하면서, 결국 취약한 로직대로 MmProbeAndLockPages() 루프는 실행되지 않고 MDL 1이 Unlocked 상태로 남게 됩니다.

이후 KsProbeStreamIrp()는 체인의 첫 번째 노드인 MDL 2를 검사합니다. 이때, MDL 2는 이미 ksthunk.sys에 의해 정상적으로 Lock된 상태기 때문에 MDL_PAGES_LOCKED가 설정되어 있고 조건을 통과합니다.

하지만 MDL 1의 경우 MmProbeAndLockPages()를 거치지 않았기 때문에 PFN 배열이 초기화되지 않은 상태고, 그럼에도 MmMapLockedPagesSpecifyCache()는 해당 값을 MDL이 설명하는 물리 페이지 번호로 사용하려고 합니다. 따라서 이때 초기화되지 않은 잔여 PFN 값이 사용된다면 잘못된 물리 페이지를 참조하게 되어 Bug Check(BSOD)를 발생시킬 수 있게 됩니다.

더 나아가 이 경로에서 IoAllocateMdl()로 생성되는 MDL은 PFN 배열을 초기화하지 않은 상태로 Pool Memory를 재사용할 수 있습니다. 따라서 공격자는 PFN 배열이 위치할 Pool 영역에 원하는 값을 Spray하여, 초기화되지 않은 PFN 값을 제어하는 방향으로 EoP를 구성할 수 있습니다.

3. Outro

이번 글에서는 MDL이 유저 버퍼의 물리 페이지를 고정하고 커널 주소 공간에 매핑하는 기본 흐름과, 이를 잘못 처리할 때 발생하는 Lifetime, Mismatch, Forgotten Lock 버그 패턴을 살펴봤는데요.

이번 글은 타이렁과의 결전이 아닌, 그를 상대하기 위한 수련편이었습니다. MDL의 동작과 세 가지 버그 패턴까지 익혔으니, 이제 남은 것은 실전으로 들어가 Medium Integrity에서 SYSTEM까지 이어지게 하는 마지막 권한의 벽일 것입니다. 과연 포는 다음 전투에서 MDL을 완성해 타이렁과의 싸움에서 승리할 수 있을지, 다음 도닦기 편도 기대해주세요!

Reference.

https://theori.io/blog/chaining-n-days-to-compromise-all-part-3-windows-driver-lpe-medium-to-system

https://medium.com/@WaterBucket/understanding-memory-descriptor-lists-mdls-for-windows-vulnerability-research-exploit-7de8729caee7

https://devco.re/blog/2025/05/17/frame-by-frame-kernel-streaming-keeps-giving-vulnerabilities-en/



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