R&D
CVE-2026-20840 Windows NTFS 힙 버퍼 오버플로우 취약점 분석
김재민, 박민제
Apr 28, 2026

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

2026년 1월, 모든 Windows 시스템에 기본 탑재되는 파일 시스템 드라이버인 ntfs.sys에서 취약점이 패치되었습니다. 이번에 분석한 CVE-2026-20840은 조작된 USB 드라이브나 VHD를 마운트하는 것만으로 시스템이 다운되거나 원격 코드 실행(RCE)으로 이어질 수 있는 취약점입니다.

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

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

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

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

요약

NTFS 드라이버가 볼륨 마운트 시 $LogFile을 파싱하는 과정에서 힙 기반 버퍼 오버플로우가 발생합니다. InitializeRestartState 함수의 Version 0 분기에서 Restart Table EntrySize의 최소값 검증이 빠져 있어, EntrySize = 0x18로 설정하면 경계 검사에서 정수 언더플로우가 일어나고, memmove가 0x298바이트 버퍼에 0x320바이트를 복사하면서 0xC0바이트만큼 커널 힙을 넘어씁니다. 조작된 NTFS 이미지를 마운트하는 것만으로도 BSOD 또는 잠재적 커널 코드 실행이 발생할 수 있습니다.

항목 내용
CVE CVE-2026-20840
대상 ntfs.sys InitializeRestartState
유형 CWE-122 (Heap-based Buffer Overflow)
영향받는 버전 ntfs.sys v10.0.26100.7309 이하
패치 버전 ntfs.sys v10.0.26100.7623
CVSS AV:L/AC:L/PR:N/UI:R

목차


취약점 원인 분석

취약점 발생 흐름

조작된 NTFS 이미지가 마운트되면 드라이버는 $LogFile의 로그 복구를 시도합니다. 이 과정에서 Checkpoint의 MajorVersion이 0이면 구형 OAT 엔트리를 신형 포맷으로 변환하는 코드 경로에 진입하게 되는데, 여기서 EntrySize에 대한 하한 검증이 없어 정수 언더플로우가 발생합니다.

[조작된 NTFS 이미지 (VHD/USB)]
         │
         ▼
    NtfsMountVolume
         │
         ▼
    NtfsRestartVolume ──► $LogFile 파싱 및 로그 복구
         │
         ▼
    InitializeRestartState ──► Checkpoint에서 OAT LSN 획득
         │                      Version == 0 분기 진입
         ▼
    ReadRestartTable ──► OAT Restart Table 읽기
         │                EntrySize=0x18 → NewEntrySize=0x14
         ▼
    경계 검사 ──► NewEntrySize - 0x28 = 0xFFFFFFFFFFFFFFEC
         │        unsigned 비교 → 항상 통과
         ▼
    memmove ──► 0x320바이트 → 0x298바이트 버퍼
                0xC0바이트 힙 오버플로우 → BSOD

NTFS $LogFile 구조

NTFS $LogFile은 파일 시스템 트랜잭션 무결성을 위한 WAL(Write-Ahead Logging)을 구현합니다. 취약점에 관련된 구조를 간략하게 표현하면 다음과 같습니다.

$LogFile
│
├─ RSTR Page 0 (Restart Page)
│  ├─ LFS_RESTART_PAGE_HEADER         ← 시그니처 "RSTR", 버전 정보
│  ├─ LFS_RESTART_AREA                ← CurrentLsn, SeqNumberBits
│  └─ LFS_CLIENT_RECORD               ← ClientRestartLsn (→ Checkpoint)
│
├─ RSTR Page 1 (백업)
│
├─ RCRD Page 2 (Log Record Page)
│  ├─ LFS_RECORD_PAGE_HEADER          ← 시그니처 "RCRD"
│  ├─ LFS_LOG_RECORD_HEADER
│  │  └─ Checkpoint (RecordType=2)    ← 복구 시작점
│  │     ├─ MajorVersion              ← 0으로 설정 시 취약한 경로 진입
│  │     ├─ OpenAttributeTableLsn     ← OAT 데이터 위치
│  │     └─ OpenAttributeTableLength  ← OAT 데이터 크기
│  │
│  └─ LFS_LOG_RECORD_HEADER
│     └─ OAT Restart Table            ← 악성 데이터 주입 지점
│        ├─ RESTART_TABLE_HEADER       ← EntrySize, NumberEntries
│        └─ Entry[0]                   ← LcnsToFollow (오버플로우 크기 결정)
│
└─ RCRD Page 3...N

