R&D
CVE-2026-40397 Windows CLFS 정수 언더플로우 취약점 분석
김재민, 박민제
May 21, 2026

본 글은 스틸리언 선제대응팀에서 진행 중인 Diffuto 프로젝트에서 산출된 분석 보고서입니다.

2026년 5월, 모든 Windows 시스템에 기본 탑재되는 Common Log File System 드라이버인 clfs.sys에서 권한 상승 취약점이 패치되었습니다. 이번에 분석한 CVE-2026-40397은 일반 사용자 권한에서 SetEndOfLog IOCTL 호출만으로 커널 내부에서 약 4GB 크기의 읽기 연산을 유발하여 BSOD를 일으킬 수 있는 취약점입니다.

🔍 이 글을 처음 보시는 분들께 Microsoft는 보안 패치를 공개할 때 “무엇을 고쳤는지”만 알려줄 뿐, “왜 문제였는지”는 설명하지 않습니다. 이에 선제대응팀에서는 패치된 바이너리를 기반으로 실제 공격자와 동일한 조건, 즉 소스코드(설계도) 없이 배포된 바이너리만으로 LLM을 활용하여 취약점이 발생한 원인을 상세히 분석하여 이 내용을 공유하고자 합니다. 소스코드가 공개된 웹·오픈소스 취약점에만 집중된 기존 분석 프레임워크들과 달리, Windows 환경에서 사용되고 있는 바이너리 영역까지 분석할 수 있다는 점이 핵심 차별점입니다. 아래 본문은 해당 분석의 기술적 상세 내용으로, 보안 연구에 관심 있는 분들을 위해 원문 그대로 공개합니다.

📌 취약점 선정 기준 선제대응팀은 Microsoft 월간 보안 업데이트에서 Windows 관련 취약점 전수를 분석합니다. 이 중 아래 기준을 충족하는 경우에 한해 외부에 공개합니다.

  • 취약점 심각도: CVSS 기준 7.0 이상의 파급력 있는 취약점에 대해 분석을 제공합니다.
  • 분석 난이도: 기술적으로 의미 있는 인사이트를 줄 수 있는 수준의 취약점만 공개합니다.

선제대응팀은 Microsoft 월간 보안 업데이트 다음날 분석 결과를 공개합니다. 매월 둘째 주에는 빠른 분석을, 넷째 주에는 심층 분석을 제공합니다.

요약

CLFS 드라이버의 ReadLogBlock 함수에서 Owner Page LSN 오프셋 연산 시 부호 없는 정수 언더플로우(unsigned integer underflow)가 발생합니다. 다중화(multiplexed) 로그에서 GetNextOwnerPageLsn이 반환한 LSN이 현재 읽기 위치보다 작거나 같을 때 두 오프셋의 뺄셈이 언더플로우되어 약 4GB 크기의 읽기가 시도되고, 이로 인해 커널 힙 오버플로우가 발생합니다. 일반 사용자 권한으로 SetEndOfLog IOCTL을 통해 트리거할 수 있으며, BSOD(Bugcheck 0x50)가 발생합니다.

항목 내용
CVE CVE-2026-40397
대상 clfs.sys CClfsLogFcbPhysical::ReadLogBlock
유형 CWE-191 (Integer Underflow)
영향받는 버전 clfs.sys v10.0.26100.8246 이하
패치 버전 clfs.sys v10.0.26100.8457
CVSS AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

목차


취약점 원인 분석

취약점 발생 흐름

일반 사용자가 SetEndOfLog IOCTL(0x8007A84C)을 호출하면 커널 내부에서 다음과 같은 호출 체인을 통해 취약한 코드에 도달합니다.

DeviceIoControl(IOCTL 0x8007A84C — SetEndOfLog)
     │
     ▼
CClfsRequest::SetEndOfLog
     │  입력 버퍼 크기 검증 (>= 0x230)
     │  스트림 인덱스, 클라이언트 변경 데이터 파싱
     ▼
CClfsLogFcbPhysical::SetEndOfLog
     │  Physical FCB 경로 — "LOG:path" (::StreamN 없이) 열기로 진입
     │  다중화 로그일 경우 스트림 수 검증 (≤ 1)
     │  TruncateContext 할당
     ▼
