Saltar a contenido

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 su IOCTL Code para identificar el method (METHOD_NEITHER), reversea la función dispatch identificando los argumentos IRP / IO_STACK_LOCATION, ubica el Type3InputBuffer y InputBufferLength, sigue la cadena hasta una función "crash" que copia el buffer de user a un buffer kernel de 0x800 bytes sin validar el tamaño, y finalmente confirma todo en runtime con WinDbg/IDA, observando el BSoD cuando RIP se pisa con 0x4141414141414141.

← Volver al Módulo 5 · ← Clase 1


Tabla de Contenidos

  1. Recap — el modelo de callbacks del DRIVER_OBJECT
  2. Cómo elegir un IOCTL para reversear
  3. Identificar el case del IOCTL en el dispatcher
  4. Decodificar un IOCTL Code — herramienta web y script Python
  5. Los 4 methods y dónde está el buffer de user
  6. Tipear los argumentos de la función dispatch en IDA
  7. Elegir la rama correcta de la union Parameters
  8. Renombrar el NTSTATUS y aplicar la enumeración
  9. Identificar el buffer y el size en la función "crash"
  10. El buffer kernel local de 0x800 bytes
  11. ProbeForRead y el try/except del driver
  12. Detectar el memcpy / memset hand-rolled
  13. Setup de debugging — attach con IDA + WinDbg backend
  14. Truco: Manual Memory Regions en IDA
  15. Aplicar tipos en runtime — struct en IDA y dt en WinDbg
  16. Walk-through del crash en runtime
  17. Por qué el BSoD no es la explotación
  18. Roadmap de explotación — qué viene en el próximo módulo
  19. 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 propio DRIVER_OBJECT. Esa estructura contiene la tabla MajorFunction[] 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] del DRIVER_OBJECT despué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 del case, 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 OutputBufferLength y OutputBuffer son 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: en if (IoControlCode - 0x222003 == 0) (resta y comparación contra cero). Eso aparece en assembly como:

sub     ecx, 222003h
jz      short loc_TriggerStackOverflow

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 0x222003 usa METHOD_NEITHER. Eso determina dónde está el buffer y el size dentro del IRP / 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á en try/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

https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/buffer-descriptions-for-i-o-control-codes


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:

NTSTATUS Dispatch(PDEVICE_OBJECT DeviceObject, PIRP Irp);

Identificar los registros en assembly (ABI x64 Microsoft)

Argumento Registro
1° (DeviceObject) RCX
2° (Irp) RDX
R8
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, R14 a veces aparece sosteniendo el IO_STACK_LOCATION (no el DeviceObject), porque se calculó temprano con Irp->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:

  1. Click derecho sobre el operando con el offset (ej. 0B8h).
  2. Structure offset / field name (atajo T).
  3. 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:

  1. Click derecho sobre la variable que contiene el IrpConvert to struct..._IRP.
  2. Idem para el que contiene el IO_STACK_LOCATION_IO_STACK_LOCATION.
  3. 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 campo Parameters es una union enorme. IDA elige una rama arbitraria — generalmente Read o Createque no es la correcta cuando se reversea un dispatch de IRP_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

  1. Click derecho sobre el campo malinterpretado → Select union field... (Alt+Y).
  2. En el árbol, expandir Parameters → seleccionar DeviceIoControl.
  3. Bajo DeviceIoControl aparecen los campos correctos:
  4. OutputBufferLength
  5. InputBufferLength
  6. IoControlCode
  7. Type3InputBuffer

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 (mirando MajorFunction[i] en DriverEntry), y después aplicar la union correcta:

Handler de... Union correcta
IRP_MJ_CREATE Parameters.Create
IRP_MJ_READ Parameters.Read
IRP_MJ_WRITE Parameters.Write
IRP_MJ_DEVICE_CONTROL Parameters.DeviceIoControl
IRP_MJ_FILE_SYSTEM_CONTROL Parameters.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

  1. Sobre la variable de retorno (ej. result, v5) → click derecho → Convert to typeNTSTATUS.
  2. Sobre los valores literales 0xC0000001 → click derecho → Use standard symbolic constant (M) → seleccionar NTSTATUS (o _NT_STATUS).
  3. Si el enum no aparece, importarlo:
  4. View → Open subviews → EnumsInsertAdd standard enum → buscar NTSTATUS.