Checkpoint는 ClientRestartLsn이 가리키는 로그 레코드에 저장되며, 로그 복구의 시작점이 됩니다. 리버싱을 통해 확인된 구조체는 아래와 같습니다.

typedef struct {
    DWORD   MajorVersion;              // +0x00: Version 0 → 취약한 per-entry memmove 경로
    DWORD   MinorVersion;              // +0x04
    LONGLONG StartOfCheckpoint;        // +0x08
    LONGLONG Field_10;                 // +0x10
    LONGLONG Field_18;                 // +0x18
    LONGLONG OpenAttributeTableLsn;    // +0x20: OAT 로그 레코드 LSN
    LONGLONG DirtyPageTableLsn;        // +0x28
    LONGLONG Field_30;                 // +0x30
    DWORD   OpenAttributeTableLength;  // +0x38: OAT 데이터 길이
    DWORD   DirtyPageTableLength;      // +0x3C
    LONGLONG TransactionTableLsn;      // +0x40
    // ...
} NTFS_CHECKPOINT;

OAT 데이터의 본체인 Restart Table 헤더 구조는 다음과 같습니다. EntrySize 필드가 취약점의 직접적인 원인입니다.

typedef struct {
    USHORT  EntrySize;         // +0x00: 엔트리 크기 (0x18 → 정수 언더플로우)
    USHORT  NumberEntries;     // +0x02
    USHORT  NumberAllocated;   // +0x04
    USHORT  Flags;             // +0x06
    DWORD   Reserved1;         // +0x08
    DWORD   Reserved2;         // +0x0C
    DWORD   FirstFreeEntry;    // +0x10
    DWORD   LastFreeEntry;     // +0x14
} RESTART_TABLE_HEADER;        // 총 0x18바이트

상세 분석: InitializeRestartState

InitializeRestartState는 Checkpoint 데이터로부터 OAT, DPT, TransactionTable을 복원하는 함수입니다. MajorVersion == 0이면 구형 OAT 엔트리를 신형 포맷(0x20바이트)으로 변환하면서 per-entry memmove를 수행하는데, 이때 EntrySize에 대한 하한 검증이 없어 문제가 됩니다.

// InitializeRestartState (ntfs.sys, RVA 0x140234B64)

NTSTATUS InitializeRestartState(PVCB Vcb, PVOID CheckpointData, ...)
{
    NTFS_CHECKPOINT *ckpt = (NTFS_CHECKPOINT *)CheckpointData;

    if (ckpt->OpenAttributeTableLength > 0) {

        PRESTART_TABLE OatTable = ReadRestartTable(
            ckpt->OpenAttributeTableLsn,
            ckpt->OpenAttributeTableLength
        );

        // Version 0 분기 — 취약한 코드 경로
        if (ckpt->MajorVersion == 0) {

            DWORD OldEntrySize = OatTable->EntrySize;   // 공격자 제어
            DWORD NewEntrySize = OldEntrySize - 4;       // 0x18 - 4 = 0x14

            // 새 테이블 할당: 0x14 entries × 0x20 + 0x18 = 0x298바이트
            DWORD NewTableSize = OatTable->NumberEntries * 0x20 + 0x18;
            PRESTART_TABLE NewTable = ExAllocatePool(PagedPool, NewTableSize);
            NewTable->EntrySize = 0x20;

            for (each allocated entry in OatTable) {
                PBYTE OldEntry = OatTableBase + entryOffset;
                PBYTE NewEntry = NewTableBase + newEntryOffset;

                // 경계 검사
                // r8 = NewEntrySize - 0x28
                //    = 0x14 - 0x28
                //    = 0xFFFFFFFFFFFFFFEC (정수 언더플로우!)
                //
                // 8 * LcnsToFollow < r8 → unsigned 비교 → 항상 통과

                DWORD copySize = 8 * LcnsToFollow;  // 0x8 × 0x64 = 0x320바이트

                memmove(
                    NewEntry + 0x20,  // dest
                    OldEntry + 0x24,  // src
                    copySize          // 0x320바이트 → 0x298바이트 버퍼에 쓰기
                );
                // 0xC0바이트 힙 오버플로우
            }
        }
    }
}

