Sécurité et Bypass d’Endpoint Detection Response (EDR) (Partie 1)

Développement d'un EDR minimaliste

Posté par Matthieu le 5 novembre 2020

De nos jours, on tend à voir de plus en plus de sociétés spécialisées dans les antivirus proposer des solutions de type Endpoint Detection and Response (EDR). Ce genre d’Anti-virus (maintenant appelé AV) « next-gen » devient fréquemment visible sur les réseaux d’entreprise et propose une multitude de fonctionnalités supplémentaires par rapport aux AV classiques.

Parmi elles, nous retrouvons une recherche active en RAM, afin de s’adapter au mode opératoire des attaquants qui se veut de plus en plus « fileless » , c’est-à-dire sans poser de fichiers sur le disque de la machine infectée.

Une autre fonctionnalité souvent mise en avant par les EDR est la surveillance active de processus afin de faire de l’analyse comportementale, l’objectif étant de stopper un processus commettant une action perçue comme illégitime.

Et c’est justement cette partie de surveillance active de processus sur laquelle je me suis penché ces dernières semaines afin d’en comprendre les rouages et de pouvoir contourner ce mécanisme de sécurité. N’ayant pas d’EDR sous la main, car la plupart sont payants et n’offrent aucun essai gratuit (vous avez quelque chose à cacher ?), j’ai donc du réimplémenter moi-même les fonctions de l’EDR qui m’intéressaient. Je vous propose de vous plonger avec moi dans cet univers très intéressant que sont les hooks , les injections de DLL, les kernel callbacks, etc…

Au programme du jour :

  • Kernel CallBacks & WMI events
  • Injection de DLL
  • Hooking windows
  • Démonstation de l’EDR handmade

Kernel CallBacks & WMI events

Nous avons précisé pendant l’introduction que les EDR proposaient un mécanisme de surveillance actif des processus en cours d’exécution. Cependant afin de pouvoir surveiller les processus, l’outil doit être notifié de leur existence. Il nous faut d’abord trouver un moyen simple et efficace pour que notre programme exécute une fonction à chaque création de processus.

Windows propose pour cela une solution très simple : un kernel Callback portant le doux nom de « PsSetCreateProcessNotifyRoutine »

NTSTATUS PsSetCreateProcessNotifyRoutine(
  PCREATE_PROCESS_NOTIFY_ROUTINE NotifyRoutine,
  BOOLEAN                        Remove
);

Cette fonction va enregistrer, dans une zone réservée en kernel land, un pointeur vers la fonction à appeler lorsqu’un nouveau processus est créé. Cette solution est celle majoritairement adoptée par les EDR.

Cependant elle me posait problème à moi aussi bien qu’à mon cerveau de développeur feignant : il faut aller en kernel land , et donc développer un pilote (driver) kernel. Cette solution aurait impliqué des dizaines d’heures de debug et de BSOD (bluescreen of the death) et ce n’était pas le but recherché.

Une autre solution bien connue s’offre donc à moi : WMI (Windows Management Instrumentation). WMI est un système de gestion interne permettant d’effectuer des requêtes en WQL directement auprès de l’OS. Parmi ces requêtes on retrouve une solution permettant d’être notifié à chaque création de process, voici comment je l’ai utilisée :

      	var startWatch = new ManagementEventWatcher(new WqlEventQuery("SELECT * FROM Win32_ProcessStartTrace"));
		startWatch.EventArrived += new EventArrivedEventHandler(EventArrived_inject);
		startWatch.Start();

