pcw.sys — user-mode write hardening via Rtl*ToUser accessors, WIL feature-staging, and security-library churn
KB5094128
1. Overview
| Field | Value |
|---|---|
| Unpatched binary | pcw_unpatched.sys |
| Patched binary | pcw_patched.sys |
| Overall similarity | 0.7938 (79.38%) |
| Matched functions | 160 |
| Changed functions | 103 |
| Identical functions | 57 |
| Unmatched (either direction) | 0 / 0 |
Verdict: No delivered security fix could be substantiated against the two builds. The change set is dominated by an SDK/toolchain rebuild: direct writes to user-mode output buffers are re-routed through the standard safe user-mode accessors (RtlCopyToUser, RtlWriteULong64ToUser, RtlCopyFromUser), the driver picks up WIL feature-staging plumbing (Feature_550970683, wil_details_*), and a large block of statically-linked security/registry library code (SeSddl*, SepSddl*, CmRegUtil*, Pi/PpRegState*, IoDevObj*) is recompiled. The user-supplied output buffer that the earlier analysis flagged is already validated with ProbeForWrite by the device-control dispatch in both builds, so the accessor substitution is defense-in-depth, not the closure of a reachable primitive. No CVE is attributed to pcw.sys in this servicing release (KB5094128).
The findings below retain their numbering but are corrected to what the binaries actually show; each is downgraded to no security-relevant change.
2. Vulnerability Summary
Finding 1 - Direct write to output pointer replaced by RtlWriteULong64ToUser (no reachable primitive)
| Attribute | Detail |
|---|---|
| Severity | None (defense-in-depth) |
| Class | User-mode write hardening (not an exploitable Untrusted Pointer Dereference) |
| Affected function | PCW_NOTIFIER::SendNotification @ 0x1C0008778 (unpatched) / 0x14000C754 (patched) |
What changed. In the success path of SendNotification, the unpatched build stores an 8-byte value to the caller's output pointer a6 with a direct mov [r15], rax at 0x1C0008A3A. The patched build replaces that single store with call RtlWriteULong64ToUser at 0x14000CA23 (rcx = a6, rdx = value). Nothing else in the write path changed; the companion store *a8 = size remains a direct mov [rax], ecx (0x1C0008A09 unpatched, 0x14000C9EF patched) in both builds.
Why this is not an arbitrary kernel write.
- The value written is not a kernel pointer. It is assembled from two counters on the notification object:
HIDWORD = *((_DWORD*)v13 + 16)(the accumulated reply size at object offset0x40) andLODWORD = *((_DWORD*)v13 + 18)(the responding-instance count at offset0x48). In assembly:mov ecx,[rdi+40h]; mov eax,[rdi+48h]at0x1C00089F3/0x1C00089F6, packed into the local written toa6. It is size/count metadata, not a pool address. - The output pointer
a6is already validated. All device-control requests reach these handlers throughPcwpFastIoDeviceControl(0x1C0007890/0x140009BD0), which executesProbeForWrite(Address, Length, 1)on the output buffer before dispatching, in both builds (pcw_unpatched.c:2440,pcw_patched.c:2128).a6is that probedAddress;a8is&Length, a kernel stack local inside the dispatcher (which is why*a8was never routed through a safe accessor). - The write is bounded. It executes only when
size <= a7(the output length), so it stays inside the probed[Address, Address+Length)range.
RtlWriteULong64ToUser (0x1400080B4) either calls the OS-provided routine cached at qword_140006400, or, on the fallback path, performs ProbeForRead(dst, 8, 1) followed by *dst = value. It is the standard safe-write wrapper; it re-validates the already-probed destination. Also cosmetic in this function: a memset(&Object, 0, ...) was added before KeInitializeEvent, and the response-count loop increment was restructured.
Finding 2 - Notification-buffer copy replaced by RtlCopyToUser (direction of the original claim was reversed)
| Attribute | Detail |
|---|---|
| Severity | None (defense-in-depth) |
| Class | User-mode write hardening |
| Affected function | PCW_NOTIFICATION::CopyToNotificationBuffer @ 0x1C00081A8 (unpatched) / 0x14000BB04 (patched) |
What changed. After KeStackAttachProcess attaches to the notification's target process, the unpatched build copies a 16-byte reply-item header with movups xmm0, [rdi] (0x1C0008260) followed by movdqu [rax+rcx], xmm0 (0x1C0008263). The patched build replaces this pair with call RtlCopyToUser at 0x14000BBDC (dst = this->buffer + offset, src = a2, len = 0x10).
Why the earlier framing does not hold. The protected operation is the write into the target process's collection buffer, not a read of an attacker source:
src(a2/rdi) isPCW_REPLYITEM_BUFFER *, a small header built on the kernel stack by the caller (CompleteInFlightNotificationfills a_DWORD v20[10]local and passes it down throughComplete). It is not user-supplied data.dstisthis->buffer_base (offset 0x30) + this->offset (offset 0x40)- a location inside the target process's user-mode collection buffer, entered underKeStackAttachProcess.RtlCopyToUser(0x140008008) validates this destination (fallback path:ProbeForRead(dst, len, 1)thenRtlCopyVolatileMemory) and copies.- The size/alignment and capacity bounds check (
new_offset > this->[0x3c]-> early return) is present unchanged in both builds (pcw_unpatched.c:2881,pcw_patched.cequivalent). The bulk payload continues to go throughMmCopyVirtualMemory(0x1C00082E4/0x14000BC6C) in both builds.
This is the same safe-accessor substitution as Finding 1, applied to a 16-byte header write into an attached process's buffer. No unvalidated read of user memory and no information-disclosure primitive is introduced or removed.
Finding 3 - memset added inside a statically-linked SDDL parser (library churn)
| Attribute | Detail |
|---|---|
| Severity | None |
| Class | Statically-linked library recompile |
| Affected function | SepSddlSecurityDescriptorFromSDDLString @ 0x1C000C188 (unpatched) |
SepSddlSecurityDescriptorFromSDDLString is a statically-linked copy of the kernel SDDL-string-to-security-descriptor parser, not PCW-specific code. The patched build zeroes the allocated pool buffer after ExAllocatePoolWithTag before it is populated. This is one line in a family of security-descriptor/registry helpers (SeSddl*, SepSddlAddAceToAcl, SepSddlGetAclForString, SepSddlGetSidForString, SepSddlParseWideStringUlong, SeUtilSecurityInfoFromSecurityDescriptor, CmRegUtil*, Pi/PpRegState*) that all changed together - the signature of a library rebuild. It is a defense-in-depth zeroing in copied library code, not a fix for an attacker-reachable pcw.sys information leak.
Finding 4 - Guard added before RtlCreateAcl, but it does not test the allocation
| Attribute | Detail |
|---|---|
| Severity | None |
| Class | Refactor artifact (no behavioral security change) |
| Affected function | PcwpCreateAllowedDacl @ 0x1C000A8D4 (unpatched) / 0x14000EAF0 (patched) |
The patched build adds a test rdi, rdi / jnz guard at 0x14000EB89 that returns 0xC0000017 (STATUS_NO_MEMORY) on the null branch. rdi is the first argument a1 - the address of the caller's unique_ptr<_ACL> container - not the ExAllocatePool2 result (rax, stored to [rdi] at 0x14000EB73). Because a1 is passed as &P from PcwpCreateDefaultSecurityDescriptor, it is never null, so the new branch is dead and RtlCreateAcl still receives the raw (possibly-null) allocation exactly as before. The claimed "NULL check that prevents a RtlCreateAcl(NULL) bugcheck" is not what the code does. This routine also runs only on the security-descriptor setup path with a fixed set of SIDs (SeExports->SeLocalSystemSid, etc.), not on an attacker-controlled path. No reachable DoS is fixed.
Finding 5 - Misattributed ExFreePoolWithTag; the free is guarded and unreachable with a non-null pointer
| Attribute | Detail |
|---|---|
| Severity | None |
| Class | Decompiler-variable noise (no behavioral change) |
| Affected function | PCW_USER_REGISTRATION::SendNotificationToRegistration @ 0x1C00085B0 (unpatched) / 0x14000CA9C (patched) |
This function is SendNotificationToRegistration (it inserts a pending-notification node into a registration list), not an ACL buffer manager, and it contains no ACL allocation. The ExFreePoolWithTag call is a rollback path for a reference pointer and is guarded by a non-null test in both builds. On the allocation-success path that pointer is set to null, so the free never executes; on failure the enclosing flag guard is false. The only difference is that the decompiler names the null-initialized pointer P in the unpatched output and v8 in the patched output - the same value. There is no ExFreePoolWithTag(nullptr)-to-ExFreePoolWithTag(*arg1) correction and no leak fix here.
3. Pseudocode Diff
Finding 1 - PCW_NOTIFIER::SendNotification
// UNPATCHED @ 0x1C0008778
LODWORD(v26) = *((_DWORD *)v13 + 18); // instance count (offset 0x48)
HIDWORD(v26) = *((_DWORD *)v13 + 16); // accumulated size (offset 0x40)
*a8 = HIDWORD(v26); // direct store to &Length (kernel local)
if ( HIDWORD(v26) <= a7 )
*a6 = v26; // direct store to already-probed user output
// PATCHED @ 0x14000C754
LODWORD(v28) = *((_DWORD *)v13 + 18);
HIDWORD(v28) = *((_DWORD *)v13 + 16);
*a8 = HIDWORD(v28); // still a direct store
if ( HIDWORD(v28) <= a7 )
RtlWriteULong64ToUser(a6, v28); // safe-accessor wrapper (re-validates dst)
Finding 2 - PCW_NOTIFICATION::CopyToNotificationBuffer
// UNPATCHED @ 0x1C00081A8
KeStackAttachProcess(this->process, &ApcState);
*(_OWORD *)(this->buffer + this->offset) = *(_OWORD *)a2; // movups/movdqu, 16B header
KeUnstackDetachProcess(&ApcState);
// ... MmCopyVirtualMemory(payload) ...
// PATCHED @ 0x14000BB04
KeStackAttachProcess(this->process, &ApcState);
RtlCopyToUser((void *)(this->buffer + this->offset), a2, 0x10u); // validated write of same 16B
KeUnstackDetachProcess(&ApcState);
// ... MmCopyVirtualMemory(payload) ... (unchanged)
a2 is a kernel-stack PCW_REPLYITEM_BUFFER; the destination is the attached process's user buffer.
Finding 4 - PcwpCreateAllowedDacl
// UNPATCHED @ 0x1C000A8D4
Pool2 = ExAllocatePool2(256, v5, 'PwcT');
*a1 = Pool2; if (old) ExFreePoolWithTag(old, 0);
RtlCreateAcl(Pool2, v5, 2u); // no test of Pool2
// PATCHED @ 0x14000EAF0
Pool2 = ExAllocatePool2(256, v5, 'PwcT');
*a1 = Pool2; if (old) ExFreePoolWithTag(old, 0);
if ( a1 != nullptr ) // tests the container ptr (always non-null), not Pool2
RtlCreateAcl(*a1, v5, 2u); // *a1 == Pool2, still possibly null
else
return 0xC0000017; // dead branch
4. Assembly Analysis
Finding 1 - the replaced store (PCW_NOTIFIER::SendNotification)
; UNPATCHED success block @ 0x1C0008778
00000001C00089F3 mov ecx, [rdi+40h] ; ecx = accumulated size
00000001C00089F6 mov eax, [rdi+48h] ; eax = instance count
00000001C00089F9 mov [rsp+var_48], eax ; pack low = count
00000001C00089FD mov [rsp+var_48+4], ecx ; pack high = size
00000001C0008A01 mov rax, [rsp+arg_38] ; rax = a8 (&Length, kernel local)
00000001C0008A09 mov [rax], ecx ; *a8 = size (direct; unchanged in patch)
00000001C0008A0B cmp ecx, r13d ; size <= a7 (output length) ?
00000001C0008A0E jbe short loc_1C0008A35
; ...
00000001C0008A35 mov rax, [rsp+var_48] ; rax = (size<<32)|count
00000001C0008A3A mov [r15], rax ; *a6 = value <-- direct write to output
; PATCHED equivalent @ 0x14000C754
000000014000CA1B mov rdx, [rsp+var_48] ; rdx = (size<<32)|count
000000014000CA20 mov rcx, r15 ; rcx = a6
000000014000CA23 call RtlWriteULong64ToUser ; validated write of the same value
Finding 2 - the replaced copy (PCW_NOTIFICATION::CopyToNotificationBuffer)
; UNPATCHED @ 0x1C00081A8
00000001C000824B call cs:__imp_KeStackAttachProcess
00000001C0008260 movups xmm0, xmmword ptr [rdi] ; load 16B header from kernel-stack a2
00000001C0008263 movdqu xmmword ptr [rax+rcx], xmm0 ; store into attached process buffer
00000001C000828E call cs:__imp_KeUnstackDetachProcess
00000001C00082E4 call cs:__imp_MmCopyVirtualMemory
; PATCHED @ 0x14000BB04
000000014000BBBF call cs:__imp_KeStackAttachProcess
000000014000BBDC call RtlCopyToUser ; validated 16B write, same src/dst
000000014000BC11 call cs:__imp_KeUnstackDetachProcess
000000014000BC6C call cs:__imp_MmCopyVirtualMemory
Safe-accessor internals (patched-only helpers)
; RtlWriteULong64ToUser @ 0x1400080B4
00000001400080BE mov rax, cs:qword_140006400 ; cached OS routine ptr
00000001400080CE jz short loc_1400080D7 ; if null, use fallback
00000001400080D0 call rax ; else call OS routine
; loc_1400080D7:
00000001400080D7 mov edx, 8 ; Length
00000001400080DC lea r8d, [rdx-7] ; Alignment = 1
00000001400080E0 call ProbeForRead_0 ; validate dst is user-mode
00000001400080E5 mov [rbx], rdi ; *dst = value
; RtlCopyToUser @ 0x140008008 : same shape - cached ptr at qword_1400063A0,
; fallback ProbeForRead(dst, len, 1) then RtlCopyVolatileMemory(dst, src, len).
Dispatch - output already probed in both builds
; PcwpFastIoDeviceControl (both builds): before any handler runs
; ProbeForWrite(Address /*output*/, Length, 1);
; RtlCopyFromUser(&local, input, input_len); // patched (memmove in unpatched)
; status = handler(&local, Address, &Length);
5. Trigger Conditions
The path exists and is reachable from user mode, but it does not yield a security primitive in either build:
- Open
\\.\PcwDrv(\Device\PcwDrv) and issue the notify IOCTL that routes throughPcwpFastIoDeviceControl->PcwpIoctlNotify(0x1C0007550) ->PCW_NOTIFIER::SendNotification. - The dispatch calls
ProbeForWriteon the user output buffer and copies the input into a kernel-stack local before the handler runs, so the output pointer the handler writes to is already constrained to user space. - On success the handler writes
(size<<32)|countmetadata into that probed buffer (bounded by the caller-supplied output length) and the collected counter payload viaMmCopyVirtualMemory.
There is no field in the IOCTL input that lets the caller substitute an unvalidated kernel address for the write destination; the destination is the probed output buffer, identical in both builds.
6. Exploit Primitive & Development Notes
None. The value written is non-pointer size/count metadata, the destination is a ProbeForWrite-validated user buffer in both builds, and the write is length-bounded. No write-what-where, information-disclosure, or control-flow primitive is present, so no exploitation notes apply.
7. Debugger Notes
For anyone reproducing the diff on the two builds:
PCW_NOTIFIER::SendNotification: compare the store at0x1C0008A3A(mov [r15], rax) with the patchedcall RtlWriteULong64ToUserat0x14000CA23. Confirm at the store thatr15holds the dispatch'sAddressand that the value inraxis(*(rdi+0x40) << 32) | *(rdi+0x48).PCW_NOTIFICATION::CopyToNotificationBuffer: compare0x1C0008260/0x1C0008263with the patchedcall RtlCopyToUserat0x14000BBDC. Confirmrdi/a2points into kernel stack (the reply-item header) and the destination is inside the attached process buffer.PcwpFastIoDeviceControl: confirmProbeForWrite(Address, Length, 1)executes on the output in both builds before the handler is called (pcw_unpatched.c:2440,pcw_patched.c:2128).
8. Changed Functions - Full Triage
An independent content-matched diff (by symbol, across relocations) of all changed functions was performed. Categories:
Safe user-mode accessor modernization (defense-in-depth, not a probe added/removed)
Every one of these already validated the same buffer in the unpatched build; the change is memmove/*p = x/inline MmUserProbeAddress check -> RtlCopyToUser / RtlWriteULong64ToUser / RtlWriteULongToUser / RtlWriteUCharToUser / RtlCopyFromUser / ProbeForRead:
| Function | Note |
|---|---|
PcwpWriteToUserBuffer (0x1C00050A8) |
memmove(dst,src,len) -> RtlCopyToUser(dst,src,len); central output writer used by Collect/Enumerate |
PCW_NOTIFIER::SendNotification (0x1C0008778) |
*a6 = value -> RtlWriteULong64ToUser (Finding 1) |
PCW_NOTIFICATION::CopyToNotificationBuffer (0x1C00081A8) |
16B movups/movdqu -> RtlCopyToUser (Finding 2) |
PcwpFastIoDeviceControl (0x1C0007890) |
input memmove -> RtlCopyFromUser; ProbeForWrite on output unchanged |
PcwpIoctlNotify, PcwpIoctlStatelessNotify, PcwpIoctlCompleteNotification, PcwpIoctlDisconnect, PcwpIoctlAddQueryItem, PcwpIoctlCreateNotifier, PcwpIoctlCreateQuery |
inline probe -> ProbeForRead, *out = x -> RtlWrite* |
AllocatedUnicodeString::Capture, Create@_PCW_INSTANCE |
memmove -> RtlCopyFromUser + inline probe -> ProbeForRead |
WIL feature-staging (not security fixes)
SendDisconnectNotification (0x1C0008B98 / 0x14000C5A4) wraps a disconnect-delivery flag test in a Feature_550970683__private_IsEnabledDeviceUsageNoInline() gate with the old code path retained as the fallback branch (feature-enabled reproduces the unpatched flag[85] == 0 delivery test; feature-disabled inverts it). This is a staged rollout, not a delivered change. All wil_details_*, Feature_550970683*, and wil_InitializeFeatureStaging additions are the supporting feature-staging/telemetry plumbing.
Statically-linked library / ETW churn
SeSddl*, SepSddl*, SeUtilSecurityInfoFromSecurityDescriptor, CmRegUtil*, PiRegState*, PpRegState*, IoDevObjCreateDeviceSecure, IopDevObjApplyPostCreationSettings, __cpu_features_init, __memset*, memset, and the McGen* / McTemplate* / EtwWrite* tracing helpers are recompiled library/tracing code (Finding 3 is one line inside this block).
Refactor / decompiler noise (no semantic change)
The remaining IOCTL handlers, _PCW_BUFFER/PCW_QUERY/PCW_COUNTERSET methods, constructors/destructors, security-descriptor accessors, and tree/silo/init routines differ only by register reallocation, branch reshuffling of cleanup code, or variable renaming. Verified stable across the diff: reference-count interlocked decrements (...+19/+76, -1 == 1), object access masks, and the 0xFFFF/0xFFFFFFF8 size limits are identical between builds. Two functions (Capture, FindOrCreate@PCW_SILO_NEUTRAL_COUNTERSET) gain a SafeInt-style multiply-overflow guard on the allocation size, but the multiplicand derives from a 16-bit UNICODE_STRING.Length, so the product cannot overflow and the guard is dead code. Several list-node frees (CompleteInFlightNotification, Disconnect, ~PCW_QUERY) gain a null check before ExFreePoolWithTag on a pointer that is unconditionally dereferenced immediately above, so the guard is a no-op - not a double-free/UAF fix.
9. Unmatched Functions
No functions were added or removed in the PCW logic (unmatched_unpatched: 0, unmatched_patched: 0). The patched-only symbols are the safe-accessor helper bodies (RtlCopyToUser, RtlWriteULong64ToUser, RtlWriteULongToUser, RtlWriteUCharToUser, RtlCopyFromUser, RtlCopyVolatileMemory and their call thunks) and the WIL feature-reporting routines - infrastructure pulled in by the rebuild, not new PCW functionality.
10. Confidence & Caveats
Confidence: High that there is no delivered security fix.
- The two flagged writes (
SendNotification,CopyToNotificationBuffer) target buffers that are alreadyProbeForWrite-validated byPcwpFastIoDeviceControlin both builds; the substitution toRtl*ToUserre-validates the same destinations and is defense-in-depth. - The value written by
SendNotificationis size/count metadata, not a kernel pointer. - Findings 3, 4, and 5 do not hold as described: Finding 3 is inside statically-linked SDDL library code, Finding 4's added guard tests the container pointer rather than the allocation (dead branch, allocation still passed unchecked), and Finding 5 was misattributed to an ACL manager and describes a guarded, unreachable free.
- An independent content-matched diff of all changed functions found no added bounds/overflow check on a reachable attacker-controlled size, no altered reference-count/free/completion semantics, and no reversed security condition.
- No CVE is attributed to pcw.sys in this release (KB5094128).
Item requiring no further action: the WIL Feature_550970683 gate in SendDisconnectNotification is a staged rollout; if that feature is later enabled by default and its non-security behavior matters, it can be re-examined, but it is not a security change in these builds.