Page cover

HEVD - HackSys

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.


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

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:


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.

O próximo passo é localizar os IOCTLs implementados no driver, que podem ser encontrados na função sub_140085078.

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:

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.

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:

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:

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:

Aqui podemos ver que, quando o IOCTL correto é identificado, o driver:

  1. Recebe o IRP (no CurrentStackLocation).

  2. 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:

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:

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:

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:

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:

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:

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.


Enviando o IOCTL

O IOCTL que ativa a primitiva de escrita arbitrária no HEVD é o 0x22200B, então:

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:

Com isso, adicionamos à classe MemoryTester um método responsável por disparar o IOCTL vulnerável:

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:

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:

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:


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:

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:

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:

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.

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:

O método GetKernelExportOffset é bem direto:

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:

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.

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:

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:

O endereço que precisamos é justamente o PDE: FFFFC9E4FE010488. Copiamos esse valor e adicionamos ao nosso código:

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:

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:

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:

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.

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:

Em seguida, preparamos uma mensagem que queremos que seja impressa pelo DbgPrint no kernel:

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:


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!

Last updated