Maintenant, à chaque création de process, la fonction EventArrived_inject est appelée. Évidemment cela serait trop beau si cette méthode était aussi efficace que les kernel callback… elle présente un souci majeur pour nous : le temps. En effet, WMI n’est pas fait pour être notifié en temps réel et cela crée une latence assez importante entre la création du process et l’exécution de la fonction « inject ». Ce point serait bloquant dans le cas d’un vrai EDR car il est impossible d’injecter un « sacrificial process » (process créé par l'attaquant uniquement dans le but de commettre une action malicieuse et de mourir) avant qu’il n’ait exécuté sa mission. Pour nous, pas de soucis nous ne comptons pas commercialiser l’EDR mais uniquement faire des tests.

La solution est donc maintenue : nous utilisons WMI !

Injection de DLL

Maintenant que notre programme est notifié à la création de processus : que faire ensuite ? Nous recherchons alors un moyen de faire exécuter du code à ce dit process afin qu’il nous informe de ses actions pour que nous puissions le surveiller. Une solution bien connue nous permettant de faire cela est l’injection de DLL.

Cette technique a pour but de forcer le processus à charger notre propre librairie afin de lui faire exécuter le code qu’elle contient. La technique est aussi bien connue des attaquants car elle peut être utilisée à des fins de migrations entre process.

Nous allons opérer en 4 étapes :

OpenProcess

Dans un premier temps, nous devons récupérer un accès (communément appelé HANDLE) sur le processus cible. Pour y parvenir nous utiliserons la fonction OpenProcess() de la WINAPI :

HANDLE OpenProcess(
  DWORD dwDesiredAccess,
  BOOL  bInheritHandle,
  DWORD dwProcessId
);

Voici comment elle est utilisée dans le cadre de notre EDR :

	IntPtr procHandle = OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, false, pid);

A la fin de l’exécution de cette fonction, nous récupérons un accès sur le process nous permettant entre autres de manipuler sa mémoire.


VirtualAlloc

Une fois cet HANDLE récupéré, nous allons nous allouer un espace mémoire dans le process afin d’y écrire le chemin vers notre dll. La fonction VirtualAllocEx répond à ce besoin :

LPVOID VirtualAllocEx(
  HANDLE hProcess,
  LPVOID lpAddress,
  SIZE_T dwSize,
  DWORD  flAllocationType,
  DWORD  flProtect
);

