# dwmcore.dll Patch Diff — CVE-2026-20871

| | |
|---|---|
| Binary | dwmcore.dll (Desktop Window Manager Composition Core) |
| Pre-patch version | 10.0.26100.7309 |
| Post-patch version | 10.0.26100.7623 |
| KB | KB5074109 |
| CVE | CVE-2026-20871 — Use After Free (CWE-416), Elevation of Privilege, CVSS 7.8 |
| Diff tool | ghidriff (Ghidra VersionTrackingDiff engine) |
| Functions changed | 2 with code changes (out of 25,517+ total, 99.97% avg similarity) |
| Functions added | 4 (WIL Controlled-Feature-Rollout accessors for `Feature_1732988217`) |
| Independent analysis | [Elastic Security Labs — "Patch diff to SYSTEM"](https://www.elastic.co/security-labs/patch-diff-to-system) by Joe Desimone |

## Summary

The fix is a use-after-free in
`CSynchronousSuperWetInk::~CSynchronousSuperWetInk` — a DirectComposition
destructor that used to *conditionally* unregister the object from its
manager. If the condition wasn't met, the object was freed while its pointer
remained live in `CSuperWetInkManager::localStrokesVector`, and the next DWM
render pass (`DirtyActiveInk`) would dereference the dangling vtable through a
controlled-address virtual call.

The patched build imports a new Controlled-Feature-Rollout flag,
`Feature_1732988217`. When the flag is enabled, `RemoveSource()` is called
**unconditionally** — the object is always unregistered before destruction. The
`IsSuperWetCompatible()` check is only consulted as a fallback when the flag is
disabled, giving Microsoft a kill-switch to roll the fix back per cohort.

The diff also surfaces a **sibling fix in `CDelegatedInkCanvas::~CDelegatedInkCanvas`**
(same feature flag, same pattern — condition widened so `RemoveSource()` runs
even when the pre-patch guard would have skipped it). Not part of Elastic's
public write-up, and likely closes a related dangling-pointer path in the same
subsystem.

The specific low-privilege trigger sequence (create ink trail → `LookupMode=2`
during Draw → `LookupMode=0` → release → `DirtyActiveInk`) and the full
exploit chain (GetRECT heap spray → `__fnINSTRING` + `CStdAsyncStubBuffer2_Disconnect`
gadget chain → `VirtualProtect` + `WinExec`) are documented in Elastic's
write-up.

**Confidence:** high. The added feature-flag `__private_IsEnabled` call, the
inverted branch structure making `RemoveSource()` the default path, and the
one-to-one match with an independently-published root cause analysis all line
up. Two independent parties (Trend ZDI's anonymous reporter credited by MSRC,
and Elastic Security Labs) reached the same conclusion.

**Credit:** MSRC credits "Anonymous working with Trend Zero Day Initiative."
Independent public exploit development and full exploit chain by
[Joe Desimone, Elastic Security Labs](https://www.elastic.co/security-labs/patch-diff-to-system).

## Functions changed

### CSynchronousSuperWetInk::~CSynchronousSuperWetInk (the destructor)

| | |
|---|---|
| Address | 1802a9818 -> 1802a9ad8 |
| Change type | code, length, address, called |
| Similarity | 0.82 (ratio), 0.91 (b_ratio) |
| Length | 169 -> 188 bytes |
| Guard flipped | **Yes** — `RemoveSource()` now runs by default when `Feature_1732988217` is enabled |

Before (pre-patch — the bug):

```c
// dwmcore.dll 10.0.26100.7309 — VULNERABLE
void __thiscall CSynchronousSuperWetInk::~CSynchronousSuperWetInk(CSynchronousSuperWetInk *this)
{
    *(undefined ***)this = &_vftable_;
    bVar2 = IsSuperWetCompatible(this);
    if (bVar2) {
        CSuperWetInkManager::RemoveSource(
            *(CSuperWetInkManager **)(*(longlong *)(this + 0x18) + 0x290),
            (CSuperWetSource *)this);
    }
    // ... cleanup continues (freeing the object) ...
}
```

If `IsSuperWetCompatible()` returns `false`, `RemoveSource()` is skipped and
the vector inside `CSuperWetInkManager` keeps a pointer to memory that's about
to be freed. An attacker flips the object's state (via
`CMD_SET_PROPERTY` with `LookupMode=0`) between the registration that
happened during Draw and the destruction — so registration used the "yes"
branch, but the destructor takes the "no" branch, orphaning the pointer.

After (post-patch — the fix):

```c
// dwmcore.dll 10.0.26100.7623 — FIXED
void __thiscall CSynchronousSuperWetInk::~CSynchronousSuperWetInk(CSynchronousSuperWetInk *this)
{
    *(undefined ***)this = &_vftable_;
    bVar2 = wil::details::FeatureImpl<Feature_1732988217>::__private_IsEnabled(&impl);
    if (!bVar2) {
        bVar2 = IsSuperWetCompatible(this);
        if (!bVar2) goto LAB_0;   // only skip when flag disabled AND not compatible
    }
    CSuperWetInkManager::RemoveSource(
        *(CSuperWetInkManager **)(*(longlong *)(this + 0x18) + 0x290),
        (CSuperWetSource *)this);
LAB_0:
    // ... cleanup continues ...
}
```

When the flag is on (production path once rolled out), `RemoveSource()` runs
unconditionally regardless of the object's current `LookupMode`. When the flag
is off, behaviour matches the vulnerable build — this is Microsoft's kill
switch. The `called` list confirms it: post-patch adds
`wil::details::FeatureImpl<Feature_1732988217>::__private_IsEnabled`; nothing
else in the destructor is added or removed.

### CDelegatedInkCanvas::~CDelegatedInkCanvas (sibling fix)

| | |
|---|---|
| Address | 18029b90c (unchanged) |
| Change type | code, length, called |
| Similarity | 0.69 (ratio), 0.78 (b_ratio) |
| Guard widened | **Yes** — new OR clause forces `RemoveSource()` when flag is enabled |

Before (pre-patch):

```c
// dwmcore.dll 10.0.26100.7309
if (*(longlong *)(this + 0xc0) != 0) {
    CSuperWetInkManager::RemoveSource(..., (CSuperWetSource *)this);
}
```

After (post-patch):

```c
// dwmcore.dll 10.0.26100.7623
bVar1 = wil::details::FeatureImpl<Feature_1732988217>::__private_IsEnabled(&impl);
if ((bVar1) || (*(longlong *)(this + 0xc0) != 0)) {
    CSuperWetInkManager::RemoveSource(..., (CSuperWetSource *)this);
}
```

Same pattern as the `CSynchronousSuperWetInk` destructor: the pre-patch guard
could leave a dangling pointer if the `this+0xc0` field wasn't set; the patch
short-circuits the guard when the flag is on, so `RemoveSource()` always runs.
Same feature flag (`Feature_1732988217`) gates both fixes — they were shipped
as one logical change against `CSuperWetInkManager::localStrokesVector` UAFs.

### New functions (feature-flag plumbing for `Feature_1732988217`)

`GetCachedFeatureEnabledState`, `GetCurrentFeatureEnabledState`, `ReportUsage`,
`__private_IsEnabled` — standard WIL Controlled Feature Rollout accessors for
the new flag gating both destructor fixes above.

## Exploit summary (from Elastic Security Labs)

Elastic's [Patch diff to SYSTEM](https://www.elastic.co/security-labs/patch-diff-to-system)
walks the full exploit chain — reproduced here in outline; see the original
for the detailed reasoning, register-level shellcode layout, and video demo.

### Prerequisites

- D3D11/DXGI device with BGRA support and a swap chain for a visible window
- DirectComposition device via `DCompositionCreateDevice()`
- Direct access to `NtDCompositionProcessChannelBatchBuffer` and
  `NtDCompositionCommitChannel` (via `win32u.dll`) to inject raw batch
  buffer commands

### Trigger sequence (UAF)

1. **Allocate the target object.** Query `IDCompositionInkTrailDevice`, call
   `CreateDelegatedInkTrailForSwapChain()` — allocates a
   `CSynchronousSuperWetInk` (resource type `0xa8`, 288 bytes) in dwm.exe's
   heap.
2. **Register with the manager.** Batch commands: create `CSuperWetInkVisual`
   (type `0xa5`, cmd `0x02`), connect to ink source (`CMD_SET_REFERENCE`
   `0x10`, propId `0x34`), set **`LookupMode=2`** (`CMD_SET_PROPERTY` `0x0B`,
   propId `10`), connect to composition tree. `LookupMode=2` makes
   `IsSuperWetCompatible()` return true during Draw → object gets added to
   `CSuperWetInkManager::localStrokesVector`.
3. **Render frames.** `Present()` + commit — DWM's render loop runs Draw and
   registers the pointer.
4. **Flip `LookupMode` to 0.** `CMD_SET_PROPERTY` propId 10 with value 0. Now
   `IsSuperWetCompatible()` will return false in the destructor.
5. **Release the ink trail.** Disconnect visual refs, release the interface —
   destructor runs, `RemoveSource()` is skipped (the bug), object is freed,
   pointer stays in the vector.
6. **Trigger the UAF.** Continue presenting frames — DWM calls
   `CSuperWetInkManager::DirtyActiveInk`, which iterates the vector and does
   `pcVar2 = *(code **)((*puVar4)->vtable + 0x50);  (*pcVar2)();` on the
   dangling pointer.

### Heap spray (GetRECT)

- Reclaim the 288-byte allocation with an 18-RECT array on a
  `CRegionGeometry` (type `0x81`) via `CMD_SET_BUFFER_PROPERTY` (`0x0F`)
  propId 5.
- Backing allocator is `HeapAlloc(GetProcessHeap(), 0, 288)` — same LFH
  bucket (34) as the target, 57 slots per subsegment.
- 72 controlled int32s = full byte-level control of the reclaimed slab.

### Gadget chain (CFG-compatible)

The UAF gives `RIP = [[spray]+0x50]` with `RCX = spray`. Elastic pivots
through two CFG-valid gadgets, avoiding a heap-address leak:

- **Stage 1 — `__fnINSTRING` (user32.dll).** Point the fake vtable at
  `&KCT[fnINSTRING_index] - 0x50`; the `KernelCallbackTable` entry
  dereferences to the function's real address. `__fnINSTRING` runs
  `FixupCallbackPointers` on the buffer, converting relative offsets to
  absolute — solving the "no ASLR leak" problem in-place — then dispatches
  the inner function pointer at `+0x48` with arguments preserved in
  `RDX`/`R8`/`R9`.
- **Stage 2 — `CStdAsyncStubBuffer2_Disconnect` (combase.dll).** Two
  sequential vtable calls with preserved argument registers. First call =
  `VirtualProtect(spray, 0x1000, PAGE_EXECUTE_READWRITE, ...)` → marks the
  spray page RWX and CFG-valid. Second call = inline shellcode at `+0xD0` in
  the same spray, split around the `VirtualProtect` pointer at `+0xE8`;
  shellcode calls `WinExec("cmd.exe", SW_SHOW)`, defuses the spray so
  re-entry is harmless, and `add rsp, 0xB8` to unwind past both intermediate
  frames back into DWM's composition loop.

The DWM process runs as the DWM user at System integrity. Elastic notes they
developed a novel DWM-to-SYSTEM path but withheld publication.

---

<sub>Source: ghidriff diff of dwmcore-2025-12.dll (10.0.26100.7309, pre-patch)
vs dwmcore-2026-01.dll (10.0.26100.7623, post-patch) —
[download pre](/data/patch_diffs/binaries/dwmcore-2025-12.dll) /
[download post](/data/patch_diffs/binaries/dwmcore-2026-01.dll). Independent
analysis and full exploit chain: [Joe Desimone / Elastic Security Labs
— Patch diff to SYSTEM](https://www.elastic.co/security-labs/patch-diff-to-system).</sub>
