
Conduite inversée et exploitation
⚠️ Avertissement : Ce projet est strictement éducatif et démonstratif. Il n’a pas vocation à être utilisé dans un contexte malveillant. L’objectif est d’apprendre la méthodologie de reverse engineering et les étapes d’exploitation d’un driver Windows.
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
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.