- Affected binary:
ntfs.sys10.0.19041.7417 → 10.0.19041.7548 - CVE: CVE-2026-49184
- Patch date: 2026-07-14
- CVSS: 8.4
- Class: Buffer overflow
- Patch KB: KB5099444 and related
TL;DR
NTFS stores directory contents and secondary indexes as B-tree nodes packed into fixed-size index buffers on disk. When ntfs.sys reads such a buffer, NtfsCheckIndexBuffer is supposed to sanity-check it before the rest of the filesystem trusts and walks the entries inside. Pre-patch, that function validated only the buffer’s fixed header — the INDX signature, the update-sequence-array size, and the header structure via NtfsCheckIndexHeader — but never bounds-checked the individual index entries chained inside the node. A malicious volume or disk image could therefore contain index entries whose declared lengths and offsets run past the end of the heap-allocated index buffer, and downstream enumeration code would read and write out of bounds on the pool. The patch introduces two new per-entry validation passes, NtfsCheckFileNameIndexEntries and NtfsCheckViewIndexEntries, which walk the entry chain and bounds-check each entry against the buffer before the buffer is accepted. Both passes are gated behind newly added Feature_* CFR killswitches that are enabled by default.
Background
NTFS directories are not flat lists; they are B-trees. Each node of the tree is an index buffer — an on-disk structure that begins with a INDX signature (0x58444E49, little-endian “INDX”) followed by an update-sequence array (USA) used for torn-write detection, then an index header, then a sequence of index entries.
The index buffer header, in the layout the decompiled code touches, looks roughly like this:
- offset
0x00: signatureINDX - offset
0x04: USA offset (word), which the code checks against0x1EE - offset
0x06: USA count in 512-byte units (word);(count << 9) - 512must equal the SCB’s expected index buffer size stored ata1 + 704(0x2c0) - offset
0x18: start of the index header proper
Each index entry in a file-name (directory) index carries, among other fields, a Length (word at entry offset 0x08) that tells the enumerator how far to advance to the next entry, a KeyLength (word at offset 0x0A), a flags word at offset 0x0C whose bit 0x2 marks the last entry in the node, and — for $FILE_NAME keys — a filename length in characters at offset 0x50 and a filename namespace/flags byte at offset 0x51. View indexes (used by $SECURE, reparse-point, object-ID, and quota indexes, among others) use a different entry layout with a data offset and data length instead of a filename key.
NtfsCheckIndexBuffer is invoked from DoAction on the read path, and it is the gate that decides whether a freshly read index buffer can be trusted. The SCB (Stream Control Block) passed as a1 tells the function what kind of index this is: a1 + 656 (0x290) distinguishes a file-name index, and bit 0x4 of the flags at a1 + 496 (0x1f0) marks a view index.
Root Cause
Here is the entire pre-patch validation:
v5 = *(_DWORD *)(a1 + 704);
if ( *(_DWORD *)a2 == IndexSignature
&& *(_WORD *)(a2 + 4) <= 0x1EEu
&& (*(unsigned __int16 *)(a2 + 6) << 9) - 512 == v5 )
{
if ( (unsigned __int8)NtfsCheckIndexHeader(a2 + 24, (unsigned int)(v5 - 24), &v7) )
return 1; // header OK -> buffer accepted
if ( a3 )
*a3 = v7 + 24;
}
The function checks three things about the fixed header — signature, USA offset ceiling, and that the encoded buffer size matches the SCB’s expected v5 — and then calls NtfsCheckIndexHeader on the header region [a2 + 24, a2 + 24 + (v5 - 24)). If NtfsCheckIndexHeader returns non-zero, the function immediately return 1, meaning “buffer is valid, trust it.”
NtfsCheckIndexHeader validates the header — the fields that describe where the entry list begins and ends within the node — but it does not walk the entry chain. Nothing here iterates the index entries or checks that any entry’s Length field keeps the enumerator inside the buffer. The consuming code that later walks the node advances entry-to-entry using each entry’s self-described Length (the word at entry +0x08), stopping only when it hits an entry whose flags word has the “last entry” bit 0x2 set.
That combination is the bug. A crafted index buffer can present a well-formed header — correct signature, correct size encoding, a header that passes NtfsCheckIndexHeader — while the entries chained after the header carry Length values that step the walker forward past the end of the heap-allocated buffer, or key/data lengths that describe fields extending beyond the buffer. Because no entry ever needs to legitimately set the “last entry” flag, the walker can be driven off the end of the allocation entirely, dereferencing and (on modifying operations) writing pool memory adjacent to the index buffer. This is a classic “validate the container, trust the contents” omission, and it yields a heap-based out-of-bounds primitive controlled entirely by on-disk data.
The Patch
The post-patch NtfsCheckIndexBuffer keeps the identical header checks, but where the old code did return 1 immediately after NtfsCheckIndexHeader succeeded, it now inserts two entry-walking passes selected by the index type:
v6 = a2 + 24;
if ( (unsigned __int8)NtfsCheckIndexHeader(a2 + 24, (unsigned int)(v5 - 24), &v8) )
{
if ( !(unsigned int)Feature_2157166907__private_IsEnabledDeviceUsage()
|| *(_QWORD *)(a1 + 656) != 1
|| (v8 = 0, (unsigned __int8)NtfsCheckFileNameIndexEntries(v6, &v8)) )
{
if ( !(unsigned int)Feature_1838391611__private_IsEnabledDeviceUsage() )
return 1;
if ( (*(_DWORD *)(a1 + 496) & 4) == 0 )
return 1;
v8 = 0;
if ( (unsigned __int8)NtfsCheckViewIndexEntries(v6, &v8) )
return 1;
}
}
if ( a3 )
*a3 = v8 + 24;
The control flow is worth unpacking carefully:
v6 = a2 + 24is the pointer to the index header — the same value passed toNtfsCheckIndexHeader— and it’s what both new helpers receive as the base to walk from.- File-name index path: if
Feature_2157166907is enabled (default) and the SCB says this is a file-name index (*(a1 + 656) == 1), it callsNtfsCheckFileNameIndexEntries(v6, &v8). If that returns 0 (invalid), the short-circuit||chain fails, the inner block is skipped, and the function falls through toreturn 0— i.e. the buffer is rejected.v8is set to the byte offset of the offending entry (used to populate*a3 = v8 + 24for the caller’s error reporting). - View index path: if we’re not a rejected file-name index, the code checks
Feature_1838391611. If that killswitch is off, it preserves the old behavior (return 1). Otherwise, if the view-index flag (a1 + 496 & 4) is clear, it alsoreturn 1(nothing more to validate). If it is a view index, it runsNtfsCheckViewIndexEntries; success accepts the buffer, failure falls through to the rejectingreturn 0.
Per the Feature_* semantics in this repo, both Feature_2157166907 and Feature_1838391611 are gates wrapped around new logic and return true by default (Rule 2), so the validation is live in shipped builds; the killswitches exist only to disable the new passes remotely if a compatibility regression surfaces.
NtfsCheckFileNameIndexEntries
v3 = (char *)a1 + *a1; // first entry = header + FirstEntryOffset
v4 = (int)a1;
if ( (v3[12] & 2) != 0 ) // flags bit 0x2 == last entry
return 1;
while ( 1 )
{
v5 = *((unsigned __int16 *)v3 + 5); // entry Length at +0x0A (KeyLength/entry size)
if ( (unsigned __int16)v5 < 0x44u
|| !v3[80] // filename char count == 0
|| 2 * (unsigned __int64)(unsigned __int8)v3[80] > (unsigned __int64)(unsigned int)(v5 - 68) + 2
|| (unsigned int)Feature_3876572475__private_IsEnabledDeviceUsage()
&& (v3[81] & 2) != 0
&& (unsigned __int8)v3[80] > 0xCu )
break; // invalid entry
v3 += *((unsigned __int16 *)v3 + 4); // advance by entry Length at +0x08
if ( (v3[12] & 2) != 0 )
return 1; // reached last entry cleanly
}
if ( a2 )
*a2 = (_DWORD)v3 - v4; // report offset of bad entry
return 0;
The helper starts at the first entry (a1 + *a1, where *a1 is the FirstEntry offset stored in the header’s first dword) and walks the chain. For each entry it enforces:
- The entry key region is at least
0x44bytes (v5 < 0x44rejects) — that’s the minimum size of a$FILE_NAMEentry key. - A non-zero filename character count (
v3[80]). - The filename, at 2 bytes per UTF-16 character, fits within the entry’s declared key length:
2 * FileNameChars <= (KeyLength - 68) + 2. This is the core bounds check that stops a filename from claiming to extend past its entry. - Under
Feature_3876572475, an additional constraint: if the namespace flags byte (v3[81] & 2) indicates a DOS/short-name namespace, the character count must be<= 0xC(12), the 8.3 name limit.
Crucially, advancement uses the entry Length at +0x08 (*((unsigned __int16 *)v3 + 4)), and every step re-reads the flags byte at +0x0C to detect the terminal entry. An entry with a nonsensical length breaks out of the loop and the buffer is rejected — the walker cannot be walked off the end because each entry’s geometry is validated before it’s used to advance.
NtfsCheckViewIndexEntries
for ( i = (unsigned __int16 *)((char *)a1 + *a1); ; i = (unsigned __int16 *)((char *)i + i[4]) )
{
v4 = i[6]; // flags word at +0x0C
if ( (v4 & 2) != 0 )
return 1; // last entry -> valid
v3 = *i; // entry Length (dword at +0x00 of view entry)
if ( (unsigned int)v3 < 0x10
|| (_WORD)v3 != ((i[5] + 3) & 0xFFFC) + 16 // Length == align4(KeyLength)+16
|| v3 + (unsigned __int64)i[1] > i[4] - 8 * (unsigned __int64)(v4 & 1) )
break;
}
if ( a2 )
*a2 = (_DWORD)i - (_DWORD)a1;
return 0;
The view-index variant enforces the analogous invariants for the view entry layout:
- Minimum entry length
0x10. - The declared entry length must exactly equal the key length (
i[5], at+0x0A) rounded up to a 4-byte boundary plus the 16-byte fixed prefix:((KeyLength + 3) & ~3) + 16. This rejects entries whose self-described size is inconsistent with their key. - The data described by the entry — length
v3plus the value at+0x02(i[1]) — must fit within the entry’s total spani[4], minus 8 bytes when the low flag bit(v4 & 1)(the sub-node/VCN-present bit) is set. This is the bound that keeps the data region inside the entry.
Both helpers share the same shape: begin at the header’s first-entry offset, validate the current entry’s geometry, only then advance using the validated length, and treat the “last entry” flag as the sole legitimate exit. The NtfsCheckIndexBuffer change simply routes to whichever helper matches the SCB’s index type and rejects the buffer if the walk finds a malformed entry.
Exploitability
The primitive is a heap (pool) out-of-bounds access on an attacker-controlled index buffer. The index buffer is a pool allocation sized from the SCB (*(a1 + 704)), and the pre-patch code accepted any buffer whose header validated, regardless of what the entry chain claimed. Downstream enumeration walking that chain by each entry’s Length field, with no terminal-entry guarantee, reads adjacent pool memory; on operations that insert, delete, or rebalance entries — writing back into the node — the same unvalidated offsets become an out-of-bounds write. Given control over neighboring pool contents via standard grooming, an out-of-bounds write into adjacent pool objects is a well-trodden path to arbitrary code execution in kernel context, which is why MSRC rates this RCE at CVSS 8.4 despite the “local” attack vector.
The trigger is entirely data-driven: no race window, no timing. An attacker supplies a crafted NTFS volume or image — a USB stick, a mounted .vhd/.vhdx, an ISO, or any removable medium — whose directory or view index contains a buffer with a valid INDX header but malformed entries. The moment NTFS reads and parses that index (opening the directory, enumerating it, or touching a secondary index like $SECURE), NtfsCheckIndexBuffer on the old code accepts the buffer and the corruption follows. Because Windows auto-mounts and eagerly parses filesystem metadata on newly attached volumes, reaching the vulnerable path requires only that the victim attach or open the malicious volume — no elevated privileges on the attacker’s part and minimal user interaction. That combination of a purely on-disk trigger and a kernel-pool write is what makes the bug practical, and it’s exactly why the fix is a self-contained set of per-entry bounds checks inserted before any entry is trusted.
Pre patch functions
Full decompilation of NtfsCheckIndexBuffer before the patch:
undefined8 NtfsCheckIndexBuffer(longlong param_1,int *param_2,int *param_3)
{
char cVar1;
int local_res8 [8];
local_res8[0] = 0;
if (((*param_2 == 0x58444e49) && (*(ushort *)(param_2 + 1) < 0x1ef)) &&
((uint)*(ushort *)((longlong)param_2 + 6) * 0x200 + -0x200 == *(int *)(param_1 + 0x2c0))) {
cVar1 = NtfsCheckIndexHeader(param_2 + 6,*(int *)(param_1 + 0x2c0) + -0x18,local_res8);
if (cVar1 != '\0') {
return 1;
}
if (param_3 != (int *)0x0) {
*param_3 = local_res8[0] + 0x18;
}
}
else if (param_3 != (int *)0x0) {
*param_3 = 0;
}
return 0;
}
Full decompilation of NtfsCheckViewIndexEntries before the patch:
(retrieved via MCP tools)
Post patch functions
Full decompilation of NtfsCheckIndexBuffer after the patch:
undefined8 NtfsCheckIndexBuffer(longlong param_1,int *param_2,int *param_3)
{
char cVar1;
int iVar2;
int local_res8 [2];
local_res8[0] = 0;
if (((*param_2 != 0x58444e49) || (0x1ee < *(ushort *)(param_2 + 1))) ||
((uint)*(ushort *)((longlong)param_2 + 6) * 0x200 + -0x200 != *(int *)(param_1 + 0x2c0))) {
if (param_3 == (int *)0x0) {
return 0;
}
*param_3 = 0;
return 0;
}
param_2 = param_2 + 6;
cVar1 = NtfsCheckIndexHeader(param_2,*(int *)(param_1 + 0x2c0) + -0x18,local_res8);
if (cVar1 != '\0') {
iVar2 = Feature_2157166907__private_IsEnabledDeviceUsage();
if ((iVar2 != 0) && (*(longlong *)(param_1 + 0x290) == 1)) {
local_res8[0] = 0;
cVar1 = NtfsCheckFileNameIndexEntries(param_2,local_res8);
if (cVar1 == '\0') goto LAB_0;
}
iVar2 = Feature_1838391611__private_IsEnabledDeviceUsage();
if ((iVar2 == 0) || ((*(uint *)(param_1 + 0x1f0) & 4) == 0)) {
return 1;
}
local_res8[0] = 0;
cVar1 = NtfsCheckViewIndexEntries(param_2,local_res8);
if (cVar1 != '\0') {
return 1;
}
}
LAB_0:
if (param_3 != (int *)0x0) {
*param_3 = local_res8[0] + 0x18;
}
return 0;
}
Full decompilation of NtfsCheckFileNameIndexEntries after the patch:
undefined8 NtfsCheckFileNameIndexEntries(uint *param_1,int *param_2)
{
byte bVar1;
int iVar2;
longlong lVar3;
lVar3 = (ulonglong)*param_1 + (longlong)param_1;
bVar1 = *(byte *)(lVar3 + 0xc);
while( true ) {
if ((bVar1 & 2) != 0) {
return 1;
}
if ((((*(ushort *)(lVar3 + 10) < 0x44) || (*(byte *)(lVar3 + 0x50) == 0)) ||
((ulonglong)(*(ushort *)(lVar3 + 10) - 0x44) + 2 < (ulonglong)*(byte *)(lVar3 + 0x50) * 2))
|| (((iVar2 = Feature_3876572475__private_IsEnabledDeviceUsage(), iVar2 != 0 &&
((*(byte *)(lVar3 + 0x51) & 2) != 0)) && (0xc < *(byte *)(lVar3 + 0x50))))) break;
lVar3 = lVar3 + (ulonglong)*(ushort *)(lVar3 + 8);
bVar1 = *(byte *)(lVar3 + 0xc);
}
if (param_2 != (int *)0x0) {
*param_2 = (int)lVar3 - (int)param_1;
}
return 0;
}
Full decompilation of NtfsCheckViewIndexEntries after the patch:
(retrieved via MCP tools)