TruncateLog (state == 0)
     │  PurgeCacheSection 호출 후
     │  state == 0이면 TruncateLogStart 진입
     ▼
TruncateLogStart
     │  flags = 0x11 (bit 0: 물리 I/O, bit 3 clear: Owner Page 계산 허용)
     │  클라이언트 변경 데이터에서 LSN 추출
     │  ReadLogBlock을 vtable 호출
     ▼
ReadLogBlock(LSN=0x7FA00, flags=0x11)
     │
     ├─ 1차 반복: *outputSize==0 → 1 섹터(512B)로 크기 제한 → 정상 읽기
     │   byte[0]==0x00 → 로그 블록 헤더 검사 스킵
     │
     └─ 2차 반복: 크기 제한 없음 → 언더플로우된 크기로 ReadLog 호출
                  → ReadSector → MmProbeAndLockPages
                  → Bugcheck 0x50 (PAGE_FAULT_IN_NONPAGED_AREA)

이 경로에서 핵심은 TruncateLogStartflags=0x11ReadLogBlock을 호출한다는 점입니다. bit 0이 설정되면 물리 I/O(캐시 우회) 경로를 사용하고, bit 3이 해제되면 다중화 로그의 Owner Page 계산이 활성화됩니다. 또한 ReadLogBlock에는 (a8 & 8) != 0일 때 다중화 로그를 거부하는 검사가 있는데, flags=0x11은 bit 3이 0이므로 이 검사를 통과합니다.

CLFS 컨테이너와 Owner Page 구조

CLFS(Common Log File System)는 하나의 로그 파일에 여러 스트림을 다중화(multiplex)하여 저장할 수 있는 Windows 커널 로깅 프레임워크입니다. 다중화 로그에서는 각 스트림의 데이터 소유권을 추적하기 위해 컨테이너 끝부분에 Owner Page라는 메타데이터 영역을 사용합니다.

CLFS Container (1MB = 0x100000)
│
├─ Log Data Region: 0x00000 ~ 0x7EFFF
│  └─ 로그 레코드가 섹터(512바이트) 단위로 저장됨
│     각 레코드의 첫 번째 바이트(byte[0])는 로그 블록 헤더 시그니처
│
└─ Owner Page Region: 0x7F000 ~ 0x7FFFF (8 섹터, 4KB)
   │  다중화 로그에서 각 섹터의 소유 스트림을 기록
   │
   ├─ Sector 0 (0x7F000): byte[0]=0x15  ← 헤더 존재 (시그니처 유효)
   ├─ Sector 1 (0x7F200): byte[0]=0x01  ← 루프 종료 조건 충족
   ├─ Sector 2 (0x7F400): byte[0]=0x01
   ├─ Sector 3 (0x7F600): byte[0]=0x01
   ├─ Sector 4 (0x7F800): byte[0]=0x01
   ├─ Sector 5 (0x7FA00): byte[0]=0x00  ← TARGET (헤더 없음 → 검사 스킵)
   ├─ Sector 6 (0x7FC00): byte[0]=0x00
   └─ Sector 7 (0x7FE00): byte[0]=0x00

CLFS에서 로그 레코드의 위치는 LSN(Log Sequence Number)으로 표현됩니다. LSN은 64비트 값으로, 상위 32비트가 컨테이너 ID, 하위 32비트가 컨테이너 내 오프셋을 나타냅니다.

typedef union _CLS_LSN {
    ULONGLONG ullOffset;
    struct {
        ULONG idxRecord;      // 하위 32비트: 컨테이너 내 바이트 오프셋
        ULONG cidContainer;   // 상위 32비트: 컨테이너 ID
    } offset;
} CLS_LSN;

GetNextOwnerPageLsn은 주어진 LSN으로부터 다음 Owner Page 영역의 시작 위치를 계산하여 반환합니다. 정상적인 경우, 반환되는 LSN은 항상 입력 LSN보다 커야 합니다. 그러나 입력 LSN이 이미 Owner Page 영역 내부(0x7F000 이상)를 가리키고 있으면, 함수는 해당 영역의 시작인 0x7F000을 반환하게 되어 입력보다 작은 값이 반환됩니다.

