ntfs.sys — TxfSatisfyFlushForTopsRange RTL_BITMAP pool allocation rounded up to ULONG boundary (feature-staged; latent ~2-byte OOB bitmap access)
KB5095091
1. Overview
| Item | Value |
|---|---|
| Unpatched binary | ntfs_unpatched.sys (10.0.28000.2179) |
| Patched binary | ntfs_patched.sys (10.0.28000.2340) |
| Overall similarity | 0.9927 |
| Matched functions | 4417 |
| Changed functions | 9 |
| Identical functions | 4408 |
| Unmatched (unpatched / patched) | 0 / 0 |
Verdict (corrected): The single security-adjacent change is in TxfSatisfyFlushForTopsRange. The unpatched code allocates a pool buffer that embeds an RTL_BITMAP and sizes the bitmap storage as SizeOfBitMap_bits/8 bytes without rounding the total allocation up to a 32-bit ULONG boundary. Because the Rtl bitmap helpers touch whole 32-bit words, when the bit count is 16 mod 32 the trailing ULONG of the bitmap extends ~2 bytes past the logical end of the allocation. The patch rounds the allocation size up to a 4-byte multiple so the trailing word is fully backed.
This is a correctness / robustness (defense-in-depth) fix, not a demonstrable exploitable pool overflow. In practice the ~2 trailing bytes are absorbed by the pool allocator's 16-byte rounding slack (they never reach an adjacent allocation), the trailing-word access is a read plus a value-preserving read-modify-write (no attacker-controlled data is written out of bounds), and only RtlSetBits and one _bittest64 read ever touch the undersized buffer. The out-of-bounds access is observable as a fault only under Special Pool / Driver Verifier. Severity is therefore Low, not High.
Delivery status: STAGED, not unconditionally delivered. The fix is gated behind the WIL feature flag Feature_NTFS_BR_TxfSatisfyFlushForTopsRangeBitmapSizeAlignFix. When the flag is disabled the patched binary executes the old (unrounded) +0x58 code. The runtime default is resolved by WIL at run time (wil_details_IsEnabledFallback) and is not a compile-time constant readable from the binary, so it cannot be proven enabled from static analysis.
Of the 9 changed functions, only TxfSatisfyFlushForTopsRange carries the fix. The remaining 8 are recompilation/codegen drift (register renaming, ETW/logsup GUID renames, semantically identical branch inversions, inline/outline refactors) plus WIL feature-staging plumbing (wil_details_FeatureStateCache_ReevaluateCachedFeatureEnabledState, wil_details_RegisterFeatureUsageProvider) that exists only to register and cache the new flag. An independent line-by-line pass over all 8 confirmed none of them adds or changes a bounds check, allocation size, memcpy/memset length, loop bound, or validation gate.
2. Vulnerability Summary
2.1 LOW — Undersized RTL_BITMAP pool allocation (bitmap not rounded to ULONG granularity) (CWE-131, latent CWE-125/CWE-787)
- Affected function:
TxfSatisfyFlushForTopsRange— unpatched@ 0x1401037F8, patched@ 0x140103808. Builds a TOPS (TxF Old Page Stream) log-array record that embeds anRTL_BITMAPinside a pooled'Ntf0'buffer. - Root cause: The function derives a bitmap bit count
v14(v21in the unpatched decompilation) that is always a multiple of 16, then sizes the pool allocation as(v14 >> 3) + 0x58bytes — exactlyv14/8bytes of bitmap storage at offset0x58— without rounding the total up to a 32-bit ULONG boundary.SizeOfBitMapis stored (in bits) at+0x54. +0x00: word = 1 (record type marker)+0x04: dword = 0x15+0x50: dword =v13=v5 & ~0xF(16-aligned base bit)+0x54:RTL_BITMAP.SizeOfBitMap=v14(bit count, multiple of 16, may be16 mod 32)+0x56: word = 0x58+0x58:RTL_BITMAP.Buffer(start of bitmap storage,v14/8bytes)- The flawed operation (unpatched):
mov eax, r14d
shr eax, 0x3 ; eax = v14 / 8 (bitmap bytes)
add ax, 0x58 ; +0x58 header; 16-bit add, NO ULONG rounding
movzx eax, ax
- Why it is a latent bug: The Rtl bitmap helpers operate on whole 32-bit ULONGs. When
v14 % 32 == 16(i.e. 16, 48, 80, 112, …),v14/8 ≡ 2 (mod 4), so the bitmap byte count is not a multiple of 4 and the final ULONG covering the bitmap extends 2 bytes beyond thev14/8-byte buffer — i.e. 2 bytes past the logical end of the allocation. - Why it is NOT a meaningful memory-corruption primitive in practice:
- The only Rtl operation on the undersized buffer is
RtlSetBits(&BitMapHeader, i - v13, 1)(single-bit sets).RtlFindSetBits,RtlClearBit, andRtlAreBitsClearin this function all operate on a different, persistent Scb bitmap ata2+0x140([rdi+0x28]in asm), not on the freshly allocated buffer. - Every bit index passed to
RtlSetBitsis in[0, v14), i.e. in-bounds. IfRtlSetBitsread-modify-writes the containing ULONG, it reads the 2 out-of-bounds trailing bytes and writes them back unchanged (the modified bit is always in-bounds). No attacker-controlled value is written out of bounds. ExAllocatePoolWithTagrounds allocations up to 16-byte granularity. For every value ofv14the 2 trailing out-of-bounds bytes fall inside the block's own rounding slack (minimum slack in the tightest case is exactly 2 bytes), so on production pool the access never reaches an adjacent allocation.- The over-read feeds only the internal bitmap; nothing is returned to user mode, so there is no information disclosure to the attacker.
- Net effect: a latent out-of-bounds access that is inert on default systems and surfaces only as a fault under Special Pool / Driver Verifier (which places the allocation against a guard page). That makes it a correctness / hardening fix.
- What the patch does: Adds
Feature_NTFS_BR_TxfSatisfyFlushForTopsRangeBitmapSizeAlignFix__private_IsEnabledDeviceUsageNoInline(). When enabled, the size becomes((v14 >> 3) + 0x5B) & ~3— round(v14/8 + 0x58)up to the next 4-byte boundary so the trailing bitmap ULONG is fully inside the allocation. When the flag is OFF the old(v14 >> 3) + 0x58path runs, so the fix is not unconditional.SizeOfBitMapat+0x54is unchanged; only the allocation grew. - Attacker reachability:
TxfSatisfyFlushForTopsRangeis on the Transactional-NTFS (TxF) old-page-stream flush path, reachable by a local user operating on a file opened inside a KTM transaction. The specific call chain and the exact user-controlled inputs that drivev14to16 mod 32were not proven end-to-end here; they are consistent with the function's arithmetic but are inferred, not demonstrated.
3. Pseudocode Diff
3.1 TxfSatisfyFlushForTopsRange
// UNPATCHED — TxfSatisfyFlushForTopsRange @ 0x1401037F8 (Hex-Rays, verbatim variable names)
v13 = v5 & 0xFFFFFFF0; // 16-aligned base bit
v21 = ((v5 + v4 - 1) & 0xFFFFFFF0) + 16 - (v5 & 0xFFFFFFF0); // bit count, ALWAYS multiple of 16
Size = (unsigned __int16)((v21 >> 3) + 88); // <-- size = v21/8 + 0x58, NOT rounded to ULONG
PoolWithTag = ExAllocatePoolWithTag((PoolType | 0x10), Size, 0x3066744E); // 'Ntf0'
memset(PoolWithTag, 0, Size);
*(_WORD *)PoolWithTag = 1;
PoolWithTag[1] = 21;
*((_WORD *)PoolWithTag + 43) = 88; // +0x56
*((_WORD *)PoolWithTag + 42) = v21; // +0x54 RTL_BITMAP.SizeOfBitMap (bits)
PoolWithTag[20] = v5 & 0xFFFFFFF0; // +0x50
BitMapHeader.Buffer = PoolWithTag + 22; // +0x58 RTL_BITMAP.Buffer
BitMapHeader.SizeOfBitMap = *((unsigned __int16 *)PoolWithTag + 42);
for ( i = v5 & 0xFFFFFFF0; i < v13 + v21; ++i ) {
if ( _bittest64(p_Buffer[6], i) && !_bittest64(p_Buffer[4], i) )
RtlSetBits(&BitMapHeader, i - v13, 1u); // single-bit sets, index always < v21
}
// later: RtlClearBit((PRTL_BITMAP)(p_Buffer + 5), m); <-- operates on the Scb bitmap at a2+0x140, NOT this buffer
// PATCHED — TxfSatisfyFlushForTopsRange @ 0x140103808
v14 = ((v5 + v4 - 1) & 0xFFFFFFF0) + 16 - (v5 & 0xFFFFFFF0); // same bit-count derivation
v15 = v14 >> 3;
if ( Feature_NTFS_BR_TxfSatisfyFlushForTopsRangeBitmapSizeAlignFix__private_IsEnabledDeviceUsageNoInline() )
v16 = (v15 + 91) & 0xFFFC; // (v15 + 0x5B) & ~3 -> round v15+0x58 up to ULONG boundary
else
v16 = v15 + 88; // old (unrounded) path retained when flag OFF
PoolWithTag = ExAllocatePoolWithTag((PoolType | 0x10), v16, 0x3066744E);
// remainder identical: SizeOfBitMap still v14; only the allocation size may grow
Key changes:
- Unpatched: Size = (v21 >> 3) + 0x58, computed with a 16-bit add ax, 0x58 and no rounding.
- Patched (flag ON): v16 = ((v14 >> 3) + 0x5B) & ~3 — equivalent to (v15 + 0x58 + 3) & ~3, rounding the byte count up so the final 32-bit bitmap word is fully inside the allocation.
- The fix is feature-gated: with the flag OFF the patched binary runs v16 = v15 + 0x58, identical to the unpatched code.
4. Assembly Analysis
4.1 TxfSatisfyFlushForTopsRange
Unpatched — size computation and bitmap setup (real addresses):
0000000140103916 mov eax, r14d
0000000140103919 shr eax, 3 ; eax = v14 / 8 (bitmap bytes)
000000014010391C add ax, 58h ; +0x58 header; 16-bit add, NO ULONG rounding
0000000140103920 movzx eax, ax
0000000140103923 mov [rsp+118h+Size], rax
...
000000014010393A mov edx, eax ; NumberOfBytes (undersized)
000000014010393C call cs:__imp_ExAllocatePoolWithTag ; tag 0x3066744E 'Ntf0'
...
000000014010397B mov [r15+54h], r14w ; RTL_BITMAP.SizeOfBitMap = v14 (bits, multiple of 16)
0000000140103984 lea rax, [r15+58h] ; RTL_BITMAP.Buffer = P + 0x58
...
0000000140103A0B call cs:__imp_RtlSetBits ; RtlSetBits(&BitMapHeader, i - v13, 1) <-- only op on the undersized buffer
Notes:
- 0x140103919–0x14010391C (shr eax,3 ; add ax,0x58): the size calculation. The total is not rounded to a 4-byte boundary; v14/8 is a multiple of 2 but can be 2 mod 4 (e.g. 16 bits -> 2 bytes).
- 0x14010385C / 0x1401038C5 (lea rcx,[rdi+28h] -> RtlAreBitsClear / RtlFindSetBits): rdi = rdx+0x118, so rdi+0x28 = a2+0x140. These operate on the persistent Scb bitmap, not the undersized allocation.
- 0x140103A0B (RtlSetBits on &BitMapHeader, whose Buffer = P+0x58): the only Rtl write into the undersized buffer, single-bit, in-bounds index.
Patched — feature-gated aligned size (real addresses):
0000000140103930 movzx r14d, ax
0000000140103934 call Feature_NTFS_BR_TxfSatisfyFlushForTopsRangeBitmapSizeAlignFix__private_IsEnabledDeviceUsageNoInline
0000000140103939 test eax, eax
000000014010393B jz short loc_140103947 ; flag OFF -> old size
000000014010393D lea rax, [r14+5Bh] ; NEW: +0x5B
0000000140103941 and rax, 0FFFFFFFFFFFFFFFCh ; & ~3 -> round total up to ULONG boundary
0000000140103945 jmp short loc_14010394B
0000000140103947 loc_140103947:
0000000140103947 lea rax, [r14+58h] ; OLD size (unchanged) when flag OFF
000000014010394B movzx r14d, ax
...
0000000140103969 call cs:__imp_ExAllocatePoolWithTag
Notes:
- The lea rax,[r14+5Bh]; and rax,~3 pair exists only in the patched binary and only on the enabled branch.
- With the flag disabled, execution falls through to lea rax,[r14+58h], identical to the unpatched size. Presence of the patch does not guarantee the fix is active.
5. Trigger Conditions
- As a local user, open a file inside a KTM transaction (
CreateTransaction+CreateFileTransacted) on an NTFS volume so writes route through the TxF old-page-stream logging path. - Issue writes that drive the TOPS flush (
TxfSatisfyFlushForTopsRange) with a 16-aligned spanv14satisfyingv14 & 0x1F == 0x10(i.e. 16, 48, 80, … bits). The entryRtlAreBitsClear(a2+0x140, a3, a4)must return FALSE so the allocation/flush branch is taken. - When that condition holds,
RtlSetBits(and a_bittest64read) touch the final ULONG whose upper ~2 bytes lie past the logical allocation end. - Observable effect: with Special Pool / Driver Verifier enabled on tag
Ntf0, the trailing-word access can fault against the guard page (DRIVER_VERIFIER_DETECTED_VIOLATION/PAGE_FAULT_IN_NONPAGED_AREA). Without Special Pool the access lands inside the allocation's 16-byte rounding slack and has no observable effect (no adjacent-object corruption, no info leak).
Important: the mapping from user-controlled write offset/length to v14 was not proven end-to-end. The condition v14 % 32 == 16 is reachable in principle (e.g. a minimal single-page span yields v14 = 16), but a concrete WriteFile sequence that both reaches this branch and lands in an unmapped page was not demonstrated.
6. Impact Assessment (corrected)
- Proven primitive: a latent, ULONG-granular out-of-bounds access of up to ~2 bytes past a pooled
RTL_BITMAP— a read plus a value-preserving read-modify-write. It is not a controllable out-of-bounds write of attacker data. - Why it does not yield a memory-corruption exploit:
- The 2 trailing bytes are absorbed by 16-byte pool rounding on production systems, so no adjacent allocation is reached.
- The RMW writes the out-of-bounds bytes back unchanged; the attacker controls neither the value nor a write past the pool block.
- The over-read is consumed internally (bitmap logic) and never returned to user mode, so there is no information disclosure.
- Realistic worst case: on a system with Special Pool / Driver Verifier active on tag
Ntf0, the guard-page access is a local, non-privileged fault (bugcheck / DoS) — and even that requires the attacker to have Special Pool enabled, which is not the default. On a default system the effect is nil. - Not substantiated (removed from the prior version of this report): controllable pool out-of-bounds write, pool grooming to place a victim object, adjacent-object header/refcount corruption, chaining to arbitrary write, local privilege escalation to SYSTEM, and any KASLR / SMEP / CFG / CET / HVCI discussion. None of these are evidenced by the diff; the primitive does not support them.
7. Debugger Notes
Assume WinDbg/KD attached to the UNPATCHED binary.
Breakpoints:
bp ntfs!TxfSatisfyFlushForTopsRange ; 0x1401037F8 entry: rcx=IrpContext, rdx=Scb/TOPS ctx, r8d=range start, r9d=range count
bp ntfs+0x10393C ; ExAllocatePoolWithTag call: edx = requested (undersized) size, r8d = 'Ntf0'
ba w1 ntfs+0x103A0B ; RtlSetBits call site; observe the trailing-ULONG access
Rebase note: analysis base is 0x140000000; subtract it to get the RVA, then bp ntfs+<rva>.
Inspect:
- At the ExAllocatePoolWithTag call (0x14010393C): edx = (v14>>3)+0x58. Compute v14 = (edx - 0x58) * 8; if v14 % 32 == 16 the bitmap trailing ULONG spills 2 bytes past the logical size. After the call rax = r15 = pool pointer; bitmap runs [r15+0x58 .. r15+0x58+(v14/8)), logical block end is r15 + edx.
? (edx - 0x58) * 8 ; = v14 (bits)
? ((edx - 0x58) * 8) % 0x20 ; == 0x10 marks the overflowing case
- At
RtlSetBits(0x140103A0B):RCX=&BitMapHeader(SizeOfBitMap = v14,Buffer = r15+0x58),RDX= start index (in-bounds),R8= 1. The trailing word atr15 + 0x58 + ((v14/8) & ~3)is touched even though it lies pastr15 + edx— but note this is inside the pool block's rounding slack unless Special Pool is active.
Expected observation: with Special Pool on tag Ntf0, an immediate fault at the bitmap trailing ULONG address. Without Special Pool, no observable effect: the access is inside the allocation's rounded size. On the patched build with the flag enabled, edx is (…)&~3 and the trailing word is fully backed.
Struct / offset notes:
'Ntf0' TOPS record layout (P = ExAllocatePoolWithTag result):
+0x00 word = 1
+0x04 dword = 0x15
+0x50 dword = v13 (= v5 & ~0xF, 16-aligned base bit)
+0x54 ULONG = SizeOfBitMap = v14 (bits, multiple of 16) <-- RTL_BITMAP.SizeOfBitMap
+0x56 word = 0x58
+0x58 ... = RTL_BITMAP.Buffer (v14/8 bytes)
Size calc (unpatched): size = (v14 >> 3) + 0x58 ; NOT rounded
Overflowing case: v14 & 0x1F == 0x10 ; trailing ULONG spans ~2 bytes past logical size
Only buffer op: RtlSetBits(&BitMapHeader, idx, 1) ; single-bit, in-bounds index
Scb-bitmap ops (NOT this buffer): RtlAreBitsClear / RtlFindSetBits / RtlClearBit on a2+0x140
8. Changed Functions — Full Triage
TxfSatisfyFlushForTopsRange(0x1401037F8 -> 0x140103808) (similarity 0.9586, security-relevant): the only meaningful diff. Unpatched sizes the'Ntf0'bitmap record as(v14>>3)+0x58with a 16-bit add and no ULONG rounding. Patched adds theFeature_NTFS_BR_TxfSatisfyFlushForTopsRangeBitmapSizeAlignFixgate selecting((v14>>3)+0x5B) & ~3. Correctness/hardening fix; feature-staged. Remaining register renames and ETW/helper address shifts are recompilation noise.NtfsCommitCurrentTransaction(similarity 0.92, cosmetic): rsi<->rdi register swap, ETW GUID rename (1c495e8d…->98a93039…), a semantically identical branch-condition inversion, and an inline iso-snapshot cleanup loop outlined into aTxfCleanupIsoSnapshots()call. No behavioral change; no bounds/size change.NtfsCheckpointForVolumeSnapshot(similarity 0.9388, cosmetic):NtfsIsVcbAvailableForThisRequesterror/main paths swapped (control-flow inversion, identical behavior), ETW GUID rename, reordered NTSTATUS comparison chain, decompiler-reconstructed call arguments. Codegen noise.NtfsCheckpointForVolumeSnapshot$fin$0(similarity 0.9712, cosmetic): the differ tracked the exception-cleanup funclet ofNtfsCheckpointForVolumeSnapshot; it does not surface as a distinct function in the decompiled C (folded into its parent). Consistent with the parent's churn; no behavioral change.NtfsCheckpointVolume(similarity 0.9614, cosmetic): large function; a top-level branch inversion (if(a3==0)->if(a3!=0)) with the guarded-mutex wait relocated to the end and rejoining the common path, register reallocation, and ETW/logsup renames. All allocation sizes, memmove/memset lengths, and restart-table counts are byte-identical; apparent offset differences are decompiler pointer-type artifacts. No added bounds/validation.NtfsFlushLsnStreamsOnLogTooFull(similarity 0.9713, cosmetic): ETW logsup symbol renames and basic-block address shifts only. No behavioral change.TxfPrepareFileForTxfLogging(similarity 0.9922, cosmetic): interprocedural refactor — the caller now passes transaction/target-scb/scb explicitly toTxfAddTxfFileIdinstead of the callee re-deriving them. All memset lengths unchanged. No bounds/size/overflow change.wil_details_FeatureStateCache_ReevaluateCachedFeatureEnabledState(similarity 0.975, behavioral but non-security): a new state-tracking bit0x40000is folded into the atomically-stored WIL feature-state word (mask0xFFFFF63E->0xFFFBF63E, plus explicit| 0x40000). Feature-staging cache bookkeeping for the new flag; gates no memory operation, allocation, or bound.wil_details_RegisterFeatureUsageProvider(similarity 0.9853, behavioral but non-security): initializes a previously-unset provider-descriptor version/flags field (v2 = 1; v3 = 0) before registration. WIL infrastructure tied to the new flag; not a memory-safety change.
Collapsed note: all eight non-target functions are recompilation/codegen drift or WIL feature-staging plumbing that exists only to register, cache, and gate the single fix in TxfSatisfyFlushForTopsRange. An independent per-function pass confirmed none adds or changes a bounds check, allocation size, copy length, loop bound, or input validation.
9. Unmatched Functions
- Removed (unpatched only): None.
- Added (patched only): None (the WIL helper
Feature_NTFS_BR_...IsEnabled*was matched/attributed in-line, not reported as an unmatched add). - Implication: the change is additive within one existing function. It is feature-flag gated (
Feature_NTFS_BR_TxfSatisfyFlushForTopsRangeBitmapSizeAlignFix): the unpatched binary lacks the ULONG rounding unconditionally, and the patched binary runs the old path when the flag is disabled.
10. Confidence & Caveats
- Confidence: high on the code facts; the security classification (Low, staged) is high-confidence given the containment analysis.
- Rationale:
- The size arithmetic is unambiguous in both
.cand.asm:(v14>>3)+0x58(16-bitadd, no rounding) vs the patched(v15+0x5B)&~3. The feature-flag name (…BitmapSizeAlignFix) corroborates a bitmap allocation-alignment fix (CWE-131). - The out-of-bounds access is real at the ULONG-granularity level but is (a) confined to the only buffer op that matters (
RtlSetBits, single-bit), (b) a value-preserving RMW, (c) contained within 16-byte pool rounding slack, and (d) not exposed to user mode — so it does not constitute an exploitable memory-corruption primitive on default systems. RtlFindSetBits/RtlClearBit/RtlAreBitsClearwere confirmed to operate on the persistent Scb bitmap ata2+0x140, not on the undersized allocation (verified in asm:lea rcx,[rdi+0x28],rdi=a2+0x118).- Not proven end-to-end:
- The exact
WriteFileoffset/length ->v14 % 32 == 16mapping and the reachability of the allocation branch (RtlAreBitsClearreturning FALSE) are inferred from the arithmetic, not demonstrated. - The runtime default of the WIL feature flag is resolved by
wil_details_IsEnabledFallbackat run time and is not statically determinable, so whether the fix is active in shipped configurations cannot be confirmed from the binary alone.