CVE-2026-49184 CVSS 8.4 July 14, 2026
← All reports

CVE-2026-49184: Heap Out-of-Bounds in the Windows NTFS Directory Index Buffer Parser

Community check
      Suggest change

      Reports here are AI-generated and may be wrong. Edit the source directly on GitHub — saving opens a pull request. Add yourself to the report's editors: list in the same PR and your name will appear at the bottom once it's merged.

      ✎ Edit on GitHub (opens a PR)

      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:

      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:

      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:

      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:

      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)