상세 분석: 언더플로우 메커니즘

CClfsLogFcbPhysical::ReadLogBlock(0x14000F380)은 로그 블록을 읽는 핵심 함수입니다. 이 함수는 while 루프 안에서 반복적으로 데이터를 읽는데, 다중화 로그일 때 Owner Page 경계를 넘지 않도록 읽기 크기를 조정하는 로직이 있습니다. 리버싱을 통해 확인한 취약 코드의 흐름은 다음과 같습니다.

// CClfsLogFcbPhysical::ReadLogBlock (clfs.sys, 0x14000F380)
// 패치 전 버전

__int64 ReadLogBlock(this, fileObject, inputLsn, mode, readBuffer,
                     bufferSize, asyncReq, flags, outputLsn, outputSize)
{
    CLFS_LSN nextOwnerPageLsn = CLFS_LSN_INVALID;

    // [1] 다중화 로그인 경우 다음 Owner Page LSN을 구함
    if (IsMultiplexed(this)) {
        nextOwnerPageLsn = GetNextOwnerPageLsn(this, inputLsn);
        // ⚠️ 반환값이 inputLsn 이하인지 검증하지 않음
    }

    CLS_LSN currentLsn = *inputLsn;
    *outputSize = 0;

    while (1) {
        if (*outputSize >= bufferSize) break;

        ULONG readSize = bufferSize - *outputSize;

        // [2] 다중화 로그: Owner Page 경계까지만 읽도록 크기 조정
        if (IsMultiplexed(this)) {
            CLS_LSN nextLsn = AddLsnOffset(this, currentLsn);
            if (nextLsn > nextOwnerPageLsn) {
                // ⚠️ 부호 없는 뺄셈 — 언더플로우 발생 지점
                readSize = (nextOwnerPageLsn.idxRecord & 0xFFFFFE00)
                         - (currentLsn.idxRecord & 0xFFFFFE00);
            }
        }

        // [3] 첫 번째 반복에서만 1 섹터로 제한
        BOOL isFirstRead = FALSE;
        if ((mode & 1) && *outputSize == 0) {
            readSize = sectorSize;  // 512바이트로 제한
            isFirstRead = TRUE;
        }

        // [4] 물리 I/O 경로 (flags bit 0 = 1)
        if (flags & 1) {
            status = ReadLog(this, &currentLsn, readBuffer,
                           readSize >> 9, ...);  // 섹터 수로 변환
        }

        // [5] 첫 반복 후 헤더 검사
        if (isFirstRead && status >= 0) {
            alignedSize = RawSectorAlign(readBuffer->sectorCount << 9);
            if (readBuffer->byte0 && readBuffer->signature) {
                // 유효한 헤더 → bufferSize를 alignedSize로 축소
                bufferSize = alignedSize;
            }
            // byte[0]==0x00이면 헤더 검사 스킵 → bufferSize 유지
        }

        *outputSize += bytesRead;
        // 두 번째 반복으로 → readSize가 언더플로우된 값 그대로 사용
    }
}

취약점이 트리거되려면 두 가지 조건이 결합되어야 합니다.

  1. GetNextOwnerPageLsncurrentLsn보다 작은 값을 반환해야 함 (언더플로우 유발)
  2. 첫 번째 반복에서 읽은 섹터의 byte[0]0x00이어야 함 (헤더 검사 스킵 → 두 번째 반복 진입)

Owner Page 영역의 Sector 5~7(0x7FA00 이상)은 두 조건을 모두 만족합니다. currentLsn0x7FA00을 가리키면, GetNextOwnerPageLsn은 Owner Page의 시작인 0x7F000을 반환하고, 이때 두 오프셋의 뺄셈에서 언더플로우가 발생합니다.

정상 케이스: currentLsn=0x1000, nextOwnerPageLsn=0x7F000
  readSize = (0x7F000 & 0xFFFFFE00) - (0x1000 & 0xFFFFFE00)
           = 0x7F000 - 0x1000
           = 0x7E000 (504KB  정상 범위)

