windowsrdppatch-diffingreverse-engineeringAI-generated

CVE-2026-56190: tracking an RDP clipboard lifetime bug through the patch

A static patch diff of rdpcorets.dll shows how Microsoft closed a manager teardown gap affecting CLIPRDR proxy streams and data objects.

Microsoft fixed CVE-2026-56190 in the July 2026 security updates. The advisory describes a use of an uninitialized resource in Windows RDP that can lead to remote code execution. Microsoft assigns a CVSS 3.1 base score of 9.8 and an Important severity rating.

The patch points to the RDP clipboard virtual channel, specifically the proxy objects used for CLIPRDR file-content transfers. The vulnerable implementation could leave a live proxy object attached to a manager that was already terminating. The fix adds a second, authoritative lifetime-tracking collection and synchronizes teardown of the manager back-pointer.

This is a static binary analysis. It identifies the patched lifetime mechanism and its protocol-facing call graph, but it does not include a crash, a working trigger, or dynamic proof of the pre-authentication ordering.

Advisory facts

Field Value
CVE CVE-2026-56190
Component Windows RDP, rdpcorets.dll
Impact Remote code execution
Weakness CWE-908, Use of Uninitialized Resource
CVSS 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
Microsoft severity Important
Publicly disclosed / exploited No / No at publication
Release July 14, 2026

Microsoft’s FAQ says exploitation requires specially crafted RDP traffic sent to a system where Network Level Authentication (NLA) is disabled. It describes interaction with improperly initialized memory, memory corruption, and attacker influence over memory writes.

Diff pair and target substitution

The requested target was Windows Server 2022, build train 20348. Winbindex did not index rdpcorets.dll for that train, so I used the affected and indexed Windows 11 24H2 build train 26100 to bracket the fix:

Vulnerable-side binary Fixed binary
Version 10.0.26100.8737 10.0.26100.8875
Update KB5095093, June 2026 preview KB5101650, July 2026 security update
SHA-256 95180187876b5da66a059203fc50fd186d2a160893ed6bc45992044157f514f2 b93d385b7b2a7e236be780e682f19ed1b2d17944a2a56052ea0bcc213e0f5967

The equivalent Server 2022 update listed by MSRC is KB5099540, build 10.0.20348.5386. The mechanism below is therefore demonstrated directly on Windows 11 24H2, not by diffing the Server 2022 binaries. Confirming the same machine-code change on the 20348 train remains useful follow-up work.

Signal in the diff

The raw diff contained a large amount of offset noise. CProxyStream and related objects grew by 0x28 bytes, shifting fields across otherwise unchanged methods. That also confused function matching: several fixed-only methods appeared in the wrong added/deleted direction until I enumerated functions in each Ghidra program.

The security-relevant additions are concentrated in one lifetime cluster:

  • CProxyStreamManager::RegisterProxyStreamObjectForTracking
  • CProxyStreamManager::UnregisterProxyStreamObjectForTracking
  • CProxyStreamManager::NotifyProxyStreamObjectsOnMgrTermination
  • matching tracking and notification methods for CProxyDataObjectManager
  • a per-stream critical section initialized by CProxyStream::CProxyStream
  • feature-gated branches using Feature_3624641849

The new WIL feature appears only in the fixed binary and gates the stream and data object lifecycle changes.

Vulnerable code

The snippets below are transcribed excerpts from Ghidra decompiler output. Statements are preserved from the analyzed binary and elisions are marked, but these are not Microsoft’s source code or complete functions. Types, local names, templates, and some this adjustments are decompiler recovery artifacts.

In the vulnerable CProxyStreamManager::Terminate at 0x1800c9540, teardown walks an ID-indexed SmartArray beginning at index 1. Only objects found in those slots receive OnManagerTerminating:

