Clase 2 — Reversing del IOCTL 0x222003 y detección del Stack Overflow en HEVD¶
Resumen: Esta clase toma el primer IOCTL del HEVD (
0x222003— Stack Overflow), decodifica suIOCTL Codepara identificar el method (METHOD_NEITHER), reversea la función dispatch identificando los argumentosIRP/IO_STACK_LOCATION, ubica elType3InputBufferyInputBufferLength, sigue la cadena hasta una función "crash" que copia el buffer de user a un buffer kernel de0x800bytes sin validar el tamaño, y finalmente confirma todo en runtime con WinDbg/IDA, observando el BSoD cuandoRIPse pisa con0x4141414141414141.
← Volver al Módulo 5 · ← Clase 1¶
Tabla de Contenidos¶
- Recap — el modelo de callbacks del DRIVER_OBJECT
- Cómo elegir un IOCTL para reversear
- Identificar el case del IOCTL en el dispatcher
- Decodificar un
IOCTL Code— herramienta web y script Python - Los 4 methods y dónde está el buffer de user
- Tipear los argumentos de la función dispatch en IDA
- Elegir la rama correcta de la union
Parameters - Renombrar el
NTSTATUSy aplicar la enumeración - Identificar el buffer y el size en la función "crash"
- El buffer kernel local de
0x800bytes ProbeForReady eltry/exceptdel driver- Detectar el memcpy / memset hand-rolled
- Setup de debugging — attach con IDA + WinDbg backend
- Truco:
Manual Memory Regionsen IDA - Aplicar tipos en runtime —
structen IDA ydten WinDbg - Walk-through del crash en runtime
- Por qué el BSoD no es la explotación
- Roadmap de explotación — qué viene en el próximo módulo
- Cheat sheet de la clase
1. Recap — el modelo de callbacks del DRIVER_OBJECT¶
Antes de meterse en la nueva clase, conviene fijar el concepto base de la Clase 1 que se vuelve a tocar:
Cuando el driver se carga (
DriverEntry), el sistema le entrega su propioDRIVER_OBJECT. Esa estructura contiene la tablaMajorFunction[]de callbacks. El driver "pisa" (sobrescribe) las entradas que le interesan:IRP_MJ_CREATE,IRP_MJ_CLOSE,IRP_MJ_DEVICE_CONTROL, etc.
El flujo cuando el user llama CreateFile¶
User-mode Kernel-mode
───────── ───────────
CreateFile(\\.\HEVD, ...)
│
│ stub NTDLL → syscall
▼
nt!NtCreateFile (resuelto vía SSDT)
│
│ ¿el handle pertenece a un device de un driver custom?
│ ¿hay callback en MajorFunction[IRP_MJ_CREATE]?
▼
DriverObject->MajorFunction[0] ← != 0?
│ sí
▼
Hevd!IrpCreateCloseHandler (callback del driver)
Tip de debugging: Si se pone un hardware breakpoint on read sobre la entrada
MajorFunction[i]delDRIVER_OBJECTdespués de que el driver carga, se observa exactamente cuándo el kernel lo lee — confirma el flujo.
DeviceIoControl sigue el mismo patrón pero termina en MajorFunction[IRP_MJ_DEVICE_CONTROL] (índice 0xE). Ese es el dispatcher que se reversea.
2. Cómo elegir un IOCTL para reversear¶
En HEVD el dispatcher tiene un switch enorme con muchos case. Hay dos formas de elegir cuál atacar primero:
| Approach | Cuándo |
|---|---|
Mirar el nombre del símbolo / string asociado (HEVD tiene strings como "Buffer Overflow Stack" en cada handler) |
Cuando el binario tiene metadata útil — ayuda |
| Research a ciegas — agarrar un IOCTL al azar y reversear hasta entender qué hace | Caso real con drivers strippeados — más realista |
Para esta clase se elige el primer IOCTL que va al Stack Overflow. El handler imprime una string del tipo
"Buffer Overflow Stack"cerca delcase, lo que confirma de qué se trata. Pero el ejercicio se hace fingiendo no saberlo, para practicar el research.
Cómo el cliente user-mode dispara el IOCTL¶
// Setup mínimo del cliente
HANDLE h = CreateFileA(
"\\\\.\\HackSysExtremeVulnerableDriver",
GENERIC_READ | GENERIC_WRITE,
0, NULL, OPEN_EXISTING, 0, NULL);
#define HEVD_IOCTL_STACK_OVERFLOW 0x222003
CHAR payload[3000];
DWORD payloadSize = sizeof(payload);
DWORD bytesReturned;
memset(payload, 'A', sizeof(payload)); // 3000 'A's
DeviceIoControl(
h,
HEVD_IOCTL_STACK_OVERFLOW,
payload, payloadSize, // InputBuffer + InputBufferLength
NULL, 0, // OutputBuffer + OutputBufferLength (no se usa)
&bytesReturned,
NULL);
Observaciones clave sobre este código que después aparecerán en el reversing:
InputBuffer=payload(3000 bytes de'A').InputBufferLength=3000(=0xBB8).- No hay output buffer → el
OutputBufferLengthyOutputBufferson cero / NULL. - El IOCTL es
0x222003.
Lo que hay que encontrar en el driver: dónde se lee el buffer (
payload), dónde se lee el size (3000), y dónde el size se usa sin validación → ese es el bug.
3. Identificar el case del IOCTL en el dispatcher¶
En el dispatcher reverseado en la Clase 1, el IoControlCode ya está identificado (después del fix de la union — ver §7). El switch del compilador puede aparecer optimizado de varias formas:
// Pseudocódigo aproximado
IoControlCode = IrpStack->Parameters.DeviceIoControl.IoControlCode;
if (IoControlCode > UMBRAL) {
// rama "alta"
if (IoControlCode == 0x22200B) ...
else if (IoControlCode == 0x222013) ...
else if (IoControlCode - 0x222003 == 0) {
status = TriggerStackOverflow(...); // ← acá
}
} else {
// rama "baja"
...
}
Patrón típico: El compilador suele transformar
case 0x222003:enif (IoControlCode - 0x222003 == 0)(resta y comparación contra cero). Eso aparece en assembly como:
Una vez identificado el bloque, se sabe que 0x222003 es el IOCTL que llega a esta función vulnerable. Falta entender qué buffer y qué size se usan.
4. Decodificar un IOCTL Code — herramienta web y script Python¶
Un IOCTL Code es un DWORD que codifica 4 campos. Hay dos formas cómodas de decodificarlo:
Opción A — Web¶
https://www.osronline.com/article.cfm%5Earticle=229.htm (decodificador web). Pegar el valor (decimal o hexa) → muestra los 4 campos.
Opción B — Script Python¶
def decode_ioctl(ioctl):
device_type = (ioctl >> 16) & 0xFFFF
required_acc = (ioctl >> 14) & 0x3
function_code = (ioctl >> 2) & 0xFFF
method = ioctl & 0x3
method_names = {
0: "METHOD_BUFFERED",
1: "METHOD_IN_DIRECT",
2: "METHOD_OUT_DIRECT",
3: "METHOD_NEITHER",
}
access_names = {
0: "FILE_ANY_ACCESS",
1: "FILE_READ_ACCESS",
2: "FILE_WRITE_ACCESS",
3: "FILE_READ_ACCESS | FILE_WRITE_ACCESS",
}
print(f" IOCTL : 0x{ioctl:08X}")
print(f" DeviceType : 0x{device_type:04X}")
print(f" RequiredAccess : {access_names[required_acc]}")
print(f" FunctionCode : 0x{function_code:03X}")
print(f" Method : {method_names[method]}")
decode_ioctl(0x222003)
Resultado para 0x222003¶
IOCTL : 0x00222003
DeviceType : 0x0022 (FILE_DEVICE_UNKNOWN)
RequiredAccess : FILE_ANY_ACCESS
FunctionCode : 0x800
Method : METHOD_NEITHER ← el dato más importante
Conclusión: El IOCTL
0x222003usaMETHOD_NEITHER. Eso determina dónde está el buffer y el size dentro delIRP/IO_STACK_LOCATION.
5. Los 4 methods y dónde está el buffer de user¶
Esta tabla es central — sin saber el method, no se sabe dónde leer los datos del usuario:
| Method | InputBuffer (puntero al buffer del user) | InputBuffer Size | OutputBuffer | Notas |
|---|---|---|---|---|
METHOD_BUFFERED (0) |
Irp->AssociatedIrp.SystemBuffer |
IrpStack->Parameters.DeviceIoControl.InputBufferLength |
Irp->AssociatedIrp.SystemBuffer (mismo, reusado) |
Kernel copia el buffer de user a un buffer del sistema. Más seguro. |
METHOD_IN_DIRECT (1) |
Irp->AssociatedIrp.SystemBuffer |
InputBufferLength |
Irp->MdlAddress (MDL) |
Híbrido: input copiado, output via MDL. |
METHOD_OUT_DIRECT (2) |
Irp->AssociatedIrp.SystemBuffer |
InputBufferLength |
Irp->MdlAddress (MDL) |
Igual que IN_DIRECT pero con dirección invertida. |
METHOD_NEITHER (3) |
IrpStack->Parameters.DeviceIoControl.Type3InputBuffer ← puntero raw user-mode |
IrpStack->Parameters.DeviceIoControl.InputBufferLength |
Irp->UserBuffer ← puntero raw user-mode |
El kernel no copia nada. El driver recibe punteros user crudos. Más inseguro. |
Por qué METHOD_NEITHER es más peligroso¶
Cuando un driver recibe Type3InputBuffer, está recibiendo el puntero literal que pasó el user. Si no llama a ProbeForRead/ProbeForWrite antes de tocarlo:
- Un puntero a memoria kernel desde user → potencial arbitrary kernel read/write.
- Una dirección no mapeada →
*((char*)ptr)causa page fault en kernel → BSoD si no está entry/except. - Race condition donde el user cambia el contenido mientras el kernel lo procesa → double fetch / TOCTOU.
Por eso HEVD para esta vulnerabilidad usa
METHOD_NEITHER: deja el puntero del user accesible directo, sin intermediarios.
Referencia oficial¶
6. Tipear los argumentos de la función dispatch en IDA¶
La función dispatch recibe (PDEVICE_OBJECT DeviceObject, PIRP Irp). Como se vio en la Clase 1, hay que aplicar el prototipo:
Identificar los registros en assembly (ABI x64 Microsoft)¶
| Argumento | Registro |
|---|---|
1° (DeviceObject) |
RCX |
2° (Irp) |
RDX |
| 3° | R8 |
| 4° | R9 |
En la práctica, el dispatcher de HEVD inmediatamente copia los argumentos a registros preservados:
mov rbp, rdx ; RBP ← Irp
mov r14, rcx ; R14 ← DeviceObject (en HEVD a veces R14 termina con CurrentStackLocation)
...
Caveat observado en la clase: En HEVD,
R14a veces aparece sosteniendo elIO_STACK_LOCATION(no elDeviceObject), porque se calculó temprano conIrp->Tail.Overlay.CurrentStackLocation. Hay que trazar qué registro contiene qué mirando los offsets que se aplican después:
mov r14, [rbp+0B8h] ; R14 = Irp->Tail.Overlay.CurrentStackLocation
; → R14 ahora apunta a IO_STACK_LOCATION
Truco IDA — convertir un offset crudo a campo legible¶
Cuando aparece [rbp+0B8h] o [rdx+0B8h], en lugar de quedar con el número:
- Click derecho sobre el operando con el offset (ej.
0B8h). - Structure offset / field name (atajo
T). - Seleccionar
_IRP.Tail.Overlay.CurrentStackLocation.
Ahora aparece como [rbp + _IRP.Tail.Overlay.CurrentStackLocation] — mucho más legible.
Aplicar la struct a la variable local¶
En la pseudocódigo:
- Click derecho sobre la variable que contiene el
Irp→ Convert to struct... →_IRP. - Idem para el que contiene el
IO_STACK_LOCATION→_IO_STACK_LOCATION. - Idem para
DeviceObject→_DEVICE_OBJECT.
A partir de ahí, los accesos Irp->IoStatus.Status, IrpStack->Parameters.DeviceIoControl.IoControlCode, etc., aparecen con nombres semánticos.
7. Elegir la rama correcta de la union Parameters¶
Bug recurrente del decompiler: Cuando una variable es un
IO_STACK_LOCATION, el campoParameterses unaunionenorme. IDA elige una rama arbitraria — generalmenteReadoCreate— que no es la correcta cuando se reversea un dispatch deIRP_MJ_DEVICE_CONTROL.
El error típico observado¶
// ❌ Lo que IDA pone por default — INCORRECTO
v3 = IrpStack->Parameters.CreatePipe.Parameters;
buffer = IrpStack->Parameters.Read.UserBuffer; // o algo así
El fix — Alt+Y → seleccionar la union correcta¶
- Click derecho sobre el campo malinterpretado → Select union field... (
Alt+Y). - En el árbol, expandir
Parameters→ seleccionarDeviceIoControl. - Bajo
DeviceIoControlaparecen los campos correctos: OutputBufferLengthInputBufferLengthIoControlCodeType3InputBuffer
Después del fix:
// ✅ Correcto
IoControlCode = IrpStack->Parameters.DeviceIoControl.IoControlCode;
inputBuffer = IrpStack->Parameters.DeviceIoControl.Type3InputBuffer;
inputLen = IrpStack->Parameters.DeviceIoControl.InputBufferLength;
Por qué confunde tanto al decompiler¶
Hex-Rays no tiene contexto de qué MajorFunction está procesando esta función. Solo ve "una variable de tipo IO_STACK_LOCATION" y elige una union al azar. Es responsabilidad del que reversea decirle cuál corresponde, basándose en saber que esta función es el handler de IRP_MJ_DEVICE_CONTROL.
Regla: Por cada función dispatch del driver, identificar primero qué
IRP_MJ_*maneja (mirandoMajorFunction[i]enDriverEntry), y después aplicar la union correcta:
Handler de... Union correcta IRP_MJ_CREATEParameters.CreateIRP_MJ_READParameters.ReadIRP_MJ_WRITEParameters.WriteIRP_MJ_DEVICE_CONTROLParameters.DeviceIoControlIRP_MJ_FILE_SYSTEM_CONTROLParameters.FileSystemControl
8. Renombrar el NTSTATUS y aplicar la enumeración¶
El dispatcher devuelve NTSTATUS. Por default IDA lo muestra como unsigned int con valores tipo 0xC0000001. Para que aparezca por nombre:
Pasos¶
- Sobre la variable de retorno (ej.
result,v5) → click derecho → Convert to type →NTSTATUS. - Sobre los valores literales
0xC0000001→ click derecho → Use standard symbolic constant (M) → seleccionarNTSTATUS(o_NT_STATUS). - Si el enum no aparece, importarlo:
- View → Open subviews → Enums →
Insert→ Add standard enum → buscarNTSTATUS.
Resultado en el decompiler¶
Códigos comunes que aparecen en HEVD:
| Hex | Símbolo |
|---|---|
0x00000000 |
STATUS_SUCCESS |
0xC0000001 |
STATUS_UNSUCCESSFUL |
0xC0000005 |
STATUS_ACCESS_VIOLATION |
0xC000000D |
STATUS_INVALID_PARAMETER |
0xC0000058 |
STATUS_NOT_SUPPORTED |
Lista completa: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/596a1078-e883-4972-9bbc-49e60bebca55
9. Identificar el buffer y el size en la función "crash"¶
Una vez aplicada la union correcta, se ve cómo el dispatcher pasa el Type3InputBuffer a una sub-función. Esa sub-función es la que termina en el overflow.
Pseudocódigo después del reversing inicial¶
// (sub-función a la que se llega tras el case 0x222003)
NTSTATUS TriggerStackOverflow(PIO_STACK_LOCATION IrpStack) {
PVOID userBuf = IrpStack->Parameters.DeviceIoControl.Type3InputBuffer;
SIZE_T userLen = IrpStack->Parameters.DeviceIoControl.InputBufferLength;
NTSTATUS status = STATUS_SUCCESSFUL; // 0
if (userBuf) {
status = FunctionCrash(userBuf, userLen); // ← acá viene el problema
} else {
status = STATUS_UNSUCCESSFUL;
}
return status;
}
Renombres aplicados durante el live reversing:
- Variable
a1(PVOID) →Type3InputBuffer(ouserBuffer).- Variable
a2(size) →bufferSize.- La función llamada →
FunctionCrash(nombre tentativo hasta confirmar lo que hace).
Argumentos identificados¶
| Posición | Tipo | Significado |
|---|---|---|
| 1° | PVOID |
Type3InputBuffer — puntero a las 3000 'A' del user |
| 2° | SIZE_T |
InputBufferLength — 0xBB8 (3000 decimal) |
0xBB8= 3000. Conviene memorizar este número porque aparece todo el tiempo en el debugging de esta clase.
10. El buffer kernel local de 0x800 bytes¶
Adentro de FunctionCrash, el primer dato que aparece es un buffer local en el stack de tamaño fijo 0x800 (2048 bytes):
sub rsp, 0x828 ; reserva en el stack — 0x800 + cookie + alineación
...
lea rcx, [rsp+0x20] ; RCX = puntero al buffer local
mov rdx, <type3buf> ; RDX = source (user buffer)
mov r8, <bufferSize> ; R8 = tamaño que viene del user
call InternalCopy ; ← copia sin validar
Pseudocódigo del bug¶
NTSTATUS FunctionCrash(PVOID source, SIZE_T sizeFromUser) {
char kernelBuffer[0x800]; // ← buffer kernel local de 2048 bytes
// ... posibles checks o llamadas a ProbeForRead ...
InternalCopy(kernelBuffer, source, sizeFromUser);
// └──────┬──────┘ └──┬──┘ └──────┬──────┘
// destino fixed source size CONTROLADO POR USER
// 0x800 bytes user buf ← BUG: no se trunca a 0x800
return STATUS_SUCCESS;
}
Por qué esto es overflow¶
| Lado | Tamaño |
|---|---|
| Buffer destino (kernel stack) | 0x800 = 2048 bytes (fijo) |
| Bytes copiados | sizeFromUser — controlado 100% por el atacante |
El atacante manda sizeFromUser = 3000. Se copian 3000 bytes en un buffer de 2048 → se desbordan 952 bytes que pisan:
- Las variables locales que estén después de
kernelBuffer. - La stack cookie (si el driver fue compilado con
/GS— HEVD no tiene cookie en este path). - La return address del stack frame de
FunctionCrash. - Posiblemente más frames hacia arriba.
Sin stack cookie + sin validación de tamaño = stack overflow clásico. Cuando
FunctionCrashhagaret, va a saltar a0x4141414141414141→ BSoD por non-canonical address.
Renombrado del buffer en IDA¶
Sobre la variable local que IDA muestra como var_818 o similar:
- Click →
N(rename) →kernelBuffer_0x800(o simplementebuffer_0x800). - Sobre el tipo → tipearlo como
char[0x800]oBYTE[0x800].
Tipear InternalCopy¶
La función llamada con los 3 argumentos típicos (dest, src, size) puede ser:
- Un
memcpyinline que IDA no reconoció. - Un memcpy hand-rolled (loop
movbyte por byte) optimizado por el compilador.
Tipear:
11. ProbeForRead y el try/except del driver¶
El driver sí intenta ser cuidadoso con el puntero user — pero el bug no está en la validación del puntero, sino en el tamaño.
Pseudocódigo del wrapper¶
NTSTATUS FunctionCrash(PVOID source, SIZE_T sizeFromUser) {
char kernelBuffer[0x800];
NTSTATUS status = STATUS_SUCCESS;
__try {
ProbeForRead(source, sizeFromUser, sizeof(UCHAR));
// ↑ verifica que [source, source+sizeFromUser) sea memoria user-mode válida.
// Si no, lanza una excepción que cae en __except.
InternalCopy(kernelBuffer, source, sizeFromUser);
// ↑ acá está el bug — copia sin truncar
}
__except (EXCEPTION_EXECUTE_HANDLER) {
status = GetExceptionCode();
}
return status;
}
Qué hace ProbeForRead¶
ProbeForRead recibe (Address, Length, Alignment) y verifica:
- Que
[Address, Address + Length)esté dentro del rango user-mode (no kernel). - Que esté correctamente alineado.
Si la verificación falla, lanza una STATUS_ACCESS_VIOLATION. No verifica que la memoria esté efectivamente mapeada — solo que sea user-space y aligned.
Por qué no protege contra el bug:
ProbeForReadvalida el puntero, no el tamaño respecto al destino. El driver chequea que el user pueda leer 3000 bytes de su propio buffer (sí, los 3000'A'están mapeados en el user). Pero no chequea que 3000 bytes quepan enkernelBuffer[0x800]. El bug es lógico, no de validación de puntero.
Identificar el try/except en IDA¶
El compilador genera el SEH como una tabla en la sección .pdata y stubs en el código:
; bloque protegido
mov rcx, source
mov rdx, sizeFromUser
mov r8d, 1
call ProbeForRead
...
call InternalCopy
jmp short loc_endTry
; manejador
loc_except:
call GetExceptionCode
mov [rsp+status], eax
jmp short loc_endTry
loc_endTry:
mov eax, [rsp+status]
add rsp, 0x828
ret
Pintar el handler en otro color en IDA (
Edit → Color → ...) ayuda a no confundirlo con código ejecutado en happy path.
12. Detectar el memcpy / memset hand-rolled¶
Cuando IDA muestra una función con un loop tipo:
…es un memcpy hecho a mano (o el memcpy del CRT del kernel inlined).
Versiones más optimizadas copian de a 8 bytes con rep movsq:
mov rdi, dest
mov rsi, source
mov rcx, size
shr rcx, 3
rep movsq
mov ecx, esize_low
and ecx, 7
rep movsb
ret
Atajo: Pegar el bloque assembly a un LLM (ChatGPT, Claude) y preguntar "what does this do?" — devuelve "este es un memcpy / memset" en segundos. Útil cuando uno está seguro que es un copy genérico y no quiere perder tiempo.
Cuidado: Confiar pero verificar. A veces lo que parece un memcpy tiene una transformación adicional (XOR, byte swap, encoding) que cambia totalmente la semántica.
13. Setup de debugging — attach con IDA + WinDbg backend¶
Pre-requisitos¶
- La VM target ya tiene HEVD cargado (Clase 1, §14).
- La VM target ya tiene
bcdedit /debug yescon la net key configurada. - La VM target está corriendo y el cliente user-mode está esperando un
Enterpara enviar elDeviceIoControl.
Configurar IDA para kernel debug¶
Bug recurrente: Cada vez que se abre el dialog de debugger options, IDA resetea a User mode. Hay que poner explícitamente Kernel mode cada vez.
Pasos:
- Con el
.sysde HEVD abierto en IDA en el host: - Debugger → Select debugger → Windbg debugger.
- Debugger → Process options (o Debugger options):
- Connection string: vacío o
com:port=...,baud=...,...(depende de transporte). Para net: dejar vacío y usar el campo "Hostname" / "Key" en otro tab. Para máquinas modernas Microsoft suele preferir net. - Kernel mode: ✓ marcado.
- Use hardware temporary breakpoint: ✓ recomendado.
- Debugger → Attach to process (o
F9con un breakpoint armado).
Nota: Es lo mismo debuggear con WinDbg standalone. La ventaja de IDA es ver el assembly + decompiler + estructuras + breakpoints en la misma UI. La desventaja es la inestabilidad ocasional. Si IDA muere, la VM target queda exactamente donde estaba — se reabre IDA y reattach.
Marcar "Same" si pregunta por dos módulos con el mismo basename¶
Cuando IDA conecta y empieza a cargar módulos del kernel, puede aparecer:
"Debugger found two modules with the same base name but different paths. Use existing database?"
Siempre responder Same.
¿Por qué? El driver que está cargado en la VM target tiene una ImageBase distinta a la de la base de datos del IDA local (que se calculó al hacer el análisis estático). IDA con Same rebasea automáticamente la base de datos para que las direcciones coincidan con las de la VM target. Esto es lo que permite que los breakpoints funcionen y que IDA reconozca el código del driver corriendo.
Importante: Si se cambió el nombre del
.sysentre el análisis y la copia a la VM, esto no funciona — IDA no reconoce el módulo. Hay que mantener el mismo filename (HEVD.sys).
Poner el breakpoint inicial¶
Al inicio de FunctionCrash (o donde se quiera observar), poner un bp:
- En IDA: navegar a la dirección, presionar
F2. - En WinDbg standalone:
bp HEVD!FunctionCrash(obp <address>).
Después: continuar (F9 / g) y desde la VM target apretar Enter en el cliente user-mode → el DeviceIoControl dispara y el debugger breakea.
14. Truco: Manual Memory Regions en IDA¶
Problema observado: En kernel debugging con IDA, hacer doble click sobre una dirección (ej. en el panel de registros) no navega a esa dirección como sí pasa en user mode debugging. IDA dice "address not in any segment" o algo similar.
Por qué pasa¶
En kernel debugging, IDA solo conoce los segmentos del módulo del driver que está reverseando. La memoria del resto del kernel (otros drivers, HAL, ntoskrnl, pool, stacks) no está mappeada como segmentos en el IDB. Cuando uno hace doble click sobre 0xFFFFB28A12345678, IDA no encuentra esa dirección en ningún segmento → no hace nada.
El fix — crear un segmento manual gigante que cubra todo¶
- Debugger → Manual Memory Regions (o
View → Open subviews → Manual Memory Regions). - Insert (o tecla
Ins). - Configurar:
- Start address:
0 - End address:
0xFFFFFFFFFFFFFFFE(15 efes y una E al final) - 64-bit: ✓ marcado
- OK → aparece la región nueva.
- Doble click sobre la región creada — esto la "expande" y la habilita.
Resultado¶
A partir de ahora, doble click sobre cualquier dirección kernel navega correctamente y muestra los bytes en la vista de hex. Y se puede aplicar estructuras sobre direcciones kernel:
- Hover sobre la dirección → verla en el panel de hex.
- Click derecho → Struct var (
Alt+QoT) → seleccionar_DRIVER_OBJECT,_IRP,_IO_STACK_LOCATION, etc. - La memoria se interpreta con todos los nombres de campos.
Atribución: Truco difundido por Ricardo Narvaja en sus cursos de exploitation. No es nada documentado oficialmente.
15. Aplicar tipos en runtime — struct en IDA y dt en WinDbg¶
Una vez en el breakpoint y con la región manual creada:
En IDA¶
| Acción | Cómo |
|---|---|
Ver Irp con sus campos |
Doble click sobre RDX → en hex view, click derecho → Struct var → _IRP |
Ver IoStackLocation con sus campos |
Doble click sobre R14 → click derecho → Struct var → _IO_STACK_LOCATION |
Ver DriverObject |
Doble click sobre el valor de RCX (en DriverEntry) → Struct var → _DRIVER_OBJECT |
En WinDbg¶
0: kd> dt nt!_IRP @rdx
+0x000 Type : 6
+0x002 Size : 0x4d8
+0x008 MdlAddress : (null)
+0x010 Flags : 0x70
...
+0x0b8 Tail : <unnamed-tag>
+0x040 Overlay : <unnamed-tag>
+0x000 CurrentStackLocation : 0xffff... _IO_STACK_LOCATION
0: kd> dt nt!_IO_STACK_LOCATION @r14
+0x000 MajorFunction : 0xe ''
+0x001 MinorFunction : 0 ''
...
+0x008 Parameters : <unnamed-tag>
Read : ... ← IDA elegía esta por error
Write : ...
DeviceIoControl : ← la correcta para este caso
+0x000 OutputBufferLength : 0
+0x008 InputBufferLength : 0xbb8 ← 3000
+0x010 IoControlCode : 0x222003 ← nuestro IOCTL
+0x018 Type3InputBuffer : 0x000001a... Void ← buffer user con 'A's
Sintaxis útil de
dt:
Comando Descripción dt nt!_IRPSolo muestra el layout, sin datos dt nt!_IRP <addr>Muestra el layout aplicado a esa dirección dt -r2 nt!_IRP <addr>Recursivo hasta 2 niveles (despliega sub-structs) dt -ny nt!*Stack*Lista todos los tipos cuyo nombre contiene "Stack" x nt!*IRP*Busca símbolos (no solo tipos) que matcheen
Buscar tipos / símbolos cuando no se sabe el nombre exacto¶
x con wildcards permite navegar el namespace nt! cuando uno no recuerda el nombre exacto.
16. Walk-through del crash en runtime¶
Esta es la secuencia paso a paso de lo que pasa cuando se manda el IOCTL con el debugger conectado:
Estado inicial — breakpoint hit en el dispatcher¶
Paso 1 — leer IO_STACK_LOCATION¶
mov r14, [rdx + _IRP.Tail.Overlay.CurrentStackLocation]
; R14 ahora apunta al IO_STACK_LOCATION actual
Paso 2 — leer IoControlCode¶
0: kd> dt nt!_IO_STACK_LOCATION.Parameters.DeviceIoControl @r14
+0x010 IoControlCode : 0x222003 ← match con lo que mandamos del user
Paso 3 — switch encuentra el case 0x222003¶
El cmp / sub lleva al bloque del Stack Overflow:
Paso 4 — print de debug¶
HEVD imprime con DbgPrintEx algo tipo "[+] Stack Overflow Trigger" (visible con DbgView o en el output de WinDbg). Este es un freebie del autor que, en un driver real, no estaría.
Paso 5 — leer Type3InputBuffer y InputBufferLength¶
0: kd> dt nt!_IO_STACK_LOCATION.Parameters.DeviceIoControl @r14
+0x008 InputBufferLength : 0xbb8 ← 3000
+0x018 Type3InputBuffer : 0x...A... ← buffer user con AAAA...
Paso 6 — ProbeForRead — pasa OK¶
El buffer está mappeado en user-space → no excepción.
Paso 7 — entrar a FunctionCrash con argumentos¶
RCX = kernelBuffer (puntero stack-local 0x800)
RDX = userBuffer (Type3InputBuffer con AAAA...)
R8 = 0xBB8 (3000 — controlado por el atacante)
Paso 8 — el copy¶
InternalCopy empieza a copiar 3000 bytes al buffer de 2048 → desborda 952 bytes:
stack frame de FunctionCrash:
┌──────────────────────────┐ ← rsp+0x000 (top)
│ kernelBuffer[0x800] │
│ (2048 bytes) │
├──────────────────────────┤ ← rsp+0x800
│ otros locales (saved │
│ regs, padding) │
├──────────────────────────┤
│ saved RBP │
├──────────────────────────┤
│ return address ← se pisa con AAAAAAAA (0x4141414141414141)
└──────────────────────────┘
Paso 9 — ret salta a 0x4141414141414141¶
0: kd> g
*** Fatal System Error: 0x0000003B
(0x00000000C0000005, 0x4141414141414141, ...)
A fatal system error has occurred.
0x3B = SYSTEM_SERVICE_EXCEPTION. El 0x4141414141414141 aparece como dirección de la falla → confirma que el atacante controla RIP.
17. Por qué el BSoD no es la explotación¶
Distinción clave del módulo: crashear ≠ explotar.
- Crash (BSoD): Confirma que hay un bug y que se controla algún valor crítico (como
RIP). Termina en pantalla azul.- Exploit: Convierte ese control en escalada de privilegios (SYSTEM) o ejecución persistente sin que la máquina muera.
Por qué saltar a un buffer del user no funciona¶
La idea naive sería: "si controlo RIP, lo apunto a un shellcode que tengo en userBuffer".
Eso no funciona en kernel exploitation moderno por dos razones:
Razón 1 — userBuffer no tiene permiso de ejecución desde kernel (SMEP)¶
SMEP (Supervisor Mode Execution Prevention) es una feature del CPU que prohíbe que código de kernel ejecute páginas de user-mode. Está activado por default en todas las CPUs Intel/AMD modernas y todos los Windows desde Win 8.
Si el RIP salta a una dirección user (los 'A' están en user space), el CPU genera page fault inmediato → BSoD.
Razón 2 — el shellcode estaría escrito en assembly user-mode¶
Aunque se pudiera saltar (sin SMEP), las funciones que el shellcode llamaría (MessageBox, system, etc.) son user-mode. Desde kernel no se las puede invocar directamente — hay que llamar a APIs kernel (ZwXxx, Ke*, Ex*).
La solución — ROP a gadgets en kernel + token stealing¶
La técnica estándar:
- Construir una cadena ROP con gadgets que ya están en el kernel (en
ntoskrnl.exeo en otros drivers cargados, todos en kernel space → SMEP-safe). - Esa cadena ejecuta un token stealing: copia el token de SYSTEM al token del proceso atacante.
- Cuando el exploit termina y el proceso vuelve a user-mode, es SYSTEM → se lanza un
cmd.exeque hereda esos privilegios.
Token stealing — concepto rápido¶
Cada proceso tiene un _EPROCESS->Token. El proceso System (PID 4) tiene el token con todos los privilegios. La idea:
// pseudocódigo del shellcode kernel-mode
PEPROCESS current = IoGetCurrentProcess();
PEPROCESS systemProc = PsInitialSystemProcess; // o se walkea la lista hasta encontrar PID 4
current->Token = systemProc->Token; // copiar el token
// Ahora cuando el thread vuelva a user, su proceso tiene token de SYSTEM
Hay que hacerlo con offsets correctos para la versión de Windows target. Hay generadores automáticos (HEVD trae uno).
Cómo dispararlo desde el exploit¶
RIP controlled → ROP chain in kernel:
1. Disable SMEP (modify CR4 — opcional, depende del exploit)
2. Set up registers
3. Call into a kernel-mode shellcode (en pool kernel previamente alocado)
4. Shellcode hace token stealing
5. Restaurar el stack, retornar al caller original (sin BSoD)
6. En user-mode, el proceso ahora es SYSTEM → spawn cmd.exe
Esto se ve en el Módulo 6. En este módulo solo se llega hasta el BSoD para identificar la vuln. La explotación se separa en otro módulo entero.
18. Roadmap de explotación — qué viene en el próximo módulo¶
| Etapa | Tema |
|---|---|
| 1 | Stack Overflow exploitation — encontrar un gadget para pop rcx; ret o similar, construir ROP, disable SMEP o ejecutar full-kernel ROP, token stealing |
| 2 | Heap Overflow exploitation — feng shui en el pool kernel (LFH, segment heap), corruption de objects vecinos |
| 3 | Use-After-Free — race conditions, reclamación con sprays |
| 4 | Type Confusion / OOB Read/Write — corrupciones más sutiles |
| 5 | Arbitrary Read/Write primitives — convertir un bug primitivo en read/write arbitrario en kernel space |
Importante para el TP del módulo: Para entregar el reversing de un IOCTL, basta con un IDB que muestre que se entendió el flujo:
- Funciones renombradas (
Dispatch,FunctionCrash, etc.).- Estructuras tipeadas (
_IRP,_IO_STACK_LOCATION).- Union correcta seleccionada (
DeviceIoControl).- Buffer y size identificados.
- Comentarios marcando dónde está el bug.
No hace falta tener todo el driver reverseado al 100% — solo el camino del IOCTL elegido. Con el IDB se ve que se trabajó.
19. Cheat sheet de la clase¶
Workflow completo para reversear un IOCTL¶
1. Mirar el case en el dispatcher → IOCTL = 0x222003
2. Decodificar IOCTL (web o Python) → METHOD_NEITHER
3. Aplicar union correcta en IDA (Alt+Y) → DeviceIoControl
4. Identificar Type3InputBuffer + Length → buffer y size del user
5. Seguir hasta la función vulnerable → FunctionCrash
6. Encontrar el buffer kernel destino → 0x800 bytes en el stack
7. Confirmar que el size NO se valida → bug
8. Adjuntar debugger, breakpoint en func → confirmar runtime
9. Mandar IOCTL desde user-mode → BSoD con RIP=0x4141...
Comandos WinDbg de la clase¶
dt nt!_IRP @rdx ; ver IRP completo
dt nt!_IO_STACK_LOCATION @r14 ; ver IO_STACK_LOCATION
dt nt!_IO_STACK_LOCATION.Parameters.DeviceIoControl @r14
; ver solo la rama correcta
dt -r2 nt!_IRP @rdx ; recursivo 2 niveles
x nt!*Irp* ; buscar símbolos
db <addr> ; ver bytes en una dirección
Atajos IDA usados¶
| Atajo | Acción |
|---|---|
Y |
Set type (cambiar prototipo de función / tipo de variable) |
Alt+Y |
Select union field |
T |
Structure offset / field name |
N |
Rename |
M |
Convert to standard symbolic constant (enum) |
F5 |
Decompile / refresh decompilation |
F2 |
Toggle breakpoint |
F9 |
Run / continue (debugging) |
Recursos enlazados¶
| Recurso | URL |
|---|---|
| Decoder web de IOCTLs | https://www.osronline.com/article.cfm%5Earticle=229.htm |
| Buffer descriptions for IOCTLs | https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/buffer-descriptions-for-i-o-control-codes |
ProbeForRead docs |
https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-probeforread |
| NTSTATUS values | https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/596a1078-e883-4972-9bbc-49e60bebca55 |
| HEVD repo | https://github.com/hacksysteam/HackSysExtremeVulnerableDriver |
| SMEP overview | https://en.wikipedia.org/wiki/Control_register#SMEP |