Voici comment elle est utilisée de notre côté :

	ntPtr allocMemAddress = VirtualAllocEx(procHandle, IntPtr.Zero, (uint)((dllName.Length + 1) * Marshal.SizeOf(typeof(char))), MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

Nous allouons dans notre process cible (procHandle), la taille nécessaire pour écrire le chemin de notre dll et un nullbyte de fin de chaine (dllName.Length + 1). Il nous faut évidemment demander la zone en écriture étant donné que nous allons écrire à cet endroit (PAGE_READWRITE)


WriteProcessMemory

Nous pouvons maintenant écrire notre fameux chemin de dll via la fonction WriteProcessMemory :

	BOOL WriteProcessMemory(
  HANDLE  hProcess,
  LPVOID  lpBaseAddress,
  LPCVOID lpBuffer,
  SIZE_T  nSize,
  SIZE_T  *lpNumberOfBytesWritten
);

Voici notre ligne correspondante :

	WriteProcessMemory(procHandle, allocMemAddress, Encoding.ASCII.GetBytes(dllName), (uint)((dllName.Length + 1) * Marshal.SizeOf(typeof(char))), out bytesWritten);

Inutile de détailler, la ligne parle d’elle-même.


CreateRemoteThread

Et enfin, nous devons démarrer un nouveau «Thread » qui va aller exécuter LoadLibrary avec notre chemin fraichement écrit en paramètre. CreateRemoteThread nous permet de lancer un Thread dans un autre processus :

	HANDLE CreateRemoteThread(
  HANDLE                 hProcess,
  LPSECURITY_ATTRIBUTES  lpThreadAttributes,
  SIZE_T                 dwStackSize,
  LPTHREAD_START_ROUTINE lpStartAddress,
  LPVOID                 lpParameter,
  DWORD                  dwCreationFlags,
  LPDWORD                lpThreadId
);

Que nous utilisons comme ceci :

	IntPtr CRT = CreateRemoteThread(procHandle, IntPtr.Zero, 0, loadLibraryAddr, allocMemAddress, 0, IntPtr.Zero);

Voici le résultat final de cette opération :

sc1

Nous remarquons que le process notepad.exe est un processus surveillé par l’EDR , il va donc injecter sa dll à l'intérieur en suivant la technique que nous venons de détailler.

Hooking sur Windows

Nous sommes, à présent, en mesure d’exécuter du code dans le contexte de notre processus cible, nous devons cependant trouver un moyen de le surveiller ou plus précisément de surveiller ses appels à des fonctions pouvant être utilisées à des fins offensives. En effet, s’il est inutile de surveiller printf(), il n’en n’est pas de même pour des fonctions comme OpenProcess(), VirtualAlloc(), CreateThread() , ReadProcessMemory(),…

Une fois de plus nous pouvons nous reposer sur une technique bien connue nous permettant de parvenir à nos fins : le Hooking IAT.

Tous les PE (Portable Executable) Windows possèdent une structure se nommant joliment Import Address Table ou IAT. Cette table est en réalité une liste de tous les pointeurs correspondants à toutes les fonctions utilisées par le programme. Si par exemple le programme va utiliser la fonction MessageBoxA(), il possède dans son IAT un pointeur redirigeant vers le code de la fonction situé dans user32.dll.

L’idée du « Hooking IAT » va donc être de modifier cette table afin de faire pointer les fonctions intéressantes vers notre DLL avant de la rediriger vers la vraie fonction. De ce fait, nous serons en mesure d’inspecter les paramètres demandés par le programme et de juger de son comportement malicieux ou non.

Comme d’habitude, un schéma vaut mille mots :

sc2
(crédit ired.team : https://www.ired.team/offensive-security/code-injection-process-injection/import-adress-table-iat-hooking)

Notre DLL doit donc être en mesure de créer ce hook et d’analyser les paramètres des fonctions. J’ai décidé d’utiliser la librairie MinHook pour y parvenir, elle permet très simplement de créer le hook.

Voici par exemple mon hook pour OpenProcess :

	HANDLE WINAPI DetourOpenProcess(DWORD dwDesiredAccess, BOOL  bInheritHandle, DWORD dwProcessId) 
{
	int lsassPID = FindLsassPID();
	fpMessageBoxW(NULL, L"Oh shit ... That OpenProcess has been hooked ...", L"SecurEDR", MB_OK);
	if (dwProcessId == lsassPID) {
		fpMessageBoxW(NULL, L"Hacker detected, Mimikatz Attempt", L"SecurEDR", MB_OK);
		return NULL;
	}
	return fpOpenProcess(dwDesiredAccess, bInheritHandle, dwProcessId);
}

Comme nous pouvons le voir, j’ai fait une règle de détection très simple et surtout TROP simple : si le process demande un handle sur lsass.exe : je le bloque ! Evidement cette règle ne serait pas utilisable en cas réel car elle présenterait trop de faux positifs et bloquerait beaucoup trop d’appels légitimes. Cependant elle fonctionne à merveille pour notre cas d’étude : mimikatz.

sc3

Le blocage de cet appel empêche évidemment le fonctionnement de mimikatz :

sc4

Mais si nous pouvions passer outre cette protection … ?

Conclusion

Aujourd’hui, nous avons vu ensemble le fonctionnement de base d’un EDR depuis la notification d’un nouveau processus jusqu’au hooking de certaines fonctions de la WinAPI. Ce projet n’a pas la prétention d’être un EDR à proprement parler mais uniquement de reproduire une des fonctionnalités de ces derniers.

Dans le prochain article nous verrons comment contourner cette sécurité et ainsi pouvoir exécuter de nouveau mimikatz sur une machine protégée par notre super « SecurEDR ».

En attendant ... Happy Hacking !

48 Coeur(s)