ntfs.sys — Feature-gated bounds/size validation in $LogFile restart-table recovery made unconditional (CWE-787) — fixed
KB5082123
1. Overview
| Field | Value |
|---|---|
| Unpatched binary | ntfs_unpatched.sys |
| Patched binary | ntfs_patched.sys |
| Overall similarity | 0.9924 |
| Matched functions | 4047 |
| Changed functions (behavioral) | 3 |
| Changed functions (tracing metadata only) | 14 |
| Unmatched (unpatched) | 0 |
| Unmatched (patched) | 0 |
Verdict: In the NTFS transaction-log ($LogFile) restart/recovery path, three validation checks — a copy-loop index bounds check, a restart-table size-consistency check, and an integer-overflow-guarded size check before a restart-table memmove — were gated behind a runtime feature-staging flag (g_Feature_2076398905_59354624, evaluated through EvaluateCurrentState/EvaluateFeature). The patch removes that feature gate in all three functions, so the checks now execute unconditionally. This is a real, correct-direction hardening of NTFS log-recovery parsing of on-disk restart-table structures. It is delivered by completing a staged rollout of these checks, not by adding a new mechanism; the runtime default state of the feature in the unpatched build cannot be determined from the binaries, so a reachable overflow in the shipped default configuration is not demonstrated here.
The gate is a WIL feature-staging flag, not a checked-build (#if DBG) assertion: EvaluateCurrentState is present and executed on retail builds. Fourteen additional functions differ only in WPP software-tracing metadata (trace line-number immediates and the per-build trace-GUID control-block symbol) and carry no logic change.
2. Vulnerability Summary
Finding 1 — Medium: Feature-gated out-of-bounds write bounds check in PageUpdateAnalysis
| Field | Detail |
|---|---|
| Severity | Medium |
| CWE | CWE-787 (Out-of-bounds Write) |
| Function | PageUpdateAnalysis (unpatched 0x1C0205DC4, patched 0x1C0205D04) |
| Entry point | Crafted NTFS volume (VHD / USB) whose $LogFile restart area is parsed during volume mount / log recovery |
Root cause: The function copies extent entries from a source restart-table record (r15, loaded from [rsi+0x48]) into a destination restart-table entry (rdi) allocated by NtfsAllocateRestartTableIndex. The destination write index is computed as ecx = [r15+0x18] − [rdi+0x10] + loop_counter, where [r15+0x18] is a VCN-style field taken from the on-disk log record. A bounds check cmp ecx, [rdi+0xc] / jnb (index vs. the destination capacity at +0x0c) exists in both builds, but in the unpatched build it is reached only when EvaluateCurrentState(&g_Feature_2076398905_59354624) returns non-zero: the test eax,eax / jz at 0x1C020606F–0x1C0206071 skips the cmp/jnb when the feature evaluates to zero, and the 8-byte write at 0x1C0206083 then proceeds with an unchecked index. The patch removes the EvaluateCurrentState call, the test, and the jz, so the bounds check always guards the write.
Call chain:
- Mount a crafted NTFS volume (VHD / USB); NTFS performs
$LogFilerestart/recovery. NtfsRestartVolume(0x1C016D7C0).AnalysisPass(0x1C0200EFC) — restart analysis pass over the restart tables.PageUpdateAnalysis(0x1C0205DC4) — restart-table entry copy at0x1C02012A3.- 8-byte write at
0x1C0206083:mov [rdi+rcx*8+0x20], rax.
Finding 2 — Low: Feature-gated restart-table size-consistency check in ReadRestartTable
| Field | Detail |
|---|---|
| Severity | Low |
| CWE | CWE-20 (Improper Input Validation) |
| Function | ReadRestartTable (unpatched 0x1C020634C, patched 0x1C0206294) |
| Entry point | Crafted NTFS volume whose $LogFile restart record has inconsistent size / entry-count fields |
Root cause: The function computes an expected restart-table size edi = (entry_count << 3) + 0x20 (or 0x28 when entry_count <= 1) from [r15+0x0e] and compares it to the declared size [r15+0x04]. In the unpatched build the comparison cmp edi, [r15+4] / jnz is gated behind EvaluateCurrentState(&g_Feature_2076398905_59354624) (the test eax,eax / jz at 0x1C0206401–0x1C0206403). When the feature evaluates to zero the mismatch is not raised as corruption, and the function proceeds to call the validator NtfsCheckRestartTable (0x1C01BC394) with a length of [rsi+0x40] − declared_size. The patch makes the size-consistency comparison unconditional. NtfsCheckRestartTable is a restart-table validation routine, not a memory-copy primitive; the effect of a bypassed check here is a mis-validated restart table rather than a demonstrated out-of-bounds copy.
Call chain:
- Mount a crafted NTFS volume;
$LogFilerestart/recovery. NtfsRestartVolume(0x1C016D7C0) →InitializeRestartState(0x1C016DA08).ReadRestartTable(0x1C020634C) at0x1C016DEF8.NtfsCheckRestartTable(0x1C01BC394) called with a length derived from the declared size.
Finding 3 — Medium: Feature-gated integer-overflow-guarded size check before restart-table memmove in InitializeRestartState
| Field | Detail |
|---|---|
| Severity | Medium |
| CWE | CWE-190 (Integer Overflow) leading to CWE-787 (Out-of-bounds Write) |
| Function | InitializeRestartState (0x1C016DA08, same address both builds) |
| Entry point | Same crafted-volume $LogFile restart/recovery surface |
Root cause: This is the top-level restart-state setup function that calls ReadRestartTable and copies restart-table entries with memmove. It contains three EvaluateCurrentState(&g_Feature_2076398905_59354624) gates (at 0x1C016DF20, 0x1C016E01B, 0x1C016E11D). At the copy site around 0x1C016E01B, when the feature evaluates to non-zero the size for the copy is computed with RtlULongMult((count−1), 8) (an overflow-checked multiply) before the bound compare and the memmove at 0x1C016E099; when the feature evaluates to zero the size is computed with a plain shl rcx, 3 that performs no overflow detection before the same compare. The patch removes all three gates, so the overflow-checked computation and the associated size checks run unconditionally.
3. Pseudocode Diff
PageUpdateAnalysis — copy-loop bounds check
// ============ UNPATCHED (0x1C0206051-0x1C0206091) ============
for (ebx = 0; ebx < src->entry_count /* [r15+0x0e] */; ebx++) {
// feature gate: check reached only when EvaluateCurrentState != 0
int enabled = EvaluateCurrentState(&g_Feature_2076398905_59354624);
unsigned idx = src->vcn /* [r15+0x18] */
- dst->start_vcn /* [rdi+0x10] */
+ ebx;
if (enabled && idx >= dst->capacity /* [rdi+0x0c] */)
NtfsRaiseStatus(STATUS_DISK_CORRUPT); // skipped when enabled == 0
dst->extents[idx] = src->extents[ebx]; // 8-byte write, 0x1C0206083
}
// ============ PATCHED (0x1C0205F9C-0x1C0205FDB) ============
for (r8d = 0; r8d < src->entry_count; r8d++) {
unsigned idx = src->vcn - dst->start_vcn + r8d;
if (idx >= dst->capacity) // unconditional
NtfsRaiseStatus(STATUS_DISK_CORRUPT);
dst->extents[idx] = src->extents[r8d];
}
ReadRestartTable — size-consistency check
// ============ UNPATCHED (0x1C02063D3-0x1C0206428) ============
int computed = (entry_count <= 1) ? 0x28
: (entry_count << 3) + 0x20; // [r15+0x0e]
if (EvaluateCurrentState(&g_Feature_2076398905_59354624)
&& computed != declared_size /* [r15+0x04] */)
NtfsRaiseStatus(STATUS_DISK_CORRUPT); // skipped on feature == 0
NtfsCheckRestartTable(entry + computed,
length - declared_size /* [rsi+0x40] - [r15+0x04] */, ...);
// ============ PATCHED (0x1C0206315-0x1C0206355) ============
if (computed != declared_size) // unconditional
NtfsRaiseStatus(STATUS_DISK_CORRUPT);
NtfsCheckRestartTable(...);
4. Assembly Analysis
PageUpdateAnalysis — Unpatched copy loop (0x1C0205DC4)
00000001C0206051 mov ebx, esi
00000001C0206053 cmp si, [r15+0Eh] ; counter vs entry count
00000001C0206058 jnb short loc_1C0206093 ; loop exit
00000001C020605A lea rcx, g_Feature_2076398905_59354624_FeatureDescriptorDetails
00000001C0206061 call EvaluateCurrentState ; runtime feature evaluation
00000001C0206066 mov ecx, [r15+18h] ; src VCN field (from on-disk log record)
00000001C020606A sub ecx, [rdi+10h] ; minus dest start VCN
00000001C020606D add ecx, ebx ; plus loop counter -> write index
00000001C020606F test eax, eax ; feature result
00000001C0206071 jz short loc_1C020607C ; feature == 0 -> skip bounds check
00000001C0206073 cmp ecx, [rdi+0Ch] ; index vs capacity
00000001C0206076 jnb loc_1C020612E ; -> STATUS_DISK_CORRUPT
00000001C020607C mov eax, ebx
00000001C020607E mov rax, [r15+rax*8+20h] ; read source extent
00000001C0206083 mov [rdi+rcx*8+20h], rax ; 8-byte write at computed index
00000001C0206088 inc ebx
00000001C020608A movzx eax, word ptr [r15+0Eh]
00000001C020608F cmp ebx, eax
00000001C0206091 jmp short loc_1C0206058
PageUpdateAnalysis — Patched copy loop (0x1C0205D04)
00000001C0205F9C mov r8d, r14d
00000001C0205F9F cmp r14w, [r13+0Eh] ; counter vs entry count
00000001C0205FA4 jnb short loc_1C0205FDD
00000001C0205FA6 mov r9, [rsp+188h+var_148] ; dest restart-table entry
00000001C0205FAB mov ecx, [r13+18h] ; src VCN field
00000001C0205FAF sub ecx, [r9+10h] ; minus dest start VCN
00000001C0205FB3 add ecx, r8d ; plus loop counter -> write index
00000001C0205FB6 cmp ecx, [r9+0Ch] ; index vs capacity (unconditional)
00000001C0205FBA jnb short loc_1C0205FFE ; -> STATUS_DISK_CORRUPT
00000001C0205FBC mov eax, r8d
00000001C0205FBF mov edx, ecx
00000001C0205FC1 mov rcx, [r13+rax*8+20h] ; read source extent
00000001C0205FC6 mov rax, [rsp+188h+var_148]
00000001C0205FCB mov [rax+rdx*8+20h], rcx ; 8-byte write at checked index
00000001C0205FD0 inc r8d
00000001C0205FD3 movzx eax, word ptr [r13+0Eh]
00000001C0205FD8 cmp r8d, eax
00000001C0205FDB jb short loc_1C0205FAB
The unpatched build has the sequence lea rcx, g_Feature_2076398905_59354624 / call EvaluateCurrentState / test eax,eax / jz gating the cmp ecx,[rdi+0xc] / jnb. The patch removes those four instructions; the cmp/jnb always executes.
ReadRestartTable — Unpatched key sequence (0x1C020634C)
00000001C02063D3 movzx eax, word ptr [r15+0Eh] ; entry count
00000001C02063DE cmp ax, r14w
00000001C02063E2 jbe short loc_1C02063F0
00000001C02063E7 lea edi, ds:20h[rax*8] ; computed = (count<<3)+0x20
00000001C02063EE jmp short loc_1C02063F5
00000001C02063F0 mov edi, 28h
00000001C02063F5 lea rcx, g_Feature_2076398905_59354624_FeatureDescriptorDetails
00000001C02063FC call EvaluateCurrentState
00000001C0206401 test eax, eax
00000001C0206403 jz short loc_1C0206412 ; feature == 0 -> skip size check
00000001C0206405 movzx eax, word ptr [r15+4] ; declared size
00000001C020640A cmp edi, eax
00000001C020640C jnz loc_1C02064CD ; -> STATUS_DISK_CORRUPT
00000001C0206412 mov r12d, edi
00000001C0206415 add r12, r15
00000001C0206420 sub edx, eax ; length = [rsi+40h] - declared size
00000001C0206428 call NtfsCheckRestartTable
In the patched build (0x1C0206294) the same region reads ... lea edi, ds:20h[rax*8] ... movzx ecx, word ptr [r8+4] / cmp edi, ecx / jnz <corrupt> with no EvaluateCurrentState gate.
InitializeRestartState — Unpatched gated copy path (0x1C016DA08)
00000001C016E01B lea rcx, g_Feature_2076398905_59354624_FeatureDescriptorDetails
00000001C016E022 call EvaluateCurrentState
00000001C016E02A mov r10d, [rbx+0Ch] ; entry count
00000001C016E02E lea ecx, [r10-1]
00000001C016E032 test eax, eax
00000001C016E034 jz short loc_1C016E06F ; feature == 0 -> plain shl path
00000001C016E03E lea edx, [r11+8]
00000001C016E042 call RtlULongMult ; overflow-checked (count-1)*8
00000001C016E047 test eax, eax
00000001C016E049 js loc_1C016E9E6 ; overflow -> error
00000001C016E064 cmp rcx, rax
00000001C016E067 jb loc_1C016E9E6
00000001C016E06D jmp short loc_1C016E08A
00000001C016E06F shl rcx, 3 ; no overflow check
00000001C016E081 cmp rdx, rcx
00000001C016E084 jb loc_1C016EA64
00000001C016E08A mov r8d, r10d
00000001C016E08D shl r8, 3
00000001C016E091 lea rdx, [r13+24h] ; Src
00000001C016E095 lea rcx, [rbx+20h] ; Dst
00000001C016E099 call memmove
The patch removes the three EvaluateCurrentState gates in this function, so the overflow-checked RtlULongMult path and the associated size compares run unconditionally.
5. Trigger Conditions
-
Prepare a crafted NTFS volume image (VHD or raw disk) whose transaction log (
$LogFile) restart area and restart tables contain the manipulated fields below. -
Manipulate the restart-record fields consumed by the recovery path:
ReadRestartTablereads the entry count at[record+0x0e]and the declared size at[record+0x04]; a mismatch with(count<<3)+0x20is the input to Finding 2.PageUpdateAnalysisreads the source VCN field at[record+0x18]and the entry count at[record+0x0e]; the write index issource_vcn − dest_start_vcn + counter, compared against the destination capacity at[dest+0x0c](Finding 1).-
InitializeRestartStatereads the entry count at[entry+0x0c]used to size the restart-tablememmove(Finding 3). -
Mount the volume (for example
CreateVirtualDisk+AttachVirtualDisk, or attaching a physical device). NTFS runs log recovery on mount, reachingNtfsRestartVolume → InitializeRestartState → ReadRestartTableandNtfsRestartVolume → AnalysisPass → PageUpdateAnalysis. -
Observed behavior. On the unpatched build, whether the gated checks execute depends on the runtime state of
g_Feature_2076398905_59354624. When a gated check does not execute, an inconsistent restart record is processed without that check; the patched build always raisesSTATUS_DISK_CORRUPT(0xC0000032) on the same inconsistency. A demonstrated pool overflow was not reproduced against the shipped default configuration.
6. Impact Assessment
- Operation guarded (Finding 1): an 8-byte write into the destination restart-table entry's extent array at index
source_vcn − dest_start_vcn + counter. If the feature-gated bounds check does not run and a crafted log record supplies an index beyond[dest+0x0c], the write lands past the allocation, which is why the corrected CWE is CWE-787. - Values involved: both the index and the written 8 bytes derive from the on-disk restart record, so a successful bypass would be a controllable pool write. This is stated as the guarded operation, not as a reproduced primitive.
- Not demonstrated: the runtime default of the feature flag in the unpatched build, a concrete out-of-range index surviving the other log-record validators (
NtfsCheckLogRecord,NtfsCheckRestartTable), and any exploitation beyond the write itself. Those claims are therefore not made here.
7. Verification Notes
Addresses use the preferred image base 0x1C000000; actual runtime addresses are the loaded module base plus the offset.
Finding 1 — PageUpdateAnalysis
| Offset (unpatched) | Instruction | Role |
|---|---|---|
0x1C0206061 |
call EvaluateCurrentState |
Feature evaluation — removed in patch |
0x1C020606F |
test eax, eax |
Feature result — removed in patch |
0x1C0206071 |
jz 0x1C020607C |
Skips bounds check when feature == 0 — removed in patch |
0x1C0206073 |
cmp ecx, [rdi+0xc] |
Bounds check — unconditional in patch |
0x1C0206076 |
jnb 0x1C020612E |
Branch to STATUS_DISK_CORRUPT |
0x1C0206083 |
mov [rdi+rcx*8+0x20], rax |
The guarded 8-byte write |
Restart-table entry fields referenced by the code: capacity at +0x0c, start VCN at +0x10, source VCN field at +0x18, entry count at +0x0e, extent array at +0x20.
Finding 2 — ReadRestartTable
| Offset (unpatched) | Instruction | Role |
|---|---|---|
0x1C02063D3 |
movzx eax, word [r15+0xe] |
Reads entry count |
0x1C02063F5 |
lea rcx, g_Feature_2076398905_59354624 |
Feature descriptor — removed in patch |
0x1C02063FC |
call EvaluateCurrentState |
Feature evaluation — removed in patch |
0x1C0206401–0x1C0206403 |
test eax,eax / jz |
Skips size check when feature == 0 — removed in patch |
0x1C020640A–0x1C020640C |
cmp edi,eax / jnz |
Size-consistency check — unconditional in patch |
Finding 3 — InitializeRestartState
Feature gates at 0x1C016DF20, 0x1C016E01B, 0x1C016E11D; the overflow-checked RtlULongMult size computation at 0x1C016E042 guards the restart-table memmove at 0x1C016E099.
8. Changed Functions — Full Triage
Behavioral changes (feature gate removed)
PageUpdateAnalysis (unpatched 0x1C0205DC4, patched 0x1C0205D04) — One of two EvaluateCurrentState(&g_Feature_2076398905_59354624) gates removed (the copy-loop gate). The cmp ecx,[rdi+0xc] / jnb bounds check, present in both builds, now runs unconditionally. Loop counter register changed (ebx→r8d) and WPP trace-line immediates shifted (0x1E1758/0x1E17CD → 0x1E17AB/0x1E1739); the second feature gate on g_Feature_2859161914_59426036 is present in both builds and unchanged.
ReadRestartTable (unpatched 0x1C020634C, patched 0x1C0206294) — The single EvaluateCurrentState gate on the (count<<3)+0x20 size-consistency check removed; the cmp/jnz now runs unconditionally. Stack frame and register allocation shifted; remaining differences are WPP trace-constant shifts.
InitializeRestartState (0x1C016DA08, same address both builds) — All three EvaluateCurrentState gates removed. The overflow-checked RtlULongMult size path and the associated compares now run unconditionally ahead of the restart-table memmove copies. Stack frame reduced 0x490→0x480 and registers reallocated; memmove call count is unchanged (both builds copy at the same sites).
Tracing-metadata-only changes (not security relevant)
Fourteen further functions differ only in WPP software-tracing metadata — trace line-number immediates and/or the per-build trace-GUID control-block symbol — with no control-flow, bounds-check, or copy change: AnalysisPass, DoAction, NtfsAbortTransaction, OpenAttributeForRestart, OpenAttributesForRestart, NtfsCloseAttributesFromRestart, NtfsIsTransactionCommitted, RedoPass, UndoPass, WPP_SF_qDLi, WPP_SF_qDLiD, WPP_SF_qDLiDDL, WPP_SF_qDLii, WPP_SF_qDLiiiiiiiii.
9. Unmatched Functions
No functions were added or removed. The patch consists entirely of in-place modifications: the removal of the g_Feature_2076398905_59354624 feature gate from three restart-recovery functions, plus tracing-metadata churn in fourteen others.
Implication: The validation logic was already present in the codebase behind a runtime WIL feature-staging flag. The patch promotes those checks to unconditional execution. No new validation helper or sanitizer was introduced.
10. Confidence & Caveats
Confidence: High for the mechanism; the exploitability is not established
The mechanism is verified directly against both builds: g_Feature_2076398905_59354624 is referenced five times across the three functions in the unpatched build and zero times in the patched build, and EvaluateCurrentState calls EvaluateFeature (WIL feature staging), so the gate is a runtime feature flag evaluated on retail builds — not a checked-build (#if DBG) assertion. The direction is confirmed: the patched build is strictly stricter (the checks always run).
What is and is not claimed
- Claimed: three real validation checks in NTFS
$LogFilerestart-table recovery were feature-gated in the unpatched build and made unconditional in the patched build; the guarded write inPageUpdateAnalysisuses an index and value derived from on-disk log-record fields. - Not claimed: a reproduced pool overflow, the runtime default state of the feature flag in the unpatched build, or any exploitation chain. These could not be determined from the binaries and are not asserted.
What to verify to raise or lower severity
- The runtime default of
g_Feature_2076398905_59354624in the unpatched build (enabled-by-default would mean the checks already ran on default retail; disabled-by-default would mean they did not). - Whether the upstream log-record validators (
NtfsCheckLogRecord,NtfsCheckRestartTable) already constrain the entry count / VCN fields enough to prevent an out-of-range index reachingPageUpdateAnalysis.