
Análise de causa-raiz da CVE-2026-54107: um use-after-free no Windows win32kfull.sys com depuração de condição de corrida, análise estática, insights de triagem do MSRC e pesquisa prática de exploração de kernel.
ValidateHwnd Não É uma Barreira: Causa-Raiz da CVE-2026-54107Um use-after-free no gerenciamento do ciclo de vida de janelas em
win32kfull.sys— como o encontrei, como me convenci de que era real e como o processo do MSRC realmente se parecia do lado do pesquisador.
Eram quase 2 da manhã quando a VM alvo parou de responder ao heartbeat do depurador e caiu em um break exatamente na instrução que eu passei semanas argumentando que era alcançável. Não uma asserção, não uma parada de pool corrompido — uma simples violação de acesso no caminho de despacho de mensagens, desreferenciando um objeto que outra thread já havia destruído.
Esse break se tornou a , caso do MSRC, corrigida na em 27 produtos Windows.
kd> !pool 2Este post é a metade não embargada da história: causa raiz, por que a classe de bug é o que é e o raciocínio que me levou até lá. Detalhes de exploração ficam de fora.
Win32k é a metade em modo kernel do subsistema gráfico do Windows. É antigo, é enorme e — criticamente — é alcançável a partir de contextos que deveriam ser não confiáveis. Essa última propriedade é a razão pela qual continua sendo um alvo permanente de pesquisa apesar de vinte anos de endurecimento, filtragem e restrição de chamadas de sistema.
Dentro do win32k, o objeto tagWND (PWND) é excepcionalmente interessante porque seu tempo de vida é gerenciado por mais de um mecanismo ao mesmo tempo. Uma janela é:
ValidateHwnd,Qualquer objeto com vários caminhos de referência independentes e um caminho de destruição compartilhado vale a pena ser lido com calma. Isso não é uma afirmação de vulnerabilidade — é uma heurística para onde gastar tempo.
O que me fez parar nesse componente foi a superfície de importação. O win32kfull.sys importa três primitivas distintas de referência de objeto do 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(/* ... */);
Três formas de entrada, um caminho de saída `ObfDereferenceObject`.
Isso não significa que o código esteja errado. Significa que o **invariante é distribuído** — nenhuma função individual é dona de *"este objeto está vivo agora"*, portanto a corretude depende de cada chamador concordar sobre qual referência ele detém e por quanto tempo ela é válida. Invariantes distribuídos são onde as condições de corrida moram, porque uma corrida nunca é um bug de lógica que você pode ver em uma única função. É um bug em uma suposição mantida entre duas.
Então a pergunta que comecei a fazer a toda função que tocava em um `PWND` não era *"este código está correto?"* mas sim:
> **Se exatamente este corpo de função for executado em duas threads com algumas instruções de diferença, qual delas está errada?**
## 3. Causa raiz
O defeito é uma **lacuna de tempo de verificação / tempo de uso entre a liberação da referência e a destruição do objeto** no caminho de destruição da janela, sem sincronização adequada contra um consumidor concorrente que valida handles.
Reduzido à sua forma:```c
/* Thread A — teardown */
NtUserDestroyWindow(HWND hwnd)
{
PWND pWnd = ValidateHwnd(hwnd);
if (pWnd) {
ObfDereferenceObject(pWnd); /* reference released */
/* <-- race window: object may become reclaimable here */
FreeWindowObject(pWnd); /* teardown proceeds on a pointer
no longer guaranteed live */
}
}
if (pWnd->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
Combinado com a filtragem por tag de pool, é isso que transforma "bugcheck intermitente sob carga" em uma falha reproduzível e atribuível.
Se você tirar apenas uma coisa deste post: habilite o special pool antes de começar, não depois de ficar preso.
Quando você tem uma falha, a questão é se está diante de corrupção ou de um bug de tempo de vida. Elas exigem relatórios diferentes. Os metadados do pool respondem isso:``` kd> !pool
Um bloco que foi *alocado* com uma tag plausível e conteúdo de lixo aponta para **corrupção**. Um bloco que foi *liberado*, ou que está em uma página sem acesso do pool especial, aponta para um **bug de tempo de vida** — algo segurou um ponteiro além da morte do objeto. Essa é a distinção entre *"o atacante escreveu aqui"* e *"este objeto não deveria ter sido alcançável"*, e é a diferença entre um relatório de corrupção de heap e um relatório CWE-362.
Faça a verificação cruzada do tipo de objeto antes de se comprometer com qualquer um dos casos. Um `PWND` tem um formato reconhecível; se a memória onde a falha ocorreu ainda carrega os remanescentes de um, você está quase certamente diante de um problema de janela de tempo de vida, e não de uma sobrescrita aleatória.
### Depuração ao vivo do kernel na intercalação
Mesmo com o pool especial, uma corrida é um problema de agendamento, e **o depurador altera o agendamento.** Essa é a principal frustração do trabalho com corridas: o instrumento perturba aquilo que mede. Um breakpoint no caminho de teardown serializa exatamente as duas threads que você está tentando sobrepor, e o bug desaparece educadamente.
O caminho para contornar isso é parar de tentar capturar a corrida com um breakpoint e, em vez disso:
- **alargar a janela artificialmente** — qualquer coisa que prolongue o intervalo entre a liberação da referência e o teardown torna a colisão alcançável em taxas normais de agendamento,
- **aumentar as tentativas de colisão em vez da precisão** — execute os dois caminhos continuamente e deixe a probabilidade fazer o trabalho,
- **usar breakpoints condicionais e de disparo único** que só armam quando o estado interessante existe, em vez de parar em toda entrada,
- **confirmar a intercalação após a falha**, a partir do estado das threads e das pilhas, em vez de tentar observá-la ao vivo.
A falha em si, quando você a obtém, é sem glamour — uma desreferência de um `PWND` no caminho de despacho de mensagens em que o objeto já passou pelo teardown em outra thread, com `!pool` confirmando que o bloco foi liberado em vez de sobrescrito:```
kd> !analyze -v
EXCEPTION_CODE: (NTSTATUS) 0xc0000005 - Access violation
FAULTING_MODULE: win32kfull
(Detalhes de offsets, endereços e reprodução foram omitidos sob divulgação coordenada.)
O impacto não é comprovado por uma falha. É comprovado por quem consegue causar a falha. Cada execução do gatilho foi feita a partir de uma conta de usuário padrão, sem privilégios de administrador, no alvo, porque uma falha de kernel alcançável apenas a partir de um contexto já privilegiado é um bug de estabilidade, não de segurança. Verificar o nível de integridade do processo que provocou a colisão é um passo de trinta segundos que decide se você tem um caso de recompensa ou uma entrada no Windows Feedback Hub.
A disciplina essencial: eu não acreditei em uma única falha. Uma única falha em uma corrida é ruído. O que a tornou reportável foi a repetibilidade sob temporização controlada — poder dizer "estes dois caminhos, esta ordenação, esta janela" e obter a mesma falha de volta. É essa a diferença entre um relatório sobre o qual o MSRC pode agir e um que eles encerram como não reproduzível.
Usei triagem assistida por IA para avançar mais rápido pela saída descompilada, e vou dizer claramente para que serviu e para que não serviu.
Boa para: cobrir superfície de ataque. Ler um grande volume de HLIL e sinalizar "estas funções tocam um objeto compartilhado sem sincronização visível" é correspondência de padrões, e correspondência de padrões em volume é exatamente o que essas ferramentas fazem bem. Isso comprimiu semanas de leitura superficial em dias.
Inútil para: julgamento. Ela vai narrar com confiança uma cadeia de ataque que não existe, afirmar alcançabilidade que não estabeleceu e produzir um relatório lindamente estruturado para um bug que não está lá. Cada conclusão teve que sobreviver à verificação manual no WinDbg antes de chegar perto de um relatório.
O modo de falha a temer não é a ferramenta estar errada. É a ferramenta estar fluente enquanto errada, às 2 da manhã, quando você quer que ela esteja certa.
Uma descoberta fabricada enviada ao MSRC custa tempo real aos engenheiros deles e custa a você uma reputação que não se reconstrói rapidamente.
| Data | Evento |
|---|---|
| 7 de maio de 2026 | Submetido — VULN-186460 |
| 7 de maio de 2026 | Caso aberto — Caso MSRC 11xxxxx |
| 11 de junho de 2026 | Comportamento confirmado pela Microsoft; análise de recompensa aberta |
| 27 de junho de 2026 | Correção agendada para o lançamento de julho; CVE-2026-54107 atribuído (pré-lançamento) |
| 30 de junho de 2026 | Recompensa concedida — US$8,000, Programa de Recompensas do Windows Insider Preview |
| 14 de julho de 2026 | Patch lançado; CVE publicado |
Cinco semanas de silêncio entre a submissão e a confirmação. É essa a parte que te testa. Você escreveu uma alegação sobre o kernel de outra pessoa e ainda não tem ideia se o seu raciocínio se sustenta, se é uma duplicata, ou se sequer reproduz no build deles. O e-mail de confirmação é o momento em que deixa de ser uma teoria que você tem e se torna uma vulnerabilidade que existe.
Uma pequena coisa que me fez rir de mim mesmo: em 14 de julho, no meu fuso, eu enviei uma mensagem no caso perguntando por que o CVE não tinha sido publicado. A resposta, educadamente: é 13 de julho em Seattle. O calendário de lançamentos da Microsoft segue o horário do Pacífico. Agora eu sei.
| Campo | Detalhe |
|---|---|
| CVE | CVE-2026-54107 |
| Caso MSRC | 11xxxxx (VULN-186460) |
| Componente | win32kfull.sys — ciclo de vida de objetos de janela |
| Classe | Condição de corrida → use-after-free |
| CWE | CWE-362 |
| Impacto | Elevação de Privilégio |
| Severidade | Importante (MSRC) |
| CVSS v3.1 | 8.8 (Alta) |
| Vetor | Local, autenticado |
| Programa | Programa de Recompensas do Windows Insider Preview |
| Recompensa | US$8,000 |
| Corrigido em | Atualização de Segurança de julho de 2026 |
Leia procurando invariantes, não bugs. "Onde este código assume algo que ele não impõe?" encontra mais do que "onde está o estouro?" — especialmente em componentes maduros, fortemente auditados, onde as classes fáceis já não existem.
Uma corrida é uma alegação interprocedimental. Você não consegue estabelecer ou refutar uma a partir de uma única função. Se a sua análise parar no limite da função, você vai gerar candidatos que nunca conseguirá fechar.
Seu ambiente de depuração é o trabalho. Perdi mais horas com um link serial COM instável do que com a caçada em si, e um pipeline quebrado produz falsos negativos que parecem idênticos a "não há nada aqui." Quase abandonei este alvo por causa de uma configuração de porta COM.
Reporte a causa, não a falha. O MSRC recebe falhas. O que move um caso é uma história coerente sobre qual invariante foi quebrado e por que a aplicação não estava lá.
Confirmado não é concluído. Entre a confirmação e o patch lançado há acompanhamento de reprodutibilidade, comportamento Canary e revisão. Continue envolvido.
Mesma metodologia, superfícies diferentes — tcpip.sys, afd.sys, clfs.sys. Prefiro ser conhecido por um corpo de trabalho do que por um único achado de sorte, e o único jeito de isso acontecer é continuar eliminando candidatos mais rápido do que eu os gero.
Se você está onde eu estava há um ano — vindo de recompensas por bugs web, curioso sobre trabalho com kernel, sem certeza se é o tipo de pessoa que consegue fazer isso — você descobre fazendo. Escolha um driver. Anexe um depurador. Leia devagar. Continue perguntando o que acontece se for executado duas vezes.
É genuinamente aí que começa.
Escrito por Pravin Choudhary (@pr4v1nx) — pesquisador independente de segurança ofensiva. Divulgado à Microsoft sob divulgação coordenada. Detalhes de exploração, offsets e código de reprodução foram intencionalmente omitidos.