취약 케이스: currentLsn=0x7FA00, nextOwnerPageLsn=0x7F000
  readSize = (0x7F000 & 0xFFFFFE00) - (0x7FA00 & 0xFFFFFE00)
           = 0x7F000 - 0x7FA00
           = 0xFFFFF400 ( 4GB  unsigned 언더플로우!)

IDA 디컴파일 결과에서도 동일한 패턴을 확인할 수 있습니다.

// ReadLogBlock 내부 while 루프 (IDA 디컴파일)
if (IsMultiplexed(this)) {
    v64 = AddLsnOffset(this, &v63, &currentLsn).ullOffset;
    if (v64 > PAIR64(nextOwnerPageLsn_hi, nextOwnerPageLsn_lo)) {
        v28 = (nextOwnerPageLsn_lo & 0xFFFFFE00)
            - (currentLsn.idxRecord & 0xFFFFFE00);
    }
}

// 이후 ReadLog에 전달
ReadLog(this, &currentLsn, readBuffer, v28 >> 9, ...);
// v28 >> 9 = 0x7FFFFA 섹터 ≈ 4GB

첫 번째 반복에서는 *outputSize == 0이므로 readSize가 1섹터(512바이트)로 제한되어 안전하게 읽힙니다. 그러나 읽힌 데이터의 byte[0]0x00이면 로그 블록 헤더로 인식되지 않아 bufferSize 축소가 일어나지 않고, 두 번째 반복에서 언더플로우된 readSize(0xFFFFF400)가 그대로 ReadLog에 전달됩니다.

ReadLog는 전달받은 섹터 수(0x7FFFFA)로 CClfsContainer::ReadSector를 호출하고, 물리 I/O를 위해 약 4GB 크기의 MDL(Memory Descriptor List)을 생성합니다. MmProbeAndLockPages가 이 크기의 가상 주소 범위를 물리 페이지에 매핑하려다 유효하지 않은 페이지에 접근하면서 Bugcheck 0x50(PAGE_FAULT_IN_NONPAGED_AREA)이 발생합니다.

언더플로우 결과가 항상 약 4GB(0xFFFFF400)로 고정되기 때문에 공격자가 읽기 크기를 세밀하게 제어하는 것은 불가능합니다. 따라서 현실적으로는 Denial of Service(BSOD)가 주된 영향이지만, CVSS 스코어는 기밀성/무결성/가용성 모두 High로 평가되어 있습니다.


PoC Demo

일반 사용자 권한으로 다중화 CLFS 로그를 생성한 뒤 SetEndOfLog IOCTL을 호출하여 BSOD(Bugcheck 0x50)를 유발합니다. 관리자 권한 없이 표준 Win32 API(CreateLogFile, AddLogContainer, DeviceIoControl)만으로 트리거됩니다.


패치 분석

패치 버전(v10.0.26100.8457)에서는 GetNextOwnerPageLsn 호출 직후 반환된 LSN이 입력 LSN 이하인지 검증하는 코드가 추가되었습니다. 이 검증은 Feature gate Feature_950255931로 보호됩니다.

// clfs.sys v10.0.26100.8457 — ReadLogBlock 내부
if (IsMultiplexed(this)) {
    nextOwnerPageLsn = GetNextOwnerPageLsn(this, inputLsn);

    // 신규 검증: Feature_950255931
    if (Feature_950255931__private_IsEnabledDeviceUsageNoInline()
        && (!inputLsn || nextOwnerPageLsn <= inputLsn->ullOffset))
    {
        return STATUS_INVALID_PARAMETER;  // 0xC000000D
    }
}

nextOwnerPageLsn <= inputLsn이면 즉시 STATUS_INVALID_PARAMETER를 반환하여 언더플로우 자체를 차단합니다. 이로써 Owner Page 영역 내부를 가리키는 LSN으로 ReadLogBlock을 호출하더라도 뺄셈이 수행되기 전에 함수가 종료됩니다.


참고 자료

김재민, 박민제
RECENT POST
김재민, 박민제
CVE-2026-40397 Windows CLFS 정수 언더플로우 취약점 분석
Deep dive into CVE-2026-40397
김재민, 박민제
CVE-2026-20840 Windows NTFS 힙 버퍼 오버플로우 취약점 분석
Deep dive into CVE-2026-20840