Share
## https://sploitus.com/exploit?id=F977C815-31EB-5AA5-8A07-C113CEE5A37D
# When `ValidateHwnd` Isn't a Gate: Root-Causing CVE-2026-54107

> A use-after-free in `win32kfull.sys` window lifecycle management โ€” how I found it, how I convinced myself it was real, and what the MSRC process actually looked like from the researcher's side.


  
  
  
  
  




It was close to 2 AM when the target VM stopped responding to the debugger's heartbeat and dropped into a break at exactly the instruction I'd spent weeks arguing was reachable. Not an assertion, not a corrupted-pool stop โ€” a plain access violation on the message-dispatch path, dereferencing an object that another thread had already torn down.

That break became **CVE-2026-54107**, MSRC case **11xxxxx**, patched in the **July 2026 Security Update** across 27 Windows products.

This post is the non-embargoed half of the story: root cause, why the bug class is what it is, and the reasoning that got me there. Exploitation specifics stay out.



## Table of Contents

- [1. Why win32k, and why window objects specifically](#1-why-win32k-and-why-window-objects-specifically)
- [2. The smell that made me stop](#2-the-smell-that-made-me-stop)
- [3. Root cause](#3-root-cause)
- [4. Why the impact rating is what it is](#4-why-the-impact-rating-is-what-it-is)
- [5. Falsification came first โ€” most candidates died](#5-falsification-came-first--most-candidates-died)
- [6. Verification: static gives hypotheses, the debugger gives truth](#6-verification-static-gives-hypotheses-the-debugger-gives-truth)
- [7. On using AI in kernel research](#7-on-using-ai-in-kernel-research)
- [8. The MSRC timeline, honestly](#8-the-msrc-timeline-honestly)
- [9. Snapshot](#9-snapshot)
- [10. What I'd tell someone starting](#10-what-id-tell-someone-starting)
- [11. What's next](#11-whats-next)



## 1. Why win32k, and why window objects specifically

Win32k is the kernel-mode half of the Windows graphical subsystem. It's old, it's enormous, and โ€” critically โ€” it's reachable from contexts that are supposed to be untrusted. That last property is why it stays a permanent research target despite twenty years of hardening, filtering, and syscall restriction work.

Within win32k, the `tagWND` object (`PWND`) is unusually interesting because its lifetime is managed by more than one mechanism at once. A window is:

- referenced **by handle**, through the user handle table and `ValidateHwnd`-style lookups,
- referenced **by pointer**, held across nested calls and message dispatch,
- referenced **implicitly** by parent/child, owner/owned, and thread/desktop relationships,
- and torn down through a **destruction path** that has to unwind all of the above in the right order.

Any object with several independent reference paths and one shared destruction path is worth reading slowly. That's not a vulnerability claim โ€” it's a heuristic for where to spend time.



## 2. The smell that made me stop

What made me sit on this component was the import surface. `win32kfull.sys` pulls in three distinct object reference primitives from `ntoskrnl`:

```c
NTSTATUS ObReferenceObjectByPointer(
    void            *Object,
    uint32_t         DesiredAccess,
    POBJECT_TYPE     ObjectType,
    char             AccessMode);

NTSTATUS ObReferenceObjectByHandle(
    HANDLE                       Handle,
    uint32_t                     DesiredAccess,
    POBJECT_TYPE                 ObjectType,
    char                         AccessMode,
    void                       **Object,
    OBJECT_HANDLE_INFORMATION   *HandleInformation);

NTSTATUS ObReferenceObjectByName(/* ... */);
```

Three ways in, one `ObfDereferenceObject` path out.

That doesn't mean the code is wrong. It means the **invariant is distributed** โ€” no single function owns *"this object is alive right now,"* so correctness depends on every caller agreeing about which reference they hold and how long it's valid for. Distributed invariants are where race conditions live, because a race is never a logic bug you can see in one function. It's a bug in an assumption held across two.

So the question I started asking every function that touched a `PWND` was not *"is this code correct?"* but:

> **If this exact function body executes on two threads a few instructions apart, which of them is wrong?**



## 3. Root cause

The defect is a **time-of-check / time-of-use gap between reference release and object teardown** in the window destruction path, without adequate synchronization against a concurrent handle-validating consumer.

Stripped to its shape:

```c
/* Thread A โ€” teardown */
NtUserDestroyWindow(HWND hwnd)
{
    PWND pWnd = ValidateHwnd(hwnd);
    if (pWnd) {
        ObfDereferenceObject(pWnd);   /* reference released */

        /* fnid == FNID_BUTTON)    /* use-after-free */
        ...
}
```

Two things have to be true for this to matter, and both were:

**(a) The window is real.** `ValidateHwnd` is the gate that's supposed to make handle-based access safe. If validation can succeed against an object whose teardown has already begun, the gate isn't a gate โ€” it's a suggestion.

**(b) The freed memory is attacker-influenceable.** The fields read immediately after validation include `fnid`, which drives message dispatch. A dispatch decision made from reclaimed memory is the difference between *"unreliable crash"* and *"security boundary violation."* That distinction is the entire reason this is CWE-362 with EoP impact and not a stability bug.

> The observed **corruption** is a use-after-free; the **cause** is CWE-362, concurrent execution using a shared resource with improper synchronization. Those are two different statements and MSRC cares about the second one. **Report the cause, not just the symptom.**

### Why win32k races are structurally harder than they look

If you've raced bugs in other subsystems, win32k will frustrate you, because the architecture fights you in three specific ways.

**Windows have thread affinity.** A window belongs to the thread that created it. A lot of the subsystem is built around the assumption that the owning thread is the one touching the object, which means the naive "spin two threads calling the same API" approach frequently doesn't overlap anything โ€” you're not racing, you're queueing. Getting two paths to genuinely collide on the same object requires understanding which operations actually execute on the caller's thread versus which get marshalled to the owner's.

**Message dispatch partially serializes you.** Sends and posts behave differently, and cross-thread versus same-thread dispatch behave differently again. Some of what looks like a concurrency opportunity is silently converted into an ordered operation before it ever reaches the code you care about. If you don't know which category your trigger falls into, you'll conclude a real race is unreachable โ€” a false negative that looks identical to "no bug here."

**The critical section hides in the caller.** Much of the subsystem runs under a coarse lock acquired well above the function you're staring at. This is the single biggest source of wasted time in win32k auditing: a function with no visible synchronization that is nonetheless perfectly safe because every path into it is already serialized. **Lock coverage is an interprocedural property.** You must walk up the call graph, not just read the function.

That third point is why *"no lock in this function"* is worth almost nothing as a signal, and why most of the work in this hunt was spent on reachability rather than on the defect itself.


## 4. Why the impact rating is what it is

MSRC assessed this as **Important, Elevation of Privilege, CVSS 8.8, attack vector local, authenticated.** Two properties drive that:

**Reachability from low integrity.** Win32k message-call surface is reachable from contexts far below SYSTEM. That's what makes it relevant to sandbox-escape chains โ€” a renderer process that has already achieved code execution inside its sandbox can still reach this surface. A kernel bug's severity is mostly a function of *who can touch it*, not how clever the corruption is.

**Dispatch-influencing corruption.** Corrupting a field that a `switch` runs on is qualitatively worse than corrupting a field that only gets logged. The former turns a memory bug into a control-flow question.

I want to be precise about something here, because I've seen first-CVE posts overstate this: **I demonstrated the race and the use-after-free. I did not ship a weaponized SYSTEM-level exploit.** The sandbox-escape framing describes the *class of chain* this bug type belongs to and why the surface is valuable โ€” it is an argument about reachability, not a claim that I built one. Overclaiming impact is the fastest way to burn credibility with a vendor, and MSRC's assessment is the number that matters, not mine.



## 5. Falsification came first โ€” most candidates died

The part nobody writes about: this was not the first candidate. It was the one that survived.

My working rule is that **a candidate is guilty until proven guilty.** Every promising pattern gets a specific, written-down reason it *shouldn't* be exploitable, and I go try to establish that reason before I go try to trigger it. Candidates I closed before this one included:

- paths that looked unsynchronized but were serialized by a lock acquired one frame up,
- paths where the "freed" object was actually cached rather than released,
- paths that were genuinely racy but not reachable from any caller a low-privilege user could drive.

Every one of those is a finding I *did not* send to MSRC. That's the point. A researcher's throughput isn't how many candidates they generate โ€” it's how fast they can kill the wrong ones so they're not still holding them at 2 AM.

**The three questions that killed most candidates:**

1. **Is anything above me holding a lock?** Interprocedural, not local. The absence of a lock in a function means nothing.
2. **Can an unprivileged caller actually reach both sides?** A race between two paths that require different privilege levels isn't a race, it's a thought experiment.
3. **Is the freed memory reclaimable in a window I can influence?** If teardown completes atomically for practical purposes, there's no bug worth reporting.



## 6. Verification: static gives hypotheses, the debugger gives truth

Static analysis of `win32kfull.sys` gave me the hypothesis. It could never give me the bug. **Race conditions aren't visible in a decompiler** because the defect isn't in the instructions โ€” it's in the interleaving.

### Lab

| Role | Setup |
| --- | --- |
| Host / debugger | Windows 11, WinDbg |
| Target | Windows Server 2022, Build 20348.2159 |
| Analysis | Kali Linux + Windows 11 VM |
| Debug transport | VMware serial COM, host โ†’ target kernel debugging |
| Static analysis | Ghidra via GhidraMCP |
| Triage assist | AI-assisted pass over decompiled output |

Three instrumentation layers did the actual work.

### Special pool and Driver Verifier

The single highest-leverage step in any kernel UAF investigation. By default, freed pool memory is reused almost immediately by the next allocation of a similar size โ€” which means a use-after-free usually *doesn't fault*. It reads someone else's valid data, keeps executing, and detonates somewhere unrelated minutes later. You then spend three days auditing an innocent function.

Special pool changes that. Each allocation gets its own page with a guard page adjacent, and freed pages are marked no-access rather than recycled. The result is that the offending dereference faults **at the instruction that performs it**, not downstream:

```
!verifier 0x1 win32kfull.sys        ; special pool on the target driver
!verifier 0x8 win32kfull.sys        ; pool tracking
```

Combined with pool tag filtering, this is what turns *"intermittent bugcheck under load"* into a reproducible, attributable fault.

> **If you take one thing from this post:** enable special pool *before* you start, not after you're stuck.

### Pool forensics on the fault

Once you have a fault, the question is whether you're looking at **corruption** or at a **lifetime bug**. Those need different reports. The pool metadata answers it:

```
kd> !pool 
kd> !pool  2
```

A block that is *allocated* with a plausible tag and garbage contents points toward **corruption**. A block that is *freed*, or sitting in a special-pool no-access page, points toward a **lifetime bug** โ€” something held a pointer past the object's death. That's the distinction between *"attacker wrote here"* and *"this object should not have been reachable,"* and it's the difference between a heap-corruption report and a CWE-362 report.

Cross-check the object type before committing to either. A `PWND` has a recognizable shape; if the memory you faulted on still carries the remnants of one, you are almost certainly in a window-lifetime problem rather than a random overwrite.

### Live kernel debugging on the interleaving

Even with special pool, a race is a scheduling problem, and **the debugger changes scheduling.** That's the central frustration of race work: the instrument perturbs the thing it measures. A breakpoint on the teardown path serializes exactly the two threads you're trying to overlap, and the bug politely disappears.

The way through it is to stop trying to catch the race with a breakpoint and instead:

- **widen the window artificially** โ€” anything that lengthens the interval between reference release and teardown makes the collision reachable at normal scheduling rates,
- **increase collision attempts rather than precision** โ€” run the two paths continuously and let probability do the work,
- **use conditional and one-shot breakpoints** that only arm once the interesting state exists, instead of breaking on every entry,
- **confirm the interleaving after the fault**, from thread state and stacks, rather than trying to observe it live.

The fault itself, once you have it, is unglamorous โ€” a dereference of a `PWND` on the message-dispatch path where the object has already gone through teardown on another thread, with `!pool` confirming the block was released rather than overwritten:

```
kd> !analyze -v

EXCEPTION_CODE: (NTSTATUS) 0xc0000005 - Access violation
FAULTING_MODULE: win32kfull
```

*(Offsets, addresses and reproduction detail are withheld under coordinated disclosure.)*

### Confirming the boundary claim

Impact isn't proven by a crash. It's proven by **who can cause the crash.** Every trigger run was executed from a standard, non-administrator user account on the target, because a kernel fault reachable only from an already-privileged context is a stability bug, not a security one. Checking the integrity level of the process that drove the collision is a thirty-second step that decides whether you have a bounty case or a Windows Feedback Hub entry.

The key discipline: **I did not believe a single crash.** A single crash on a race is noise. What made it reportable was repeatability under controlled timing โ€” being able to say *"these two paths, this ordering, this window"* and get the same fault back. That's the difference between a report MSRC can act on and one they close as not-reproducible.



## 7. On using AI in kernel research

I used AI-assisted triage to move through decompiled output faster, and I'll say plainly what it was and wasn't good for.

**Good for: covering surface area.** Reading a large volume of HLIL and flagging *"these functions touch a shared object with no visible synchronization"* is pattern-matching, and pattern-matching at volume is exactly what these tools do well. It compressed weeks of skimming into days.

**Useless for: judgment.** It will confidently narrate an attack chain that doesn't exist, assert reachability it hasn't established, and produce a beautifully structured writeup for a bug that isn't there. Every single conclusion had to survive manual verification in WinDbg before it went anywhere near a report.

> The failure mode to fear isn't the tool being wrong. It's the tool being **fluent while wrong**, at 2 AM, when you want it to be right.

A fabricated finding sent to MSRC costs their engineers real time and costs you a reputation you can't rebuild quickly.


## 8. The MSRC timeline, honestly

| Date | Event |
| --- | --- |
| May 7, 2026 | Submitted โ€” VULN-186460 |
| May 7, 2026 | Case opened โ€” MSRC Case 11xxxxx |
| Jun 11, 2026 | Behavior confirmed by Microsoft; bounty review opened |
| Jun 27, 2026 | Fix scheduled for July release; CVE-2026-54107 assigned (pre-release) |
| Jun 30, 2026 | Bounty awarded โ€” US$8,000, Windows Insider Preview Bounty Program |
| Jul 14, 2026 | Patch shipped; CVE published |

Five weeks of silence between submission and confirmation. That's the part that tests you. You've written down a claim about someone else's kernel and you have no idea yet whether your reasoning holds, whether it's a duplicate, or whether it even reproduces on their build. The confirmation email is the moment it stops being a theory you have and becomes a vulnerability that exists.

One small thing that made me laugh at myself: on July 14 my time, I pinged the case asking why the CVE hadn't published. The reply, politely: *it's July 13 in Seattle.* Microsoft's release calendar runs on Pacific time. Now I know.

---

## 9. Snapshot

| Field | Detail |
| --- | --- |
| **CVE** | CVE-2026-54107 |
| **MSRC case** | 11xxxxx (VULN-186460) |
| **Component** | `win32kfull.sys` โ€” window object lifecycle |
| **Class** | Race condition โ†’ use-after-free |
| **CWE** | CWE-362 |
| **Impact** | Elevation of Privilege |
| **Severity** | Important (MSRC) |
| **CVSS v3.1** | 8.8 (High) |
| **Vector** | Local, authenticated |
| **Program** | Windows Insider Preview Bounty Program |
| **Award** | US$8,000 |
| **Fixed** | July 2026 Security Update |

---

## 10. What I'd tell someone starting

**Read for invariants, not for bugs.** *"Where does this code assume something it doesn't enforce?"* finds more than *"where is the overflow?"* โ€” especially in mature, heavily-audited components where the easy classes are gone.

**A race is an interprocedural claim.** You cannot establish or refute one from a single function. If your analysis stops at the function boundary, you'll generate candidates you can never close.

**Your debugging environment is the job.** I lost more hours to an unstable serial COM link than to the actual hunt, and a broken pipeline produces false negatives that look identical to "there's nothing here." I nearly abandoned this target over a COM port setting.

**Report the cause, not the crash.** MSRC gets crashes. What moves a case is a coherent story about which invariant broke and why the enforcement wasn't there.

**Confirmed isn't done.** Between confirmation and shipped patch there's reproducibility follow-up, Canary behavior, and review. Stay engaged.


## 11. What's next

Same methodology, different surfaces โ€” `tcpip.sys`, `afd.sys`, `clfs.sys`. I'd rather be known for a body of work than for one lucky find, and the only way that happens is to keep killing candidates faster than I generate them.

If you're where I was a year ago โ€” coming from web bounties, curious about kernel work, unsure whether you're the sort of person who can do this โ€” you find out by doing it. Pick one driver. Get a debugger attached. Read slowly. Keep asking what happens if it runs twice.

That's genuinely where it starts.


Written by **Pravin Choudhary** ([@pr4v1nx](https://github.com/pr4v1nx)) โ€” independent offensive security researcher. Disclosed to Microsoft under coordinated disclosure. Exploitation detail, offsets, and reproduction code are intentionally withheld.