Saltar a contenido

Clase 1 — Patch diffing de ksthunk.sys y reversing inicial del filter driver

← Volver al Módulo 6

Resumen: en esta clase se arranca desde el diff entre una versión vulnerable y una versión parcheada de ksthunk.sys/fstunc.sys, se usa BinDiff para encontrar el cambio real del parche, se interpreta el gate IsEnableDeviceUsage, y después se empieza a reconstruir el driver como código C++: DriverEntry, clases, vtables, AddDevice, device stack, dispatch de IRPs y cómo llegar al IOCTL vulnerable.


Tabla de Contenidos

  1. Contexto del caso
  2. Workflow correcto para patch diffing
  3. IsEnableDeviceUsage: el gate típico de Microsoft
  4. Cómo leer el diff en BinDiff
  5. Corregir matches incorrectos
  6. Identificar el bloque real del parche
  7. Reversing desde DriverEntry
  8. Reconstrucción de clases C++ en IDA
  9. CKernelFilterDriver::Initialize
  10. AddDevice y creación del filter device
  11. Filter drivers y device stack
  12. DispatchIrpBridge y ruta hacia el IOCTL
  13. Por qué no aparece un \\.\DeviceName directo
  14. Obtener el symbolic link con GUID
  15. WinDbg: confirmar el stack de devices
  16. Workflow mental completo
  17. Cheat sheet
  18. Glosario corto

1. Contexto del caso

El caso de estudio del módulo es un parche real de Microsoft para el componente Kernel Streaming WOW Thunk Service Driver.

En la clase aparecen nombres como:

ksthunk.sys
fstunc.sys
KSTUNC
FSTUNC

La idea principal no es adivinar la vulnerabilidad desde cero, sino comparar dos binarios:

Versión Rol
Binario viejo Versión vulnerable o previa al parche
Binario nuevo Versión parcheada

El diff permite responder:

  • qué función cambió;
  • qué bloque nuevo agregó Microsoft;
  • qué condición valida el parche;
  • qué ruta vieja quedó protegida por una condición nueva;
  • cómo se llega a esa función desde user-mode.

El bug del CVE está asociado a una ruta de IOCTL dentro de un driver de Kernel Streaming, pero la clase se enfoca primero en ubicar y entender el patch.


2. Workflow correcto para patch diffing

El workflow recomendado es:

  1. Conseguir la versión vulnerable o inmediatamente anterior.
  2. Conseguir la versión parcheada.
  3. Abrir primero la versión parcheada en IDA.
  4. Esperar que termine el auto-analysis.
  5. Guardar la base .i64.
  6. Abrir la versión vulnerable en IDA.
  7. Guardar su base .i64.
  8. Correr BinDiff entre ambas bases.
  9. Revisar funciones con similitud menor a 1.0 y funciones unmatched.

La clase recalca que conviene comparar contra la versión más cercana posible al parche. Si se compara contra una build demasiado vieja, el diff queda lleno de ruido: cambios de refactor, cambios de build, funciones movidas, fixes viejos y diferencias que no pertenecen al CVE.

Orden práctico

patched.sys  -> IDA -> patched.i64
vuln.sys     -> IDA -> vuln.i64

patched.i64  <->  vuln.i64  -> BinDiff

Qué se busca primero

  • Funciones nuevas en el binario parcheado.
  • Funciones viejas modificadas.
  • Basic blocks nuevos con checks de tamaño.
  • Branches nuevos que separan ruta vulnerable y ruta parcheada.
  • Calls a funciones con nombre tipo IsEnable....

3. IsEnableDeviceUsage: el gate típico de Microsoft

Uno de los puntos más importantes de la clase es el patrón IsEnableDeviceUsage.

Microsoft muchas veces no elimina directamente la ruta vieja. En vez de eso, introduce un feature gate:

if (IsEnableDeviceUsage(...)) {
    // patched path
} else {
    // old / vulnerable-compatible path
}

En la práctica:

Retorno Significado práctico
0 tomar ruta vieja / no parcheada
1 tomar ruta nueva / parcheada

Esto es útil para Microsoft porque puede controlar comportamiento con feature flags, compatibilidad o rollout interno. Para reversing es muy útil porque el gate marca dónde está el fix.

Por qué mirar funciones IsEnable*

