
Ingeniería inversa y explotación de controladores
⚠️ Aviso: Este proyecto es estrictamente educativo y demostrativo. No está pensado para utilizarse en un contexto malintencionado. El objetivo es aprender la metodología de ingeniería inversa y los pasos de explotación de un driver de Windows.
Aquí explico el proceso que seguí para resolver el ejercicio propuesto por d1rk(SaadAhla) https://github.com/SaadAhla, que consiste en realizar ingeniería inversa y explotación sobre un driver legítimo, firmado y no presente en las listas de bloqueo (HVCI, LOLBIN...). Un programa en C que permite terminar cualquier proceso activo del sistema mediante este driver en modo kernel está disponible; detallo su funcionamiento un poco más abajo.

📃 Uso: DriverKiller.exe <nom_processus.exe> [-d]
Opción -d: Permite eliminar el servicio y el driver del sistema después de la explotación.
El modo testsigning debe estar activado en la máquina objetivo porque el certificado del driver ha caducado.
Parte 1 - Ingeniería inversa:
El ejercicio proporciona un archivo .sys, nombrado con su hash SHA-256.
El primer paso es abrir este archivo con IDA.
IDA está disponible gratuitamente. Solo hay que ir al sitio de Hex-Rays para generar una licencia y descargar el software.
Empezamos enumerando la IAT (Import Address Table) del driver y buscando la llamada a la API que nos interesa: ZwTerminateProcess.
Al hacer doble clic en ZwTerminateProcess, IDA nos redirige al código compilado de esta función. Seleccionando la entrada y mostrando las referencias cruzadas, obtenemos la lista de funciones del driver que la llaman.
Observamos que es la función sub_12EF4, en el offset 1CE, la que utiliza ZwTerminateProcess. Después de un doble clic, IDA muestra su código compilado.
El código descompilado revela las llamadas a ZwOpenProcess (que abre un handle hacia el proceso objetivo) y a ZwTerminateProcess (que termina el proceso a través de ese handle).
Al consultar la documentación de ZwOpenProcess (https://learn.microsoft.com/fr-fr/windows-hardware/drivers/ddi/ntddk/nf-ntddk-zwopenprocess), vemos que el parámetro ClientID corresponde a un puntero que indica el PID del proceso objetivo.
En la línea anterior, ClientId.UniqueProcess se inicializa con la variable v22. Esta última se define justo encima:
v22 = (void )((_QWORD *)i + 10);
Para entender esta asignación, hay que identificar la variable i y el campo +10.
Más arriba en esta función, observamos una llamada a ZwQuerySystemInformation con el parámetro SYSTEM_PROCESS_INFORMATION. También entendemos que i es el iterador sobre las entradas de esta estructura con la variable v6.
Según la documentación de ZwQuerySystemInformation: (https://learn.microsoft.com/en-us/windows/win32/sysinfo/zwquerysysteminformation), esta función devuelve una matriz con una entrada por cada proceso activo en el sistema.
La estructura SYSTEM_PROCESS_INFORMATION se describe aquí: https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntquerysysteminformation
typedef struct _SYSTEM_PROCESS_INFORMATION {
ULONG NextEntryOffset;
ULONG NumberOfThreads;
BYTE Reserved1[48];
UNICODE_STRING ImageName;
KPRIORITY BasePriority;
HANDLE UniqueProcessId;
PVOID Reserved2;
ULONG HandleCount;
ULONG SessionId;
PVOID Reserved3;
SIZE_T PeakVirtualSize;
SIZE_T VirtualSize;
ULONG Reserved4;
SIZE_T PeakWorkingSetSize;
SIZE_T WorkingSetSize;
PVOID Reserved5;
SIZE_T QuotaPagedPoolUsage;
PVOID Reserved6;
SIZE_T QuotaNonPagedPoolUsage;
SIZE_T PagefileUsage;
SIZE_T PeakPagefileUsage;
SIZE_T PrivatePageCount;
LARGE_INTEGER Reserved7[6];
} SYSTEM_PROCESS_INFORMATION;
Recordatorio: tamaños de algunos tipos en Windows x64
typedef struct _UNICODE_STRING {
USHORT Length; -> 2
USHORT MaximumLength; -> + 2 = 4
PWSTR Buffer; -> + 8 = 12 (12 n'est pas un multiple de 8 donc padding de 4 ajouté en amont de Buffer) = 16
} UNICODE_STRING;
Cálculo del offset de UniqueProcessId:
ULONG NextEntryOffset; -> 4
ULONG NumberOfThreads; -> + 4 = 8
BYTE Reserved1[48]; -> + 48 = 56
UNICODE_STRING ImageName; -> + 16 = 72
KPRIORITY BasePriority; -> + 4 = 76 (76 n'est pas un multiple de 8 donc padding de 4 ajouté) = 80
HANDLE UniqueProcessId; -> + 8 = 88
El miembro UniqueProcessId está, por tanto, en el offset 0x50 (80 decimal).
Al observar la asignación de nuestra variable v22, vemos que i se convierte (cast) a puntero QWORD (8 bytes)
v22 = (void )((_QWORD *)i + 10);Por lo tanto,
v22 corresponde a la dirección de i + 10 * 8 = 80 bytes. Esta variable contiene, entonces, el PID obtenido de la estructura SYSTEM_PROCESS_INFORMATION.
Para saber qué PID se pasará a ZwTerminateProcess, hay que analizar la condición que rodea esta asignación.
Vemos que primero se recupera el nombre de la imagen del proceso:
v9 = (wchar_t )((_QWORD *)i + 8);Porque
v9 = dirección de i + 8 × 8 = 64 bytes. Esto corresponde al Buffer del miembro ImageName, ya que dicho miembro se encuentra en el offset 56 + 2 (USHORT) + 2 (USHORT) + 4 (padding) = 64
Teniendo en cuenta las manipulaciones y los bucles que aparecen a continuación, se puede plantear la hipótesis de que se realiza una comparación entre el nombre del proceso pasado como argumento (a2) y los procesos activos del sistema v9/String.
sub_1C078(String, v9, (int)v13); v17 = strupr(a2); v18 = strupr(String);
Por lo tanto, se supone que el parámetro a2 contiene el nombre del proceso que se terminará mediante ZwTerminateProcess.
Observamos que a2 es un parámetro de la función sub_12EF4. Para profundizar, hay que examinar las referencias de esta función (la he renombrado ZwTerminateProcessCaller para una mejor legibilidad).
