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.

  1. 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 offset 0x40) and LODWORD = *((_DWORD*)v13 + 18) (the responding-instance count at offset 0x48). In assembly: mov ecx,[rdi+40h]; mov eax,[rdi+48h] at 0x1C00089F3/0x1C00089F6, packed into the local written to a6. It is size/count metadata, not a pool address.
  2. The output pointer a6 is already validated. All device-control requests reach these handlers through PcwpFastIoDeviceControl (0x1C0007890 / 0x140009BD0), which executes ProbeForWrite(Address, Length, 1) on the output buffer before dispatching, in both builds (pcw_unpatched.c:2440, pcw_patched.c:2128). a6 is that probed Address; a8 is &Length, a kernel stack local inside the dispatcher (which is why *a8 was never routed through a safe accessor).
  3. 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) is PCW_REPLYITEM_BUFFER *, a small header built on the kernel stack by the caller (CompleteInFlightNotification fills a _DWORD v20[10] local and passes it down through Complete). It is not user-supplied data.
  • dst is this->buffer_base (offset 0x30) + this->offset (offset 0x40) - a location inside the target process's user-mode collection buffer, entered under KeStackAttachProcess. RtlCopyToUser (0x140008008) validates this destination (fallback path: ProbeForRead(dst, len, 1) then RtlCopyVolatileMemory) 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.c equivalent). The bulk payload continues to go through MmCopyVirtualMemory (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:

  1. Open \\.\PcwDrv (\Device\PcwDrv) and issue the notify IOCTL that routes through PcwpFastIoDeviceControl -> PcwpIoctlNotify (0x1C0007550) -> PCW_NOTIFIER::SendNotification.
  2. The dispatch calls ProbeForWrite on 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.
  3. On success the handler writes (size<<32)|count metadata into that probed buffer (bounded by the caller-supplied output length) and the collected counter payload via MmCopyVirtualMemory.

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 at 0x1C0008A3A (mov [r15], rax) with the patched call RtlWriteULong64ToUser at 0x14000CA23. Confirm at the store that r15 holds the dispatch's Address and that the value in rax is (*(rdi+0x40) << 32) | *(rdi+0x48).
  • PCW_NOTIFICATION::CopyToNotificationBuffer: compare 0x1C0008260/0x1C0008263 with the patched call RtlCopyToUser at 0x14000BBDC. Confirm rdi/a2 points into kernel stack (the reply-item header) and the destination is inside the attached process buffer.
  • PcwpFastIoDeviceControl: confirm ProbeForWrite(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 already ProbeForWrite-validated by PcwpFastIoDeviceControl in both builds; the substitution to Rtl*ToUser re-validates the same destinations and is defense-in-depth.
  • The value written by SendNotification is 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.