정수 언더플로우 메커니즘

EntrySize가 정상 범위(0x30 등)일 때와 취약한 값(0x18)일 때를 비교하면 경계 검사가 어떻게 무력화되는지 명확해집니다.

정상: EntrySize = 0x30
  NewEntrySize = 0x30 - 4 = 0x2C
  r8 = 0x2C - 0x28 = 0x04
   8 × LcnsToFollow < 0x04?  LcnsToFollow > 0이면 실패  memmove 스킵

취약: EntrySize = 0x18
  NewEntrySize = 0x18 - 4 = 0x14
  r8 = 0x14 - 0x28 = 0xFFFFFFFFFFFFFFEC (unsigned 언더플로우)
   8 × LcnsToFollow < 0xFFFFFFFFFFFFFFEC?  어떤 값이든 TRUE  memmove 실행

실제 어셈블리에서도 동일한 흐름을 확인할 수 있습니다.

; ntfs!InitializeRestartState+0x600
lea     r8, [rax-28h]           ; r8 = NewEntrySize - 0x28
                                 ; 0x14   0xFFFFFFFFFFFFFFEC

movzx   ecx, word ptr [rsi+0Ch] ; ecx = LcnsToFollow (공격자 제어)
shl     rcx, 3                   ; rcx = 8 * LcnsToFollow

cmp     rcx, r8                  ; 0x320 < 0xFFFFFFFFFFFFFFEC?
jb      do_memmove               ; unsigned less-than  항상 점프

do_memmove:
    call    memmove              ;  오버플로우 발생

오버플로우 크기 계산

NewTable 할당: 0x14 × 0x20 + 0x18 = 0x298바이트
memmove dest:  NewTable + 0x18(헤더) + 0x20(오프셋) = NewTable + 0x38
memmove 크기:  0x8 × 0x64 = 0x320바이트
memmove 끝:   NewTable + 0x38 + 0x320 = NewTable + 0x358

오버플로우 = 0x358 - 0x298 = 0xC0바이트

메모리 레이아웃으로 표현하면 아래와 같습니다.

           NewTable (0x298바이트 할당)
┌────────────────────────────────────────┐
│ RESTART_TABLE_HEADER (0x18바이트)        │
├────────────────────────────────────────┤ ← NewEntry 시작
│ Entry[0] (0x20바이트)                    │
├────────────────────────────────────────┤ ← memmove dest 시작
│ Entry[1]~Entry[0x13]                   │
├════════════════════════════════════════┤ ← 할당 경계 (0x298바이트)
│ ██████ 0xC0바이트 오버플로우 █████████      │ ← 커널 힙 손상
└────────────────────────────────────────┘ ← memmove 종료 (0x358바이트)

PoC Demo


패치 분석

패치 버전(v10.0.26100.7623)에서는 EntrySize의 하한을 검증하는 코드가 추가되었습니다.

// ntfs.sys v10.0.26100.7623
if (Feature_1740854585__private_IsEnabledDeviceUsageNoInline()) {
    if (*RestartTable < 0x2Cu) {
        return STATUS_DISK_CORRUPT_ERROR;
    }
}

EntrySize >= 0x2C가 보장되면 NewEntrySize = EntrySize - 4 >= 0x28이므로 NewEntrySize - 0x28 >= 0, 언더플로우 자체가 발생하지 않습니다.


참고 자료

김재민, 박민제
RECENT POST
김재민, 박민제
CVE-2026-20840 Windows NTFS 힙 버퍼 오버플로우 취약점 분석
Deep dive into CVE-2026-20840
이승용
에픽 퓨리 작전으로 살펴보는 현대 사이버전의 양상 2부
사이버전 진화의 출발점