EnterCriticalSection((LPCRITICAL_SECTION)(this + 0x30));
iVar1 = *(int *)(this + 0x6c);
iVar5 = 1;
if (1 < iVar1) {
  do {
    local_res8 = (CProxyStream *)0x0;
    iVar4 = SmartArray<CProxyStream,long>::GetAt
                      ((SmartArray<CProxyStream,long> *)(this + 0x68),iVar5,&local_res8);
    pCVar2 = local_res8;
    if ((iVar4 != 0) && (local_res8 != (CProxyStream *)0x0)) {
      CProxyStream::OnManagerTerminating(local_res8);
      (**(code **)(**(longlong **)(pCVar2 + 0x20) + 0x10))();
      iVar4 = SmartArray<CProxyStream,long>::AddAt
                        ((SmartArray<CProxyStream,long> *)(this + 0x68),iVar5,
                         (CProxyStream *)0x0);
    }
    iVar5 = iVar5 + 1;
  } while (iVar5 < iVar1);
}
LeaveCriticalSection((LPCRITICAL_SECTION)(this + 0x30));
*(undefined8 *)(this + 0x60) = 0;
*(uint *)(this + 0x14) = *(uint *)(this + 0x14) | 4;

The vulnerable CProxyStream::OnManagerTerminating at 0x1800c8354 clears its manager back-pointer at offset 0x38 without taking a stream-local lock:

*(undefined8 *)(this + 0x38) = 0;
return;

That design assumes the ID array is a complete inventory of live streams at teardown. The fixed binary indicates that Microsoft no longer relied on that assumption: it adds a separate collection populated during object creation rather than waiting for an ID-array slot to be acquired.

Exact fix

The fixed CProxyStreamManager::Terminate at 0x1800ca5c0 calls a new notifier before walking the legacy array. When the feature is enabled, the old per-slot notification is skipped because the exhaustive pass has already notified every tracked object:

EnterCriticalSection((LPCRITICAL_SECTION)(this + 0x30));
bVar2 = wil::details::FeatureImpl<__WilFeatureTraits_Feature_3624641849>::
          __private_IsEnabled(/* ... */);
if (bVar2) {
  NotifyProxyStreamObjectsOnMgrTermination(this + -8);
}

/* legacy SmartArray walk */
if ((iVar4 != 0) && (local_res8 != (CProxyStream *)0x0)) {
  bVar2 = wil::details::FeatureImpl<__WilFeatureTraits_Feature_3624641849>::
            __private_IsEnabled(/* ... */);
  if (!bVar2) {
    CProxyStream::OnManagerTerminating(this_00);
  }
  /* release and clear the legacy slot */
}

The new notifier at 0x1800c90e4 holds a dedicated manager lock, enumerates the new DynArray, notifies each non-null stream, clears its slot, and finally resets the logical count:

EnterCriticalSection((LPCRITICAL_SECTION)(this + 0x88));
iVar1 = *(int *)(this + 0xb4);
uVar2 = 0;
if (0 < iVar1) {
  lVar5 = 0;
  do {
    local_res8 = (CProxyDataObject *)0x0;
    if ((uVar2 < *(uint *)(this + 0xb4)) &&
       (this_00 = *(CProxyStream **)(*(longlong *)(this + 0xb8) + lVar5 * 8),
       this_00 != (CProxyStream *)0x0)) {
      CProxyStream::OnManagerTerminating(this_00);
      iVar3 = DynArray<CProxyDataObject*___ptr64,unsigned_long>::AddAt
                        ((DynArray<CProxyDataObject*___ptr64,unsigned_long> *)
                         (this + 0xb0),uVar2,&local_res8);
    }
    uVar2 = uVar2 + 1;
    lVar5 = lVar5 + 1;
  } while (lVar5 < iVar1);
}
*(undefined4 *)(this + 0xb4) = 0;
LeaveCriticalSection((LPCRITICAL_SECTION)(this + 0x88));

The template type recovered as CProxyDataObject* in this function is inconsistent with the symbol and accessed values; this is a decompiler typing artifact. The method is the stream notifier and calls CProxyStream::OnManagerTerminating.

Registration and removal both modify the tracking array under the same manager lock:

/* RegisterProxyStreamObjectForTracking, 0x1800c98d4 */
EnterCriticalSection((LPCRITICAL_SECTION)(this + 0x88));
iVar1 = DynArray<CProxyDataObject*___ptr64,unsigned_long>::AddAt
                  ((DynArray<CProxyDataObject*___ptr64,unsigned_long> *)(this + 0xb0),
                   param_2,(CProxyDataObject **)&local_res10);
