본 글은 스틸리언 선제대응팀에서 진행 중인 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, 언더플로우 자체가 발생하지 않습니다.
참고 자료
- Microsoft Security Update Guide — CVE-2026-20840
- NVD — CVE-2026-20840
- NTFS Documentation:
$LogFileStructure and Log Recovery Mechanism