Cuando en el binario parcheado aparece una función nueva tipo:

IsEnableDeviceUsage

o una variante con sufijo numérico, suele ser una señal fuerte de que hay una mitigación o un cambio de comportamiento agregado por el parche.

Cuidado con gates viejos

No todo IsEnable* es del parche actual.

Puede haber gates agregados en meses anteriores. Si están en ambos binarios, BinDiff los va a matchear como funciones comunes. Para el parche actual interesa especialmente:

  • función IsEnable* presente solo en el binario nuevo;
  • call nuevo hacia ese gate;
  • bloque nuevo que depende del retorno;
  • validación nueva detrás del branch patched.

4. Cómo leer el diff en BinDiff

En BinDiff hay varias vistas útiles, pero en la clase se remarca especialmente Secondary unmatched.

La idea:

  • abrir el diff;
  • mirar unmatched functions;
  • prestar atención a las funciones que existen solo en la versión parcheada;
  • buscar IsEnableDeviceUsage o funciones similares;
  • saltar desde esa función hacia sus xrefs/callers.

Lectura mental del diff

Si el binario nuevo tiene:

IsEnableDeviceUsageXYZ

y el viejo no, entonces el camino es:

función nueva IsEnable...
        ↑ xref
función modificada que llama al gate
        ↑ revisar basic blocks
bloque con check nuevo

Por qué no quedarse solo con similarity

Una función puede tener similarity alta y aun así contener el fix real. Un parche de seguridad puede ser apenas:

if (Size > MaxSize) {
    return STATUS_INVALID_PARAMETER;
}

Eso cambia pocos bytes, pero semánticamente es todo el parche.


5. Corregir matches incorrectos

BinDiff no es perfecto. Puede matchear mal funciones parecidas o basic blocks parecidos.

La clase muestra que hay que corregir el diff manualmente cuando el match no tiene sentido.

Correcciones típicas

Problema Acción
Función vieja matcheada contra función nueva equivocada borrar el match incorrecto
Función correspondiente quedó unmatched agregar match manual
Basic blocks similares pero no equivalentes borrar match de bloque
Bloque nuevo del patch quedó mezclado con bloque viejo separar y revisar en Flow Graph

Regla práctica

Si el graph no cierra visualmente, hay que desconfiar del match automático.

La herramienta ayuda, pero la interpretación final la hace el reverser.


6. Identificar el bloque real del parche

Una vez ubicado el caller de IsEnableDeviceUsage, se mira la función en Flow Graph.

La forma general observada en clase es:

call IsEnableDeviceUsage
test eax, eax
jz old_path

patched_path:
    nuevo check de size / límites
    si falla -> error
    si pasa  -> continuar

old_path:
    comportamiento anterior

El bloque interesante suele ser el que aparece solo en el binario parcheado. En este caso, la corrección está asociada a un size check antes de llegar a la operación vulnerable.

Cómo reconocer el bloque de parche

Buscar:

  • comparación nueva contra un tamaño máximo;
  • branch nuevo hacia error;
  • constantes nuevas usadas como límite;
  • return de error tipo STATUS_INVALID_PARAMETER;
  • separación explícita entre patched path y old path.

Probar comportamiento parcheado vs viejo

En una VM parcheada se puede observar la rama tomada. Si el objetivo es estudiar la ruta vieja, se puede forzar el branch en debugger o alterar temporalmente el retorno del gate para análisis.

Punto importante de la clase: si hay varios gates del mismo parche, no alcanza con forzar uno solo. Puede haber que invertir o forzar todas las decisiones relacionadas para reproducir la ruta anterior.


7. Reversing desde DriverEntry

Después de ubicar el patch, la clase vuelve al inicio del driver para reconstruir la arquitectura.

La función inicial es:

NTSTATUS DriverEntry(
    PDRIVER_OBJECT DriverObject,
    PUNICODE_STRING RegistryPath
);

En Windows drivers, DriverEntry cumple un rol parecido a main, pero para kernel-mode.

Qué buscar en DriverEntry

  • Inicialización de globals.
  • Allocations de objetos C++.
  • Constructors manuales o inlined.
  • Inicialización de vtables.
  • Registro de callbacks del DriverObject.
  • DriverUnload.
  • AddDevice.
  • Tabla MajorFunction[].