Resultado en el decompiler

// ❌ Antes
return 0xC0000001;

// ✅ Después
return STATUS_UNSUCCESSFUL;

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 (o userBuffer).
  • Variable a2 (size) → bufferSize.
  • La función llamada → FunctionCrash (nombre tentativo hasta confirmar lo que hace).

Argumentos identificados

Posición Tipo Significado
PVOID Type3InputBuffer — puntero a las 3000 'A' del user
SIZE_T InputBufferLength0xBB8 (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 sizeFromUsercontrolado 100% por el atacante

El atacante manda sizeFromUser = 3000. Se copian 3000 bytes en un buffer de 2048 → se desbordan 952 bytes que pisan:

  1. Las variables locales que estén después de kernelBuffer.
  2. La stack cookie (si el driver fue compilado con /GS — HEVD no tiene cookie en este path).
  3. La return address del stack frame de FunctionCrash.
  4. Posiblemente más frames hacia arriba.

Sin stack cookie + sin validación de tamaño = stack overflow clásico. Cuando FunctionCrash haga ret, va a saltar a 0x4141414141414141 → BSoD por non-canonical address.

Renombrado del buffer en IDA

Sobre la variable local que IDA muestra como var_818 o similar:

  1. Click → N (rename) → kernelBuffer_0x800 (o simplemente buffer_0x800).
  2. Sobre el tipo → tipearlo como char[0x800] o BYTE[0x800].

Tipear InternalCopy

La función llamada con los 3 argumentos típicos (dest, src, size) puede ser:

  • Un memcpy inline que IDA no reconoció.
  • Un memcpy hand-rolled (loop mov byte por byte) optimizado por el compilador.

Tipear:

PVOID InternalCopy(PVOID Dest, PVOID Source, SIZE_T Length);

11. ProbeForRead y el try/except del driver

El driver 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: ProbeForRead valida 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 en kernelBuffer[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:

loc_loop:
    mov     al, [rdx]
    mov     [rcx], al
    inc     rcx
    inc     rdx
    dec     r8
    jnz     short loc_loop
    ret

…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

  1. La VM target ya tiene HEVD cargado (Clase 1, §14).
  2. La VM target ya tiene bcdedit /debug yes con la net key configurada.
  3. La VM target está corriendo y el cliente user-mode está esperando un Enter para enviar el DeviceIoControl.

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:

  1. Con el .sys de HEVD abierto en IDA en el host:
  2. Debugger → Select debuggerWindbg debugger.
  3. Debugger → Process options (o Debugger options):
  4. 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.
  5. Kernel mode: ✓ marcado.
  6. Use hardware temporary breakpoint: ✓ recomendado.
  7. Debugger → Attach to process (o F9 con 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 .sys entre 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 (o bp <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

  1. Debugger → Manual Memory Regions (o View → Open subviews → Manual Memory Regions).
  2. Insert (o tecla Ins).
  3. Configurar:
  4. Start address: 0
  5. End address: 0xFFFFFFFFFFFFFFFE (15 efes y una E al final)
  6. 64-bit: ✓ marcado
  7. OK → aparece la región nueva.
  8. 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:

  1. Hover sobre la dirección → verla en el panel de hex.
  2. Click derecho → Struct var (Alt+Q o T) → seleccionar _DRIVER_OBJECT, _IRP, _IO_STACK_LOCATION, etc.
  3. 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!_IRP Solo 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

0: kd> x nt!*Irp*
fffff800`...   nt!IoCancelIrp
fffff800`...   nt!IoFreeIrp
... (cientos de matches)

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

DispatcherEntry:
    RCX = DeviceObject (puntero al device)
    RDX = Irp           (puntero al IRP del request)

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:

sub     ecx, 222003h
jz      loc_TriggerStackOverflow

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

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

  1. Construir una cadena ROP con gadgets que ya están en el kernel (en ntoskrnl.exe o en otros drivers cargados, todos en kernel space → SMEP-safe).
  2. Esa cadena ejecuta un token stealing: copia el token de SYSTEM al token del proceso atacante.
  3. Cuando el exploit termina y el proceso vuelve a user-mode, es SYSTEM → se lanza un cmd.exe que 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