LeaveCriticalSection((LPCRITICAL_SECTION)(this + 0x88));

/* UnregisterProxyStreamObjectForTracking, 0x1800ca740 */
local_res8 = (CProxyDataObject *)0x0;
EnterCriticalSection((LPCRITICAL_SECTION)(this + 0x88));
iVar1 = DynArray<CProxyDataObject*___ptr64,unsigned_long>::AddAt
                  ((DynArray<CProxyDataObject*___ptr64,unsigned_long> *)(this + 0xb0),
                   param_1,&local_res8);
LeaveCriticalSection((LPCRITICAL_SECTION)(this + 0x88));

Finally, the fixed constructor initializes a critical section at stream offset 0x40, and the fixed OnManagerTerminating at 0x1800c9230 uses it around the back-pointer clear:

/* CProxyStream::CProxyStream, 0x1800c7ba0 */
if (bVar1) {
  InitializeCriticalSection((LPCRITICAL_SECTION)(this + 0x40));
}

/* CProxyStream::OnManagerTerminating, 0x1800c9230 */
if (bVar1) {
  EnterCriticalSection((LPCRITICAL_SECTION)(this + 0x40));
  *(undefined8 *)(this + 0x38) = 0;
  LeaveCriticalSection((LPCRITICAL_SECTION)(this + 0x40));
}
else {
  *(undefined8 *)(this + 0x38) = 0;
}

The same pattern is mirrored for CProxyDataObject and its manager.

Root cause

CProxyStream objects proxy file-content reads over CLIPRDR and keep a back-reference to their manager. In the vulnerable build, manager termination notified only streams currently present in the manager’s ID-indexed SmartArray. The patch is consistent with a live stream being able to exist outside that collection because creation, external COM lifetime, and acquisition of an ID-array slot are distinct events; the static analysis does not prove the concrete ordering that creates this state.

If teardown missed such a stream, its manager back-pointer was not cleared. A later operation could then use state belonging to a manager whose channel resources had already been torn down. The new stream-local lock specifically coordinates OnManagerTerminating with the destructor’s snapshot and reference of that manager pointer before unregistration; the analyzed read path does not acquire this lock.

The patch closes both gaps:

  1. Every stream is registered at creation and unregistered at destruction in a separate birth-to-death tracking array.
  2. Manager termination walks that authoritative array and notifies every live stream.
  3. Each stream coordinates manager notification with destructor-side manager snapshot/unregistration through a dedicated critical section.
  4. The same lifecycle discipline is applied to clipboard data objects.

This is consistent with Microsoft’s CWE-908 classification: a proxy can otherwise use a manager resource after that resource has entered an invalid post-termination state.

Static reachability evidence

Cross-references in the vulnerable and fixed Ghidra programs support the mechanism:

  • The vulnerable OnManagerTerminating has one caller, the legacy manager teardown. The fixed build adds the exhaustive notifier as a second caller.
  • CProxyStream::CreateInstance calls the new registration method, while the destructor calls unregistration. ID-array acquisition occurs separately through AcquireProxyStream and SendFileContentsRequest.
  • Peer-controlled file-content response bytes flow through CClipBase::OnFileContentsResponse, OnStreamDataReceived, and CProxyStream::FillReceiveBuffer.
  • Teardown flows from CClipBase::Terminate to CProxyStreamManager::Terminate and, in the fixed build, to the new notifier.

These facts establish the changed lifetime invariant and protocol-facing data path. They do not establish a concrete packet sequence that wins the teardown/use ordering.

Confidence and remaining work

Confidence in the patched mechanism’s CVE mapping is high: the diff brackets Microsoft’s fixed build, the new WIL feature gates only the affected proxy-object lifecycle, and the tracking-plus-locking change maps directly to the advisory’s uninitialized-resource description. Confidence in the exact packet ordering and failure sequence is lower because neither has been reproduced dynamically.

Dynamic validation is still pending. The useful next checks are:

  • reproduce a fault on 26100.8737 and a negative result on 26100.8875;
  • confirm the ordering is reachable before logon with NLA disabled;
  • extract and diff the Server 2022 20348 pair directly.

No trigger or exploit code is included here.

References