csc.sys — feature-gated directory-enumeration double-buffering refactor in CscQueryDirectory
KB5094127
1. Overview
| Field | Value |
|---|---|
| Unpatched binary | csc_unpatched.sys |
| Patched binary | csc_patched.sys |
| Module | csc.sys — Windows Offline Files / Client-Side Caching (CSC) RDBSS miniredirector |
| Overall similarity | 0.9867 |
| Matched functions | 1415 |
| Changed functions | 1 |
| Identical functions | 1414 |
| Unmatched (either direction) | 0 / 0 |
Verdict. Exactly one function changed: the CSC directory-enumeration handler CscQueryDirectory (sub_1C0069FF0 unpatched, sub_1C006C180 patched). The change adds a code path, gated behind the WIL feature toggle Feature_3405715771__private_IsEnabledDeviceUsage (sub_1C0007A98 in the patched build), that routes the directory-enumeration writes through a freshly allocated NonPagedPoolNx scratch buffer of the same length and then copies only the produced bytes back into the caller's output buffer. The buffer pointer (RX_CONTEXT+0x1b8) and its byte length (RX_CONTEXT+0x1c8) are a matched pair throughout both builds; every write in both builds is bounded by that length, and the patched scratch buffer is allocated to exactly that length. The change does not alter any size/allocation relationship and does not fix a demonstrable memory-safety defect. It is a feature-staged refactor (double-buffering of the enumeration output), not a vulnerability fix.
2. Change Summary
Finding #1 — CscQueryDirectory double-buffering refactor (sub_1C0069FF0 → sub_1C006C180)
- Severity: None (no security-relevant change)
- Class: Refactor / defense-in-depth, feature-flag (WIL) staged
- Affected function:
CscQueryDirectory(sub_1C0069FF0, unpatched) →CscQueryDirectory(sub_1C006C180, patched) - Function similarity: 0.8424
What the function does. CscQueryDirectory is the CSC miniredirector's directory-query worker. Its address is installed into the miniredirector dispatch table by CscInitializeDispatchTable (sub_1C008D338; the address of CscQueryDirectory is taken at 0x1C008D544), so it is reached indirectly through the RDBSS IRP_MJ_DIRECTORY_CONTROL / MRxQueryDirectory dispatch path. It operates on an RDBSS RX_CONTEXT. Two adjacent fields carry the output-buffer state:
RX_CONTEXT+0x1b8→ pointer to the directory-enumeration output buffer.RX_CONTEXT+0x1c8→ that buffer's byte length.
These two are a matched (buffer, length) pair. This is directly observable: on the success path both builds compute remaining = length − bytes_written and write it back to +0x1c8 (unpatched 0x1C006A9C0: mov eax, r13d ; sub eax, ecx ; mov [rsi+1C8h], eax; patched 0x1C006CC03: mov eax, ebx ; sub eax, r13d ; mov [r14+1C8h], eax). The code tracks how many bytes the enumeration produced against the length in +0x1c8; it never uses +0x1c8 as a count against a differently sized allocation.
The two memset sites (present, identical semantics in both builds). The function calls memset(*(RX_CONTEXT+0x1b8), 0, *(RX_CONTEXT+0x1c8)) — i.e. it zeroes the output buffer to its own length — at two places:
0x1C006A23F(unpatched) — reached on an error / validation-failure path: when the FCB signature checkcmp [rbx], 0EC21hfails (or the related+0x1F6parameter check fails), the code sets an error status (edi = 0xC000000D/0xC0000002) and zeroes the caller's output buffer before returning. This is output-buffer clearing on error, not an enumeration write. The patched build performs the same output-buffer clear on its error path at0x1C006CC1A.0x1C006A616(unpatched) — reached on the main enumeration branch when the flag bytevar_38 & 0x30is set (0x1C006A532:test byte [rsp+..], 30h ; jnz). This zeroes the buffer before the enumeration writers populate it. The patched equivalent is0x1C006C767.
In both sites the count is the buffer's own length. There is no second, larger, independently controlled size involved.
What the patch adds. In the patched build, immediately before the enumeration writer QueryDirEnumSet (sub_1C006AF30), a new branch gated on Feature_3405715771__private_IsEnabledDeviceUsage (sub_1C0007A98) does the following when the feature is enabled:
ExAllocatePoolWithTag(NonPagedPoolNx (0x200), NumberOfBytes = length, tag 0x72447343 'CsDr')(0x1C006C8B9) — allocates a scratch buffer of exactlylengthbytes (the value in+0x1c8).memset(scratch, 0, length)(0x1C006C8ED), sets an "allocated" flag (var_BF = 1), and swaps the scratch pointer intoRX_CONTEXT+0x1b8(0x1C006C8FA).- The enumeration operations then run against the scratch buffer instead of the caller's buffer.
- At function end, gated on the same feature and the "allocated" flag,
memmove(original_buffer, scratch, bytes_written)(0x1C006CB8C) copies back only the bytes the enumeration produced (bytes_written = r13d = *(FOBX+0x6C),0x1C006CB01), then restores the original pointer into+0x1b8(0x1C006CB99). - The scratch is released via
memset(FOBX+0x48, 0, 0x40)(0x1C006CC4C) +ExFreePoolWithTag(scratch, 0)(0x1C006CC56).
When the feature is disabled, the patched code uses the original buffer directly (0x1C006C8A2: mov rbx, [r14+1B8h]), i.e. the unpatched behavior.
Why this is not a bounds fix. The scratch buffer is allocated to the same length as the caller's buffer, and the copy-back is bounded by bytes_written, which is always ≤ length. If the caller's buffer at +0x1b8 could ever be smaller than length, the copy-back memmove into that same buffer would itself overflow it — so the patch implicitly relies on (buffer, length) being consistent, exactly as the unpatched code does. No size/allocation relationship is changed. The observable effect of the refactor is that the caller's output buffer receives only the produced bytes via a private kernel scratch, rather than being written in place; this is a structural/defense-in-depth change, gated for staged rollout by a feature flag.
3. Pseudocode Diff
The following captures the actual structural change on the main enumeration branch.
// ================= UNPATCHED CscQueryDirectory (sub_1C0069FF0) =================
v4 = *(_DWORD *)(a1 + 0x1c8); // length of the output buffer at +0x1b8
Size = v4;
// error / validation-failure path (FCB signature or +0x1F6 check failed):
// zero the caller's output buffer to its own length before returning an error
memset(*(void **)(a1 + 0x1b8), 0, Size); // dst = output buffer, count = its length
// main enumeration branch (var_38 & 0x30):
if (v37 & 0x30) {
v18 = *(_QWORD *)(v32 + 0x10);
memset(*(void **)(a1 + 0x1b8), 0, Size); // zero output buffer to its length
RxSubjectContextFromRxContext(a1, &v29);
CscStoreOpenEntryHandle(v18, ...);
QueryDirEnumSet(v18 + 0x48,
*(_DWORD *)(a1 + 0x1b0), // FileInformationClass
*(_QWORD *)(a1 + 0x1b8), // output buffer
Size, // capacity = length
0);
// ... CscQueryDirOnlineAndUpdateCache / CscQueryDirRemoveStaleEntries /
// CscQueryDirProcessRemainingLocal populate the buffer, bounded by length ...
}
// on success: *(a1 + 0x1c8) = length - bytes_written; // remaining length bookkeeping
// ================= PATCHED CscQueryDirectory (sub_1C006C180) =================
Src = NULL;
if (Feature_3405715771__private_IsEnabledDeviceUsage()) { // WIL feature toggle
Src = ExAllocatePoolWithTag(NonPagedPoolNx, /*NumberOfBytes=*/ length, 0x72447343 /*'CsDr'*/);
memset(Src, 0, length); // scratch is exactly `length` bytes
temp_allocated = 1;
*(_QWORD *)(a1 + 0x1b8) = Src; // enumeration now targets the scratch
} else {
buffer = *(void **)(a1 + 0x1b8); // legacy: write in place
}
// ... same enumeration operations, bounded by `length`, now into scratch (or in place) ...
if (Feature_3405715771__private_IsEnabledDeviceUsage() && temp_allocated) {
memmove(original_buffer, Src, bytes_written); // bytes_written <= length
*(_QWORD *)(a1 + 0x1b8) = original_buffer; // restore
}
// on success: *(a1 + 0x1c8) = length - bytes_written; // same bookkeeping
// at exit, if feature enabled and Src != NULL: ExFreePoolWithTag(Src, 0);
The change is the introduction of a same-size scratch buffer plus a bytes_written-bounded copy-back, gated behind a feature flag. Both builds bound every write by length.
4. Assembly Analysis
Unpatched — the two memset sites and the enumeration writer (sub_1C0069FF0)
; ===== Prologue: load the buffer length =====
00000001C006A01E mov rsi, rcx ; rsi = arg1 (PRX_CONTEXT)
00000001C006A029 mov rbx, [rcx+38h] ; FCB pointer (signature 0xEC21 expected)
00000001C006A04D mov r13d, [rsi+1C8h] ; r13d = output-buffer length
00000001C006A054 mov dword ptr [rsp+108h+Size], r13d
; ===== FCB signature / parameter validation =====
00000001C006A213 mov eax, 0EC21h
00000001C006A218 cmp [rbx], ax ; FCB signature check
00000001C006A21B jz loc_1C006A2B1 ; ok -> main path
00000001C006A221 mov edi, 0C000000Dh ; else STATUS_INVALID_DEVICE_REQUEST
; ===== memset site #1 (error/validation path): zero output buffer to its length =====
00000001C006A233 mov r8d, r13d ; count = length
00000001C006A236 xor edx, edx ; fill = 0
00000001C006A238 mov rcx, [rsi+1B8h] ; dst = output buffer
00000001C006A23F call memset
; ===== memset site #2 (0x30-flag enumeration branch) =====
00000001C006A532 test byte ptr [rsp+108h+var_30], 30h
00000001C006A53A jnz loc_1C006A5F9
00000001C006A60A mov r8d, r12d ; count = length
00000001C006A60D xor edx, edx
00000001C006A60F mov rcx, [rsi+1B8h] ; dst = output buffer
00000001C006A616 call memset
; ===== enumeration writer, capacity = length =====
00000001C006A74F mov r9d, r12d ; capacity = length (5th param)
00000001C006A752 mov r8, [rsi+1B8h] ; output buffer (4th param)
00000001C006A759 mov edx, [rsi+1B0h] ; FileInformationClass
00000001C006A75F call QueryDirEnumSet
; ===== success: write remaining length back to +0x1c8 =====
00000001C006A9BB mov eax, r13d ; eax = length
00000001C006A9BE sub eax, ecx ; - bytes_written
00000001C006A9C0 mov [rsi+1C8h], eax ; +0x1c8 = remaining length
Annotations:
- mov r13d, [rsi+1C8h] loads the output buffer's own length; it is used as the memset count for the buffer at +0x1b8 (same buffer it labels).
- memset site #1 (0x1C006A23F) is on the error/validation path (FCB-signature or +0x1F6 check failure), zeroing the caller's output buffer before an error return — not an enumeration write.
- memset site #2 (0x1C006A616) is the main 0x30-flag enumeration branch.
- QueryDirEnumSet (sub_1C006AF30) receives length as its capacity argument; it is bounded by that length.
- The remaining = length − bytes_written store at 0x1C006A9C0 confirms +0x1c8 is the buffer length, tracked against bytes produced.
Patched — the feature-gated scratch buffer (sub_1C006C180)
; ===== feature gate, then allocate scratch of exactly `length` bytes =====
00000001C006C899 call Feature_3405715771__private_IsEnabledDeviceUsage
00000001C006C89E test eax, eax
00000001C006C8A0 jnz short loc_1C006C8AB
00000001C006C8A2 mov rbx, [r14+1B8h] ; feature off: use original buffer in place
00000001C006C8A9 jmp short loc_1C006C901
00000001C006C8AB mov r8d, 72447343h ; tag 'CsDr'
00000001C006C8B1 mov rdx, r13 ; NumberOfBytes = length
00000001C006C8B4 mov ecx, 200h ; NonPagedPoolNx
00000001C006C8B9 call cs:__imp_ExAllocatePoolWithTag
00000001C006C8CD mov rbx, rax
00000001C006C8E5 mov r8, r13 ; count = length
00000001C006C8E8 xor edx, edx
00000001C006C8EA mov rcx, rax
00000001C006C8ED call memset ; zero the scratch
00000001C006C8F2 mov [rsp+138h+var_BF], 1 ; temp_allocated = 1
00000001C006C8FA mov [r14+1B8h], rbx ; swap scratch into +0x1b8
; ===== enumeration writer (unchanged), capacity = length =====
00000001C006C912 mov r9d, dword ptr [rsp+138h+Size] ; capacity = length
00000001C006C917 mov r8, rbx ; scratch (or original) buffer
00000001C006C921 call QueryDirEnumSet
; ===== copy only produced bytes back, restore original pointer =====
00000001C006CB01 mov r13d, [r13+6Ch] ; bytes_written from FOBX+0x6C
00000001C006CB69 call Feature_3405715771__private_IsEnabledDeviceUsage
00000001C006CB70 jz short loc_1C006CBA0
00000001C006CB72 cmp [rsp+138h+var_BF], 0 ; temp_allocated?
00000001C006CB77 jz short loc_1C006CBA0
00000001C006CB79 mov r8d, r13d ; count = bytes_written (<= length)
00000001C006CB7C mov rdx, [rsp+138h+Src] ; src = scratch
00000001C006CB84 mov rcx, [rsp+138h+var_60] ; dst = original buffer
00000001C006CB8C call memmove
00000001C006CB99 mov [r14+1B8h], rax ; restore original pointer
; ===== free the scratch =====
00000001C006CC1F call Feature_3405715771__private_IsEnabledDeviceUsage
00000001C006CC4C call memset ; memset(FOBX+0x48, 0, 0x40)
00000001C006CC56 call cs:__imp_ExFreePoolWithTag ; ExFreePoolWithTag(scratch, 0)
What changed structurally:
- Register reshuffle (rsi → r14, r12/r13 reassigned), one extra callee-saved register pushed, and the stack frame grown from 0xF0 to 0x120 to carry the scratch-buffer state.
- A feature-gated scratch allocation, copy-back, and free were added around the enumeration writes.
Not changed: CscDetermineAbortStatus (sub_1C0016C78) is called in both builds (unpatched 0x1C006A116, patched 0x1C006C2D6); it is not new and is unrelated to the added scratch path. CscQueryDirStitchRemoteBuffer (sub_1C0069AA0) is not called by CscQueryDirectory in either build.
5. Reachability
CscQueryDirectory is registered in the CSC miniredirector dispatch table by CscInitializeDispatchTable (sub_1C008D338, address taken at 0x1C008D544) and is reached through the RDBSS IRP_MJ_DIRECTORY_CONTROL / MRxQueryDirectory dispatch when a directory that engages the CSC (Offline Files) redirector is enumerated (NtQueryDirectoryFile / FindFirstFileW / FindNextFileW). Reaching the main enumeration branch requires the FCB signature at +0x38 to be 0xEC21 (a valid redirector FCB) and the flag byte (var_38) to select the 0x20/0x30 sub-branch. This is the normal directory-enumeration path; nothing about it is a corruption trigger, because every write is bounded by the buffer length in +0x1c8.
6. Security Assessment
There is no out-of-bounds write, pool overflow, or other memory-safety defect in the unpatched CscQueryDirectory that this patch removes:
- The
memsetcalls zero the output buffer to its own length (+0x1b8and+0x1c8are a matched pair), and the enumeration writer receives that same length as its capacity. - The
remaining = length − bytes_writtenbookkeeping written back to+0x1c8in both builds confirms the buffer/length pairing. - The patched scratch buffer is allocated to exactly that length, and the copy-back is bounded by
bytes_written ≤ length; the fix therefore changes no size/allocation relationship. Were the caller's buffer ever smaller thanlength, the patched copy-back would overflow it just as an in-place write would — which it does not, because the pair is consistent. - The added path is gated behind the WIL feature toggle
Feature_3405715771__private_IsEnabledDeviceUsage; when the feature is off, the patched code is behaviorally identical to the unpatched code.
Classification: no security-relevant change. The modification is a feature-staged refactor (double-buffering the directory-enumeration output through a same-size kernel scratch), consistent with a defense-in-depth / staged-rollout change rather than a vulnerability remediation. No CWE and no exploitable primitive are justified by the binaries.
7. Verification Notes
For anyone re-checking this against the binaries (RVAs; add the driver's loaded base — preferred image base 0x1C0000000):
| Offset | Instruction | Meaning |
|---|---|---|
+0x6A04D |
mov r13d, dword [rsi+0x1c8] |
Loads the output-buffer length. |
+0x6A21B |
cmp [rbx], ax ; jz (ax = 0xEC21) |
FCB signature check gating the main path. |
+0x6A23F |
mov rcx, [rsi+0x1b8] ; call memset |
Error-path output-buffer clear (site #1). |
+0x6A616 |
mov rcx, [rsi+0x1b8] ; call memset |
0x30-flag enumeration branch buffer clear (site #2). |
+0x6A75F |
mov r9d, r12d ; mov r8, [rsi+0x1b8] ; call QueryDirEnumSet |
Enumeration writer, capacity = length. |
+0x6A9C0 |
mov eax, r13d ; sub eax, ecx ; mov [rsi+0x1c8], eax |
+0x1c8 = length − bytes_written (buffer/length pairing). |
(patched) +0x6C8B9 |
mov r8d, 0x72447343 ; mov rdx, r13 ; mov ecx, 0x200 ; call ExAllocatePoolWithTag |
Feature-gated scratch allocation (added). |
(patched) +0x6CB8C |
mov r8d, r13d ; call memmove |
Copy bytes_written back to the original buffer (added). |
(patched) +0x6CC56 |
call ExFreePoolWithTag |
Free the scratch (added). |
Struct / offset notes (RX_CONTEXT)
| Offset | Field |
|---|---|
+0x38 |
FCB pointer (signature 0xEC21 gates the main path). |
+0x1b0 |
FileInformationClass (directory info class). |
+0x1b8 |
Directory-enumeration output buffer pointer. |
+0x1c8 |
Output buffer byte length (paired with +0x1b8). |
The patched-build address
0x1C0007A98isFeature_3405715771__private_IsEnabledDeviceUsage; the same address in the unpatched build is an unrelated function (CscEaSystemMemFree), i.e. the builds differ in layout. Both images use preferred base0x1C0000000; add the runtime base delta under ASLR.
8. Changed Functions — Full Triage
Only one function changed.
CscQueryDirectory (sub_1C0069FF0 unpatched) → CscQueryDirectory (sub_1C006C180 patched)
- Similarity: 0.8424
- Change type: Refactor / defense-in-depth (feature-flag staged); not security-relevant.
- Behavioral change: Adds a
Feature_3405715771-gated path that allocates a same-sizeNonPagedPoolNxscratch buffer (tag'CsDr'), runs the directory-enumeration writes into it, and copies only the produced bytes back to the caller's output buffer before freeing the scratch. Register allocation was reshuffled and the stack frame grown (0xF0 → 0x120) to carry the scratch state. When the feature is off, behavior matches the unpatched build.
No other functions changed.
9. Unmatched Functions
None. Both binaries report zero added and zero removed functions. The only difference is the in-line, feature-gated scratch-buffer path inside CscQueryDirectory.
10. Confidence & Caveats
Confidence: High that this is not a security-relevant change. The diff is unambiguous: a single function changed, and the change is a same-size scratch buffer plus a bytes_written-bounded copy-back, gated behind a WIL feature flag. The unpatched code bounds every write by the output buffer's own length (+0x1c8), the value that both builds also use to compute the remaining-length bookkeeping written back to +0x1c8. The patched scratch is allocated to exactly that length and the copy-back is bounded by bytes_written ≤ length, so no size/allocation relationship is altered and no out-of-bounds condition is introduced or removed. There is no evidence in either binary that +0x1c8 can exceed the allocation of the buffer at +0x1b8; on the contrary, the fix relies on the pair being consistent. Accordingly, no CWE class and no exploit primitive apply.