En la clase se observa una allocation chica, de tamaño 0x18, que corresponde al objeto principal del driver:

sizeof(CKernelFilterDriver) ~= 0x18

Ese objeto después se inicializa y se guarda asociado al DriverObject.


8. Reconstrucción de clases C++ en IDA

El driver está escrito con patrones de C++: objetos, métodos, vtables y herencia.

Para ordenar el decompiler, se reconstruyen estructuras.

Plugin HRT / Kaspersky

En la clase se usa un plugin tipo HRT/Kaspersky para IDA que ayuda a crear structs a partir de allocations.

Ejemplo mental:

ExAllocatePoolWithTag(..., 0x18, ...)
Create dummy struct
struct S18 { ... }
rename a CKernelFilterDriver

El plugin puede crear:

  • struct dummy por tamaño;
  • campo de vtable;
  • tipos auxiliares;
  • funciones virtuales aproximadas.

Renombrar el struct

Un nombre inicial tipo:

S18

se renombra a algo semántico:

struct CKernelFilterDriver;

La clase también menciona el truco de usar typedefs para vincular nombres reconstruidos con nombres esperados por símbolos o tipos de Microsoft.

Objetivo real

No se busca que la estructura sea perfecta desde el principio. Se busca que el pseudocódigo deje de verse como punteros crudos y empiece a expresar intención:

driver->Initialize(DriverObject, RegistryPath);

en vez de:

(*(void (__fastcall **)(__int64, PDRIVER_OBJECT, PUNICODE_STRING))(*(_QWORD *)obj + 0x10))(obj, DriverObject, RegistryPath);

9. CKernelFilterDriver::Initialize

La función de inicialización del objeto principal configura el driver real.

En la clase se reconstruye algo parecido a:

CKernelFilterDriver::Initialize(DriverObject, RegistryPath)

Operaciones importantes

  1. Llama a IoAllocateDriverObjectExtension.
  2. Reserva una extensión de 8 bytes asociada al DriverObject.
  3. Guarda un puntero al objeto CKernelFilterDriver en esa extensión.
  4. Inicializa callbacks del DriverObject.
  5. Rellena MajorFunction[] con un puente común.
  6. Registra AddDevice.
  7. Registra DriverUnload.

IoAllocateDriverObjectExtension

El patrón observado:

IoAllocateDriverObjectExtension(
    DriverObject,
    ClientIdentificationAddress,
    8,
    &DriverObjectExtension
);

El tamaño 8 sugiere que la extensión guarda un solo puntero:

*(CKernelFilterDriver **)DriverObjectExtension = this;

Esto permite recuperar el objeto C++ desde callbacks estáticos del modelo WDM.

Tabla MajorFunction[]

El driver rellena muchas entradas con la misma función puente:

DriverObject->MajorFunction[i] = DispatchIrpBridge

En el transcript se menciona un loop de aproximadamente 0x1C entradas desde el offset de MajorFunction.

La idea es que distintos IRP major codes entren por el mismo bridge y después se redistribuyan según el tipo de request.


10. AddDevice y creación del filter device

En WDM/PnP, AddDevice se llama cuando el sistema enumera un device al que el driver debe asociarse.

Firma conceptual:

NTSTATUS AddDevice(
    PDRIVER_OBJECT DriverObject,
    PDEVICE_OBJECT PhysicalDeviceObject
);

Qué hace en esta clase

La ruta reconstruida es:

AddDevice
  -> IsDeviceFilterable
  -> CreateFilterDevice
  -> AttachToStack

IsDeviceFilterable

Consulta configuración/registry para decidir si el device concreto debe ser filtrado.

Esto es típico en filter drivers: no necesariamente se attachan a todo, sino a devices compatibles con cierta clase/interfaz.

CreateFilterDevice

La clase muestra una allocation de tamaño 0x80, que se interpreta como un objeto tipo:

struct FilterDevice;

Ese objeto se asocia con el DeviceObject creado por el driver.


11. Filter drivers y device stack

El detalle clave: el driver crea un device con nombre NULL.

Conceptualmente:

IoCreateDevice(
    DriverObject,
    DeviceExtensionSize,
    NULL,
    DeviceType,
    DeviceCharacteristics,
    FALSE,
    &DeviceObject
);

Si DeviceName = NULL, no hay un nombre directo estilo:

\\.\NombreDelDriver

Eso indica que no se trata de un device standalone pensado para abrirse directamente con CreateFile, sino de un filter device que se adjunta a un stack existente.

DeviceExtension

El objeto C++ del filtro se guarda en:

DeviceObject->DeviceExtension = FilterDevice;

Después, cuando llega un IRP, el bridge recupera ese contexto desde el DeviceObject.

Attach al stack

La función importante es:

IoAttachDeviceToDeviceStack(
    SourceDevice,
    TargetDevice
);

Esto mete al filter driver arriba de otro device en el stack.

Modelo mental

User-mode request
Device interface / symbolic link
Top of stack: KSTUNC filter device
Lower driver: MSKSSRV
Lower driver: SWENUM

El filtro intercepta o procesa IRPs que van hacia ese stack.


12. DispatchIrpBridge y ruta hacia el IOCTL

El driver registra una función puente para varios major functions.

La idea general:

NTSTATUS DispatchIrpBridge(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
    FilterDevice *filter = (FilterDevice *)DeviceObject->DeviceExtension;
    return filter->DispatchIrp(Irp);
}

El bridge convierte el callback estilo C de WDM en una llamada a método C++.

Ruta hacia device control

El camino relevante es:

DispatchIrpBridge
  -> FilterDevice::DispatchIrp
  -> switch (MajorFunction)
  -> IRP_MJ_DEVICE_CONTROL
  -> IOCTL dispatcher
  -> case 0x2F007
  -> función vulnerable / parcheada

IOCTL mencionado

En la clase aparece el IOCTL:

0x2F007

Ese valor es importante porque conecta la superficie user-mode con la función donde se ve el patch.

Qué mirar en IDA

  • IoGetCurrentIrpStackLocation o acceso manual a Irp->Tail.Overlay.CurrentStackLocation.
  • MajorFunction.
  • Parameters.DeviceIoControl.IoControlCode.
  • switch/cases sobre IOCTLs.
  • branch hacia el caso 0x2F007.
  • validaciones nuevas agregadas por el patch.

13. Por qué no aparece un \\.\DeviceName directo

En drivers simples, como ejemplos de HEVD, suele existir:

\\.\HackSysExtremeVulnerableDriver

porque el driver crea un device con nombre y symbolic link propio.

En este caso no es así.

La clase remarca que IoCreateDevice recibe DeviceName = NULL, entonces buscar un string directo tipo \\.\KSTUNC no alcanza.

Consecuencia

Para hablar con el driver desde user-mode hay que abrir el device expuesto por la interfaz de dispositivo del stack, no un nombre directo inventado por el driver.

El symbolic link lo administra Plug and Play / Configuration Manager en base a un GUID de interfaz.


La clase muestra que se puede obtener el link usando APIs de Configuration Manager / SetupAPI.

Una API mencionada es:

CM_Get_Device_Interface_List

El flujo conceptual:

GUID de interfaz
CM_Get_Device_Interface_List_Size
CM_Get_Device_Interface_List
lista de symbolic links
CreateFile(link, ...)

Qué representa el GUID

El GUID corresponde a una interfaz relacionada con Microsoft Streaming / Kernel Streaming. No es simplemente el GUID del driver, sino de la clase/interfaz de device expuesta por el stack.

Los links de device interface suelen verse largos, por ejemplo:

\\?\root#system#...#{GUID}\...

No hay que memorizarlos a mano. La forma robusta es enumerar por GUID.

Relación con el filter driver

Aunque el handle se abre contra la interfaz del stack, el IRP baja/sube por el stack y puede llegar al filter driver KSTUNC, que es donde está el dispatch relevante.


15. WinDbg: confirmar el stack de devices

Para confirmar runtime qué device object se está usando, la clase propone romper en el dispatch y mirar el stack.

Breakpoint mental

bp ksthunk!DispatchIrpBridge

Cuando se corta, el primer argumento en x64 Windows está en rcx:

rcx = PDEVICE_OBJECT DeviceObject
rdx = PIRP Irp

Comandos útiles

!devstack <DeviceObject>
!devobj <DeviceObject>
!devnode <DevNode>

Stack observado en clase

La salida mostraba una pila parecida a:

KSTUNC
MSKSSRV
SWENUM

