J'explique ici la démarche que j’ai suivie pour résoudre l’exercice proposé par d1rk(SaadAhla) https://github.com/SaadAhla, consistant à effectuer du reverse engineering et de l’exploitation sur un driver légitime, signé, et non présent dans les blocklists (HVCI, LOLBIN...). Un programme C permettant de terminer n'importe quel processus actif sur le système via ce Kernel-mode Driver est disponible, je détaille son fonctionnement un peu plus bas.
📃 Usage : DriverKiller.exe <nom_processus.exe> [-d]
Option -d : Permet de supprimer le service et le Driver du système après l'exploitation.
Le testsigning mode doit être activé sur la machine cible car le certificat du Driver a expiré.
Partie 1 - Reverse engineering :
L’exercice fournit un fichier .sys, nommé avec son hash SHA-256.
La première étape consiste à ouvrir ce fichier avec IDA.
IDA est disponible gratuitement. Il suffit de se rendre sur le site d’Hex-Rays afin de générer une licence et de télécharger le logiciel.
On commence par lister l’IAT (Import Address Table) du Driver et rechercher l’appel à l’API qui nous intéresse : ZwTerminateProcess.
En double-cliquant sur ZwTerminateProcess, IDA nous redirige vers le code compilé de cette fonction. En sélectionnant l’entrée puis en affichant les cross-references, on obtient la liste des fonctions du Driver qui l’appellent.
On observe que c’est la fonction sub_12EF4, à l’offset 1CE, qui utilise ZwTerminateProcess. Après un double-clic, IDA affiche son code compilé.
Le code décompilé révèle les appels à ZwOpenProcess (qui ouvre un handle vers le processus cible) et à ZwTerminateProcess (qui termine le processus via ce handle).
En consultant la documentation de ZwOpenProcess (https://learn.microsoft.com/fr-fr/windows-hardware/drivers/ddi/ntddk/nf-ntddk-zwopenprocess), on constate que le paramètre ClientID correspond à un pointeur indiquant le PID du processus visé.
Sur la ligne au-dessus, ClientId.UniqueProcess est initialisé avec la variable v22. Cette dernière est définie juste au-dessus :
v22 = (void )((_QWORD *)i + 10);
Pour comprendre cette affectation, il faut identifier la variable i et le champ +10.
Plus haut dans cette fonction, on observe un appel à ZwQuerySystemInformation avec le paramètre SYSTEM_PROCESS_INFORMATION. On comprend également que i est l’itérateur sur les entrées de cette structure avec la variable v6.
D’après la documentation de ZwQuerySystemInformation : (https://learn.microsoft.com/en-us/windows/win32/sysinfo/zwquerysysteminformation), cette fonction retourne un tableau contenant une entrée par processus actif sur le système.
La structure SYSTEM_PROCESS_INFORMATION est décrite ici : 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;Rappel : tailles de certains types sur Windows x64
- ULONG = 4 octets
- USHORT = 2 octets
- HANDLE = 8 octets
- PWSTR = 8 octets
- KPRIORITY (typedef d'un LONG) = 4 octets
- UNICODE_STRING = 16 octets car voici sa structure :
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;
Calcul de l’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
Le membre UniqueProcessId est donc à l’offset 0x50 (80 décimal).
En regardant l'affectation de notre variable v22, on constate que i est casté en pointeur QWORD (8 octets)
v22 = (void )((_QWORD *)i + 10);Donc
v22 correspond l'adresse de i + 10 * 8 = 80 octets. Cette variable contient donc bien le PID récupéré de la structure SYSTEM_PROCESS_INFORMATION.
Pour savoir quel PID sera passé à ZwTerminateProcess, il faut analyser la condition qui entoure cette affectation.
On constate que le nom de l’image du processus est d’abord récupéré :
v9 = (wchar_t )((_QWORD *)i + 8);Car
v9 = adresse de i + 8 × 8 = 64 octets. Cela correspond au Buffer du membre ImageName, puisque ce membre se trouve à l’offset 56 + 2 (USHORT) + 2 (USHORT) + 4 (padding) = 64
Au vu des manipulations et des boucles dessous, on peut émettre l’hypothèse qu’une comparaison entre le nom du processus passé en argument (a2) et les processus actifs sur le système v9/String est effectuée.
sub_1C078(String, v9, (int)v13); v17 = strupr(a2); v18 = strupr(String);
C’est donc le paramètre a2 qui est censé contenir le nom du processus à terminer via ZwTerminateProcess.
On remarque que a2 est un paramètre de la fonction sub_12EF4. Pour aller plus loin, il faut examiner les références de cette fonction (je l’ai renommée ZwTerminateProcessCaller pour une meilleure lisibilité).
On constate que ZwTerminateProcessCaller est appelée par la fonction sub_13624 à l'offset 61A.
Avant d’analyser ce code décompilé, je vais chercher les références de la fonction sub_13624 (renommée ZwTerminateProcessCallerCaller) afin de m’assurer que ce code est bien utilisé après un appel API à DeviceIoControl depuis le UserMode.
On constate que ZwTerminateProcessCallerCaller est appelée par la fonction sub_14130 (renommée ZwTerminateProcessCallerCallerCaller ...heureusement pour nous, c'est la dernière avant le point d'entrée 😅).
On constate que ZwTerminateProcessCallerCallerCaller est appelée par la fonction sub_1A4A8 à l'offset 306.
On trouve l'assignation de la fonction ZwTerminateProcessCallerCallerCaller :
memset64(DriverObject->MajorFunction, (unsigned __int64)ZwTerminateProcessCallerCallerCaller, 0x1Cu);Ce qui signifie que cette fonction est assignée à toutes les entrées de la table MajorFunction (0x1B = 27, et il existe 28 IRP majeures).
Avant de revenir à la fonction sub_13624 (alias ZwTerminateProcessCallerCaller), on récupère le Symbolic Name et le Device Name (identiques ici) : Viragtlt.
En revenant sur ZwTerminateProcessCallerCaller, on remarque que son deuxième paramètre (donc a2) correspond à MasterIrp->AssociatedIrp.SystemBuffer.
Juste au dessus de l'appel à ZwTerminateProcessCaller on trouve le IOCTL code : -2106392528 (en hexadécimal : 0x82730030).
Grâce à ces informations, on peut en déduire que pour exploiter ce Driver, il faut envoyer un appel API DeviceIoControl au Driver avec le nom du processus à terminer dans le SystemBuffer.
🔷 Informations récupérées grâce au reverse engineering :
- IOCTLCode :
0x82730030 - Device Name :
Viragtlt - Symbolic Name :
Viragtlt - SystemBuffer doit contenir le nom du processus cible
Partie 2 - Exploitation
Pour exploiter ce Driver (s'il est installé et actif sur la machine cible), il est nécessaire d'ouvrir un handle vers celui-ci, puis de faire un appel API DeviceIoControl avec un Buffer contenant le nom du processus que l'on souhaite terminer.
Pour cet exercice, j'ai développé un projet C qui :
- Vérifie si le Driver est présent et actif sur le système (avec un nom de service précis) :
- Si oui, le programme exploite le Driver avec un appel API DeviceIoControl.
- Si non, le programme extrait le driver de ses ressources, le déploie sur le bureau de l'utilisateur, crée un service actif puis exploite le Driver avec un appel API DeviceIoControl. (Nécessite les droits admin car une création de service est effectuée.)
- Si le Driver est présent sur le système mais que le service n'est pas démarré, le programme essaye de démarrer le service puis l'exploite avec un appel API DeviceIoControl.
J'ai également ajouté une option -d qui permet de supprimer le service et le Driver du système après l'exploitation.
Voici le comportement du programme C dans son cycle d'exécution complet :
Evasion AV/EDR
Dans ce cas, DriverKiller.exe n’est pas détecté par Microsoft Defender, ni en statique ni en dynamique. L’évasion n’a pas vraiment de sens ici car le Driver exploité possède un certificat expiré, son utilisation en conditions réelles est donc difficilement envisageable. Mais pour une meilleure furtivité, on aurait pu implémenter :
- Le masquage de certains appels API de la table IAT grâce à des implémentations personnalisées de GetProcAddress et GetModuleHandle
- Un rapprochement du Kernel pour l’exécution des appels API (Direct/Indirect Syscalls)
- Des techniques Anti-VM / Anti-Debug
Détection sur le driver au 29/08/2025 (résultat déjà existant, je n'ai rien soumis sur VirusTotal pour des raisons évidentes) :