Interpretación:

  • KSTUNC está arriba como filter driver.
  • MSKSSRV aparece debajo como parte del servicio de Kernel Streaming.
  • SWENUM está más abajo como enumerador software.

Esto confirma que la ruta user-mode no abre un device propio de KSTUNC, sino una interfaz que termina pasando por ese stack.


16. Workflow mental completo

El camino completo de la clase se puede resumir así:

1. Tengo vulnerable.sys y patched.sys
2. Genero IDBs en IDA
3. Corro BinDiff
4. Busco Secondary unmatched
5. Encuentro IsEnableDeviceUsage en patched
6. Voy a sus xrefs
7. Identifico función modificada
8. Corrijo matches de BinDiff si hace falta
9. Miro Flow Graph
10. Separo old path vs patched path
11. Reconozco size check nuevo
12. Vuelvo a DriverEntry
13. Reconstruyo CKernelFilterDriver
14. Sigo Initialize -> AddDevice -> CreateFilterDevice
15. Entiendo que es filter driver con DeviceName NULL
16. Encuentro DispatchIrpBridge
17. Sigo IRP_MJ_DEVICE_CONTROL -> IOCTL 0x2F007
18. Enumero device interface por GUID para hablar desde user-mode
19. Confirmo el stack con !devstack

La parte más importante es no analizar la función vulnerable aislada. Hay que unir tres vistas:

Vista Pregunta que responde
Patch diff ¿Qué cambió?
Reversing estático ¿Cómo está estructurado el driver?
Debugging runtime ¿Cómo llego a esa ruta desde user-mode?

17. Cheat sheet

BinDiff

Elemento Uso
Secondary unmatched encontrar funciones nuevas del binario parcheado
IsEnable* buscar gates de Microsoft
Similarity < 1.0 encontrar funciones modificadas
Flow Graph ver bloques nuevos y branches
Manual matching corregir errores del diff automático

Señales de parche real

  • Call nuevo a IsEnableDeviceUsage.
  • Branch nuevo según retorno del gate.
  • Check nuevo de tamaño/límite.
  • Camino de error nuevo.
  • Bloque presente solo en patched.
  • Función alcanzable desde IOCTL.

WDM / C++ driver

Patrón Significado
DriverEntry entrada principal del driver
IoAllocateDriverObjectExtension guardar contexto C++ asociado al DriverObject
MajorFunction[] tabla de dispatch por IRP major code
AddDevice callback PnP para adjuntar devices
IoCreateDevice(..., NULL, ...) device sin nombre directo; probable filter device
DeviceObject->DeviceExtension contexto privado del device
IoAttachDeviceToDeviceStack attach del filtro al stack inferior
DispatchIrpBridge puente C/WDM hacia método C++

WinDbg

bp ksthunk!DispatchIrpBridge
r rcx
!devstack <rcx>
!devobj <rcx>
!devnode <devnode>

User-mode

Acción API
Obtener tamaño de lista de interfaces CM_Get_Device_Interface_List_Size
Obtener symbolic links CM_Get_Device_Interface_List
Abrir handle CreateFile
Enviar IOCTL DeviceIoControl

18. Glosario corto

Término Descripción
Patch diffing Comparar binario vulnerable y parcheado para inferir el fix.
BinDiff Herramienta para comparar bases de IDA y matchear funciones/basic blocks.
Unmatched function Función que existe en una versión pero no fue matcheada en la otra.
Feature gate Función/condición que activa o desactiva una ruta nueva.
IsEnableDeviceUsage Gate usado por Microsoft para seleccionar ruta parcheada/no parcheada.
Filter driver Driver que se adjunta arriba o abajo de otro device stack para interceptar IRPs.
Device stack Cadena de DEVICE_OBJECTs por donde circula un IRP.
AddDevice Callback PnP usado para crear/adjuntar el device object del driver.
DeviceExtension Memoria privada asociada a un DEVICE_OBJECT.
IRP_MJ_DEVICE_CONTROL Major function usada para IOCTLs.
Device interface GUID GUID usado por PnP/Configuration Manager para enumerar symbolic links de devices.

Apunte basado en la transcripción clase-modulo6-1 del Módulo 6. La clase conecta patch diffing real de Microsoft con reversing de un driver C++ WDM/filter y debugging de la ruta IOCTL.