WinAPI Hooking

Technique de malware

Publié le


TL;DR

Si vous n'êtes là que pour le code source :
Programmes de l'implémentation du hooking ici.
Programmes pour la détection d'un inline hook ici.


Introduction

Cet article se concentre sur l'étude d'injection permettant de hooker les fonctions de l'API Windows. Nous explorerons ce qu'est le hooking, son fonctionnement, et sa méthode d'implémentation. Cette technique de hooking présente des cas d'utilisation variés, que ce soit pour de l'attaque ou de la défense.
Nous aborderons à la fois son utilisation à des fins malveillantes, pour prendre le contrôle lors de l'appel d'une fonction, et à des fins d'analyse, permettant de surveiller l'exécution d'un programme à des points précis, comme au début et à la fin d'une fonction (un peu à l'image du programme API monitor).
Cette approche offre plusieurs avantages, notamment la possibilité d'analyser un programme en dehors d'un debugger, évitant ainsi les techniques anti-debug.

L'étude a été réalisée sur un environnement Windows 11, version 23H2.


API Hooking

L'API Hooking est une technique qui consiste à prendre le contrôle d'une fonction en modifiant ses premières instructions pour rediriger l'exécution vers un code spécifique. Cette méthode a plusieurs objectifs : elle permet d'intercepter les arguments passés à la fonction afin d'analyser ce qui va être exécuté et comment c'est structuré. C'est aussi une approche qu'emploie certains EDRs.

Cependant, cette technique est également utilisée pour masquer du code à des emplacements où les analystes ne pensent pas habituellement à chercher, car ces derniers se fient souvent à des appels tels que call VirtualAllocEx, car ils ont confiance en la WinAPI.

Voici un schéma qui résume la WinAPI :
Image
Ici, nous prenons comme exemple la fonction VirtualAlloc qui provient de la librairie Kernel32.dll, cette fonction fini par pointer sur la fonction NtAllocateVirtualMemory qui provient de la librairie Ntdll.dll, cette fonction contient comme seules instructions :

mov eax, 0x0018
syscall

 
A partir de l'instruction syscall, la structure SSDT est parcouru à l'emplacement 0x0018 afin de récupérer un pointeur sur NtAllocateVirtualMemory qui provient cette fois-ci de NtOsKrnl.exe qui est tout simplement le noyau du système.
Dans un contexte de hooking, nous aurons plutôt la situation suivante :
Image
Juste après l'appel de la fonction VirtualAlloc se situera une instruction jmp permettant de sauter dans la fonction HookedVirtualAlloc contenant notre code. A la fin de ce code il y aura un pointeur vers la véritable fonction VirtualAlloc afin de compléter la demande initiale, il s'agit là de la technique du trampoline.


Isolation des processus

Nous ne pouvons pas simplement modifier une fonction de la WinAPI une fois pour l'appliquer à tous les processus du système. L'opération doit être répétée pour chaque processus que l'on souhaite hooker. Cette contrainte est due à l'isolation des processus, qui garantit que chaque processus dispose de son propre espace mémoire et empêche le partage de données. Cependant, il est tout de même possible qu'un processus puisse partager une partie de sa mémoire avec un autre.

Cela peut prêter à confusion, car l'adresse mémoire d'une librairie en mémoire est identique pour tous les processus du système utilisant la même architecture (x86 ou x64). Cependant, chaque processus dispose de sa propre copie de la librairie lors de sa création.

Par exemple dans la capture ci-dessous nous pouvons apercevoir qu'il existe plusieurs instance de kernel32 alors que pourtant ils pointent tous sur la même adresse :
Image
Bien que chaque processus pointe sur la même adresse virtuelle, ils pointent en réalité sur des adresses physiques différentes.

Pour mieux illustrer ce principe, voici un schéma représentant deux processus utilisant la même fonction d'une même bibliothèque, avec l'un des deux processus ayant subi un hook :
Image


Solution choisie

Pour notre solution nous avons les contraintes suivantes :

 
Pour répondre à ces contraintes, nous avons décidé de développer un driver qui sera notifié de la création de nouveaux processus en utilisant le callback PsSetCreateProcessNotifyRoutine. Ce driver transmettra les informations concernant le nouveau processus à deux programmes injecteurs distincts : l'un conçu pour les processus 32-bit et l'autre pour les processus 64-bit.

Ces deux programmes auront pour unique but d'injecter leur DLL respective dans le processus nouvellement créé. Les DLLs injectées contiendront le code nécessaire pour réaliser le hooking de la fonction, l'exécution de la fonction demandée par le programme d'origine, ainsi que le hooking de l'adresse de retour.

Voici un schéma qui nous permettra une meilleure compréhension de la solution :
Image
Ainsi qu'un diagramme de séquence nous permettant de mieux visualiser les étapes dans le temps :
Image
Dans un premier temps, le driver sera installé en créant un service de type noyau. Une fois la phase d'initialisation terminée, le driver restera en écoute pour détecter la création de nouveaux processus grâce au callback PsSetCreateProcessNotifyRoutine et recevra également des requêtes provenant de nos injecteurs situés dans l'espace utilisateur.

Lorsqu'une requête est initiée par les injecteurs, le driver renvoie une structure contenant le PID et le nom du processus à l'injecteur en 64 -bits, et uniquement le PID pour l'injecteur en 32-bit. Une fois que les injecteurs reçoivent le PID du processus à traiter, ils commencent la phase d'injection sur le processus ciblé.

Les programmes développés sont les suivants :


KernelSpace - Poucave.sys

Dans cette partie, nous allons détailler chaque section du code du driver. Pour rappel, ce programme a pour objectif de transmettre rapidement, en espace utilisateur, l'information concernant la création d'un nouveau processus au programme Injector32.exe pour les processus de 32-bits et au programme Injector64.exe pour les processus de 64 -bits.
Le driver déclare deux clés IoControlCode afin de pouvoir communiquer avec les injecteurs :

#define IOCTL_GET_DATA_32 CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)
#define IOCTL_GET_DATA_64 CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS)

 
On déclare également :

struct MyProcessInfo {
	HANDLE PID;
	WCHAR processName[256];
};
struct MyProcessInfo processInfo;

UNICODE_STRING devName = RTL_CONSTANT_STRING(L"\\Device\\AgentDriver");
UNICODE_STRING symLink = RTL_CONSTANT_STRING(L"\\??\\AgentDriverLnk");
KSPIN_LOCK processInfoLock;

BOOLEAN IsProcess32Bit(HANDLE PID, NTSTATUS *status) {
	PEPROCESS Process;
	*status = PsLookupProcessByProcessId(PID, &Process);

	PVOID Wow64Process = *(PVOID*)((PUCHAR)Process + 0x580); 
	ObDereferenceObject(Process);
	if (Wow64Process != NULL) {
		return TRUE;
	} else {
		return FALSE;
	}
}

Objets et lien symbolique

Dans ce programme, nous allons créer un objet qui sera associé à notre driver, mais cet objet ne sera accessible que depuis l'espace kernel. Par conséquent, pour permettre à nos injecteurs de l'espace utilisateur de manipuler cet objet, nous devons créer un lien symbolique. Ce lien symbolique servira de point d'accès permettant aux injecteurs de communiquer avec le driver depuis l'espace utilisateur.

IoCreateDevice(DriverObject, 0, &devName, FILE_DEVICE_UNKNOWN, 0, FALSE, &DeviceObject);
IoCreateSymbolicLink(&symLink, &devName);
[/p]

Callback

Le kernel possède aussi une API contenant une liste de fonction exportée. Vous trouverez ici une liste des callbacks.
Parmi tous ces callbacks, nous allons utiliser PsSetCreateProcessNotifyRoutine :

NTSTATUS PsSetCreateProcessNotifyRoutine(
  [in] PCREATE_PROCESS_NOTIFY_ROUTINE NotifyRoutine,
  [in] BOOLEAN                        Remove
);

// Ci-dessous la structure de la fonction attendu pour l'argument NotifyRoutine
PCREATE_PROCESS_NOTIFY_ROUTINE PcreateProcessNotifyRoutine;
void PcreateProcessNotifyRoutine(
  [in] HANDLE ParentId,
  [in] HANDLE ProcessId,
  [in] BOOLEAN Create
)
{...}

 
Cette fonction nous permettra d'être notifiés lorsqu'un nouveau processus sera créé.
Voici comment elle est initialisée dans notre programme :

PsSetCreateProcessNotifyRoutine(sCreateProcessNotifyRoutine, FALSE);

 
Voici l'implémentation de la fonction sCreateProcessNotifyRoutine :

void sCreateProcessNotifyRoutine(HANDLE ppid, HANDLE pid, BOOLEAN create) {
	UNREFERENCED_PARAMETER(ppid);
	if (create) {
		KIRQL oldIrql;
		PEPROCESS process = NULL;
		UNICODE_STRING* processImageName = NULL;

		KeAcquireSpinLock(&processInfoLock, &oldIrql);
		RtlZeroMemory(&processInfo, sizeof(processInfo));
		processInfo.PID = pid;

		if (NT_SUCCESS(PsLookupProcessByProcessId(processInfo.PID, &process))) {
			if (NT_SUCCESS(SeLocateProcessImageName(process, &processImageName)) && processImageName != NULL) {
				size_t length = min(processImageName->Length / sizeof(WCHAR), 255);
				wcsncpy_s(processInfo.processName, sizeof(processInfo.processName) / sizeof(WCHAR), processImageName->Buffer, length);
				processInfo.processName[length] = L'\0';
			}
			ObDereferenceObject(process);
		}
		KeReleaseSpinLock(&processInfoLock, oldIrql);
	}
}

Ici, nous utilisons la fonction PsLookupProcessByProcessId pour récupérer le PID du processus et SeLocateProcessImageName pour obtenir le nom du processus. Le SpinLock est utilisé pour éviter toute concurrence entre la fonction sCreateProcessNotifyRoutine et la fonction DriverAgentSendData, que nous verrons juste après, car ces deux fonctions accèdent aux données contenues dans la structure MyProcessInfo. Nous voulons nous assurer que les données de cette structure ne changent pas pendant la réponse aux injecteurs.


Requêtes IRP

Les requêtes IRP sont des structures de données utilisées pour gérer les opérations d'entrée et de sortie entre le système et les périphériques matériels, ainsi qu'entre les drivers et les programmes en espace utilisateur. Dans notre cas, nous allons utiliser les requêtes IRP pour transférer les informations des processus créés vers les injecteurs.
Nos requêtes IRP sont initialisées de la sorte :

DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DriverAgentSendData;
DriverObject->MajorFunction[IRP_MJ_CREATE] = DriverAgentCreateClose;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = DriverAgentCreateClose;

 
Les types IRP_MJ_CREATE et IRP_MJ_CLOSE pointent tous les deux sur la fonction DriverAgentCreateClose qui permet de traiter les ouvertures et fermeture d'handle initié du côté des injecteurs afin d'éviter les BSOD.
Voici un aperçu de cette fonction :

NTSTATUS DriverAgentCreateClose(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
	UNREFERENCED_PARAMETER(DeviceObject);

	Irp->IoStatus.Status = STATUS_SUCCESS;
	Irp->IoStatus.Information = 0;
	IoCompleteRequest(Irp, IO_NO_INCREMENT);
	return STATUS_SUCCESS;
}

 
Le type IRP_MJ_DEVICE_CONTROL pointe sur la fonction DriverAgentSendData qui est le coeur du programme, elle permet de distinguer les requêtes en provenance de Injector32.exe et Injector64.exe mais aussi l'architecture des processus nouvellement créés afin d'envoyer les informations au bon injecteur.
Voici un aperçu de cette fonction :

NTSTATUS DriverAgentSendData(_In_ PDEVICE_OBJECT DeviceObject, _Inout_ PIRP Irp) {
	UNREFERENCED_PARAMETER(DeviceObject);

	PIO_STACK_LOCATION pIoStackIrp = IoGetCurrentIrpStackLocation(Irp);
	NTSTATUS status = STATUS_SUCCESS;

    // Associe la variable buffer à un tampon dans l'espace utilisateur qui contiendra les données
	PVOID buffer = Irp->AssociatedIrp.SystemBuffer; 
    
	PEPROCESS Process;
	if (buffer == NULL) {
		status = STATUS_INSUFFICIENT_RESOURCES;
		Irp->IoStatus.Status = status;
		IoCompleteRequest(Irp, IO_NO_INCREMENT);
		return status;
	}

    if (processInfo.PID != 0) {
		KIRQL oldIrql;
		KeAcquireSpinLock(&processInfoLock, &oldIrql);
        struct MyProcessInfo localProcessInfo = processInfo;
		KeReleaseSpinLock(&processInfoLock, oldIrql);

		BOOLEAN is32Bit = IsProcess32Bit(localProcessInfo.PID, &status);
		if (!NT_SUCCESS(status)) {
			Irp->IoStatus.Status = status;
			IoCompleteRequest(Irp, IO_NO_INCREMENT);
			return FALSE;
		}

		if (is32Bit && pIoStackIrp->Parameters.DeviceIoControl.IoControlCode == IOCTL_GET_DATA_32) {
            // Copie des données de l'espace noyau dans l'espace utilisateur
			RtlCopyMemory(buffer, &localProcessInfo, sizeof(struct MyProcessInfo));
			Irp->IoStatus.Information = sizeof(struct MyProcessInfo);
            RtlZeroMemory(&processInfo, sizeof(processInfo));
		} 
		else if (!is32Bit && pIoStackIrp->Parameters.DeviceIoControl.IoControlCode == IOCTL_GET_DATA_64) {
            // Copie des données de l'espace noyau dans l'espace utilisateur
			RtlCopyMemory(buffer, &localProcessInfo, sizeof(struct MyProcessInfo));
			Irp->IoStatus.Information = sizeof(struct MyProcessInfo);
            RtlZeroMemory(&processInfo, sizeof(processInfo));
		}
	}

	Irp->IoStatus.Status = status;
	IoCompleteRequest(Irp, IO_NO_INCREMENT);
	return status;
}

Dans ce code, nous traitons les requêtes uniquement lorsqu'un nouveau processus est détecté. Pour différencier un nouveau processus d'un ancien, nous utilisons les données stockées dans la structure processInfo. Les données de cette structure sont libérées après leur exploitation, ce qui signifie qu'après l'appel de la fonction RtlZeroMemory, la valeur du PID est réinitialisée à 0. Cela explique la condition processInfo.PID != 0, qui vérifie que la structure contient bien les informations d'un nouveau processus.

Nous distinguons également l'origine de la requête en nous basant sur la valeur de pIoStackIrp->Parameters.DeviceIoControl.IoControlCode, qui nous indique quel injecteur a initié la requête. Enfin, les données sont transférées de l'espace noyau à l'espace utilisateur dans le tampon associé à la variable buffer.


UserSpace - Injector.exe

Cette section se compose de deux parties : le programme en version 32-bit et sa version 64-bit. Pour rappel, ces deux programmes ont pour objectif d'injecter leur DLL respective : Hook32.dll pour la version 32-bits et Hook64.dll pour la version 64 -bits. Une fois la DLL injectée, il est nécessaire de la charger dans le processus cible pour exécuter le code qu'elle contient.

Dans un premier temps, il faudra déclarer la structure qui contiendra les informations du processus ainsi que la clé IoControlCode.

Pour le code Injector32.exe :

#define IOCTL_GET_DATA_32 CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)

struct MyProcessInfo {
    HANDLE PID;
    WCHAR processName[256];
};

const char* MyDLL32 = "Hook32.dll";

 
Pour le code Injector64.exe :

#define IOCTL_GET_DATA_64 CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS)

struct MyProcessInfo {
    HANDLE PID;
    WCHAR processName[256];
};

const char* MyDLL64 = "Hook64.dll";

Communication avec le kernel

Un handle est placé sur le lien symbolique associé au driver Poucave.sys :

HANDLE hDevice = CreateFile(L"\\\\.\\AgentDriverLnk", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL);

 
Nos requêtes sont effectuées à l'aide de la fonction DeviceIoControl :

DeviceIoControl(hDevice, IOCTL_GET_DATA_32, NULL, 0, &processInfo, sizeof(processInfo), &bytesReturned, NULL)

 
L'injection de la DLL dans le processus cible s'opère dans la fonction WhatTheHook32 :

if (isProcess32(hCurrentProc, &isWow64)) { // Nouveau check sur l'architecture
    WhatTheHook32(hCurrentProc); // Il s'agira de WhatTheHook64() pour Injector64.exe
}

Injection du hook

Pour l'injection et l'exécution de la DLL dans le processus cible, nous devons utiliser une série de fonction de la WinAPI qui sont :


Voici un aperçu de la fonction :

int WhatTheHook32(HANDLE hCurrentProc) {
    LPVOID pRemoteAddr = VirtualAllocEx(hCurrentProc, NULL, strlen(MyDLL32) + 1, (MEM_COMMIT | MEM_RESERVE),PAGE_READWRITE);

    WriteProcessMemory(hCurrentProc, pRemoteAddr, (LPCVOID)MyDLL32, strlen(MyDLL32) + 1, NULL);

    HANDLE hRemoteThread = CreateRemoteThread(hCurrentProc, NULL, 0, (LPTHREAD_START_ROUTINE)LoadLibraryA, pRemoteAddr, 0, NULL);

    WaitForSingleObject(hRemoteThread, INFINITE);
    VirtualFreeEx(hCurrentProc, pRemoteAddr, 0, MEM_RELEASE);

    CloseHandle(hRemoteThread);
    return 0;
}

UserSpace - Hook.dll

Pour les premiers essais j'ai choisi de hooker la fonction VirtualAllocEx et d'exécuter la fonction NtSuspendProcess pour mettre en pause le processus. Mettre en pause le processus avant et après l'appel d'une fonction hooké semble assez pertinent.

Concernant la stratégie de hook, nous allons tout d'abord nous occuper de VirtualAllocEx dans la fonction InstallMyHook puis nous nous occuperons du hook de l'adresse de retour dans la fonction HookedVirtualAllocEx.

Dans un premier temps, nous devons déclarer nos type de pointeur de fonction pour VirtualAllocEx et NtSuspendProcess. Ainsi que certaines variables qui nous seront utile durant la phase de hook :

typedef LPVOID(WINAPI* VirtualAllocExType)(
    HANDLE hProcess,
    LPVOID lpAddress,
    SIZE_T dwSize,
    DWORD flAllocationType,
    DWORD flProtect
    );

typedef LONG(NTAPI* NtSuspendProcessType)(
    IN HANDLE ProcessHandle
    );

VirtualAllocExType TrueVirtualAllocEx = NULL; // Contiendra un pointeur sur le véritable VirtualAllocEx à hook
BYTE originalBytes[5];   // Contiendra les 5 premiers octets de VirtualAllocEx
BYTE originalRetAddr[5]; // Contiendra les 5 premiers octets du code à partir de l'adresse de retour
void* returnAddress;     // Contiendra un pointeur sur l'adresse de retour

 
Ce programme comporte deux fonctions importantes qui sont utilisées au moment du hook et de la restauration, il s'agit des fonctions PlaceHook et RestoreOriginalFunction.
Voici un aperçu de la fonction PlaceHook :

void PlaceHook(BYTE* pOrigFunc, BYTE* origBytes, DWORD_PTR pHookedFunc) {
    DWORD oldProtect;
    if (!VirtualProtect(pOrigFunc, pageSize, PAGE_EXECUTE_READWRITE, &oldProtect)) {
        printf("[-] VirtualProtect failed to change protection: %d\n", GetLastError());
        return;
    }

    memcpy(origBytes, pOrigFunc, 5);

    DWORD offset = (pHookedFunc - (DWORD_PTR)pOrigFunc) - 5;
    BYTE jmp[5] = { 0xE9, 0, 0, 0, 0 };
    *(DWORD*)((BYTE*)jmp + 1) = (DWORD)offset;

    memcpy(pOrigFunc, jmp, 5);

    if (!VirtualProtect(pOrigFunc, pageSize, oldProtect, &oldProtect)) {
        printf("[-] VirtualProtect failed to restore protection: %d\n", GetLastError());
    }
}

PlaceHook est relativement simple, elle permet de patcher la fonction cible en commençant par sauvegarder les 5 premiers octets de celle-ci dans la variable origBytes, faisant référence à originalBytes, car ces octets vont être remplacés. Ensuite, il y a la construction d'un saut relatif en calculant la distance entre l'adresse de la fonction à patcher (VirtualAllocEx) et l'adresse de la fonction de remplacement (HookedVirtualAllocEx). Cette distance est ensuite ajoutée à l'instruction 0xE9 qui représente l'instrution jmp, ainsi nous aurons :

jmp <distance entre VirtualAllocEx et HookedVirtualAllocEx>

Une fois notre nouvelle instruction établie (contenue dans 5 octets) nous l'utilisons pour patch VirtualAllocEx avec la fonction memcpy.

Quant à la fonction RestoreOriginalFunction, elle est beaucoup plus simple car elle se charge simplement de restaurer les octets d'origine de la fonction qui a subit un hook :

void RestoreOriginalFunction(BYTE* pOrigFunc, BYTE* origBytes) {
    DWORD oldProtect;
    if (VirtualProtect(pOrigFunc, pageSize, PAGE_EXECUTE_READWRITE, &oldProtect)) {
        memcpy(pOrigFunc, origBytes, 5);
        VirtualProtect(pOrigFunc, pageSize, oldProtect, &oldProtect);
    }
    else {
        printf("[-] VirtualProtect failed: %d\n", GetLastError());
    }
}

Patch de VirtualAllocEx

La fonction InstallMyHook lancé au moment du chargement de la DLL permet d'installer un hook sur VirtualAllocEx à l'aide de la fonction PlaceHook. Pour rappel, cette fonction permettra de remplacer les 5 premiers octets de la fonction à hook juste après les avoir sauvegardés dans la variable originalBytes.
Ici, la fonction de remplacement sera HookedVirtualAllocEx :

void InstallMyHook() {
    getPageSize();
    TrueVirtualAllocEx = (VirtualAllocExType)GetProcAddress(GetModuleHandle(TEXT("kernel32.dll")), "VirtualAllocEx");
    if (!TrueVirtualAllocEx) {
        printf("[-] Failed to get address of VirtualAllocEx\n");
        return;
    }
    PlaceHook((BYTE*)TrueVirtualAllocEx, (BYTE*)originalBytes, (DWORD_PTR)HookedVirtualAllocEx);
}

 
Observation du hook pour la version 32-bit :
Image


Patch de l'adresse de retour

Pour rappel, la fonction HookedVirtualAllocEx a 4 objectifs qui sont les suivants :

LPVOID WINAPI HookedVirtualAllocEx(
    HANDLE hProcess,
    LPVOID lpAddress,
    SIZE_T dwSize,
    DWORD flAllocationType,
    DWORD flProtect
) {
    HANDLE hCurrentProc = GetCurrentProcess();
    suspend(hCurrentProc);
    RestoreOriginalFunction((BYTE*)TrueVirtualAllocEx, (BYTE*)originalBytes);

    void* retAddr = _ReturnAddress();
    returnAddress = retAddr;
    PlaceHook((BYTE*)retAddr, (BYTE*)originalRetAddr, (DWORD_PTR)retHooked);

    CloseHandle(hCurrentProc);
    LPVOID result = TrueVirtualAllocEx(hProcess, lpAddress, dwSize, flAllocationType, flProtect);
    return result;
}

 
Dans la fonction suspend, on trouve simplement un appel à la fonction NtSuspendProcess pour mettre en pause le processus. La restauration de VirtualAllocEx est effectuée par l'appel de la fonction RestoreOriginalFunction.

L'adresse de retour, contenue dans _ReturnAddress(), permet de revenir à cet endroit :

.text:00E8105B call    ds:GetCurrentProcess
.text:00E81061 mov     esi, ds:VirtualAllocEx
.text:00E81067 push    eax                     
.text:00E81068 call    esi ; VirtualAllocEx      
.text:00E8106A push    offset Format           ; <--- ReturnAddress

Cette adresse de retour est placé dans returnAddress et retAddr qui fera l'objet d'un hook avant d'appeler la fonction VirtualAllocEx pour satisfaire la demande initiale.
Ce hook modifiera le programme à partir de l'adresse 0x00E8106A afin de remplacer les premiers octets par un saut dans la fonction retHooked :

void __declspec(naked) retHooked() {
    __asm {
        pushad                  // Sauvegarde les 8 registres dans la pile (EAX, EBX ...)
        pushfd                  // Sauvegarde les drapeau des indicateurs d'état dans la pile
        call handleRetHooked    // Cette fonction contient l'exécution de NtSuspendProcess
        popfd                   // Restore les drapeau des indicateurs d'état
        popad                   // Restore les 8 registres dans la pile
        jmp returnAddress       // Saut à l'adresse 0x00E8106A (flux du programme original)
    }
}

Après l'appel de la fonction handleRetHooked, qui contient un simple NtSuspendProcess, un saut est effectué pour reprendre le flux d'exécution du programme d'origine.

Observation du hook de l'adresse de retour pour la version 32-bit :
Image
Pour le programme en 64-bit, il y a quelques modifications. Les fonctions PlaceHookLongJmp et RestoreOriginalFunctionLongJmp sont ajoutées, elles fonctionnent de manière similaire à PlaceHook et RestoreOriginalFunction, à la différence près que le nombre d'octets patchés est de 12 au lieu de 5. Ici, on applique un saut absolu, au lieu d'un saut relatif :

memcpy(origBytes, pOrigFunc, 12);

BYTE jump[12];
jump[0] = 0x48;  // Opcode REX.W pour obliger l'utilisation de RDX au lieu d'EDX
jump[1] = 0xBA;  // Opcode pour MOV RDX, imm64
*((void**)(jump + 2)) = pHookedFunc;  // Chargement de l'adresse 64 bits de retHooked dans RDX
jump[10] = 0xFF;  // Opcode pour JMP RDX
jump[11] = 0xE2;  // Opcode pour JMP RDX

memcpy(pOrigFunc, jump, 12);

Ajouter le préfixe REX.W est obligatoire pour forcer l'utilisation du registre RDX, sans ça le registre EDX est utilisé à la place.

Observation sans préfixe puis avec préfixe pour la version 64-bit :
Image


Détections

Après notre analyse détaillée de l'API hooking (type inline hook), il est pertinent d'examiner comment ce mécanisme peut être détecté. Comme il s'agit d'un inline hook et non d'un IAT hook, il n'est pas possible de simplement identifier le hook via la table des exports en se basant sur les adresses. Par exemple, comparer la table des exports d'un kernel32.dll modifié avec celle d'un kernel32.dll non modifié ne révélera aucune différence. Cependant, en analysant les octets de chaque fonction exportée, nous pouvons vérifier si elle commence par l'octet 0xe9, qui indique un saut relatif, ou par une séquence de valeurs nous permettant d'identifier ce type d'instruction :

mov REG, 0x12345678
jmp REG

 
Dans le cadre d'une analyse forensic, la même approche peut être appliquée avec deux méthodes : l'analyse live et l'analyse mémoire.

Pour une analyse live, nous pouvons examiner la liste des modules actuellement chargés en mémoire par le processus et rechercher tout module qui se distingue des autres (par son chemin, nom, etc.). Il est également utile de vérifier si ce module est chargé dans d'autres processus pour déterminer s'il infecte l'ensemble des processus du système.

Pour une analyse mémoire, nous avons choisi d'utiliser MemProcFs sur un dump de mémoire généré avec FTK Imager. MemProcFs est un outil très puissant qui permet de parser divers artefacts, ce qui nous permet de gagner du temps pour récupérer la liste des processus actifs, ainsi que leurs modules chargés. Cette liste comprend tous les dossiers de modules avec leurs informations, y compris les DLLs reconstituées à partir des éléments extraits de la mémoire sous le nom pefile.dll.

À ce stade, nous pourrions simplement ouvrir la DLL dans un désassembleur et chercher une fonction exportée qui aurait été modifiée par un hook, mais cette tâche peut être assez laborieuse. Pour accélérer ce processus, nous avons donc créé un script Python afin de gagner en efficacité :

import pefile

def isNearJmp(first_bytes):
    if first_bytes[:1] == b'\xe9':
        return True, int.from_bytes(first_bytes[1:5], "little", signed="True"), None
    else:
        return False, None, None
    
def isAbsoluteJmp(first_bytes):
    instructions32 = {
        b'\xb8':'mov eax',
        b'\xbb':'mov ebx',
        b'\xb9':'mov ecx',
        b'\xba':'mov edx',
    }
    
    instructions64 = {
        b'\x48\xb8':'mov rax',
        b'\x48\xbb':'mov rbx',
        b'\x48\xb9':'mov rcx',
        b'\x48\xba':'mov rdx',
    }
    
    jmp32 = {
       b'\xff\xe0':'jmp eax',
       b'\xff\xe3':'jmp ebx',
       b'\xff\xe1':'jmp ecx',
       b'\xff\xe2':'jmp edx'
    }
    
    jmp64 = {
       b'\xff\xe0':'jmp rax',
       b'\xff\xe3':'jmp rbx',
       b'\xff\xe1':'jmp rcx',
       b'\xff\xe2':'jmp rdx'
    }
    
    # 32-bit mode
    if ( instructions32.get(first_bytes[:1]) ) and ( jmp32.get(first_bytes[5:7]) ):
        return True, int.from_bytes(first_bytes[1:5], "little", signed="True"), f'{instructions32[first_bytes[:1]]}::{jmp32[first_bytes[5:7]]}', True
    # 64-bit mode
    elif ( instructions64.get(first_bytes[:2]) ) and ( jmp64.get(first_bytes[9:11]) ):
        return True, int.from_bytes(first_bytes[2:9], "little", signed="True"), f'{instructions32[first_bytes[:2]]}::{jmp32[first_bytes[9:11]]}', False
    else:
        return False, None, None, None

def isAddressValid(pe, hookFuncAddr, base_address, func_name):
    address = base_address + hookFuncAddr
    for section in pe.sections:
        section_start = base_address + section.VirtualAddress
        section_end = base_address + section_start + section.Misc_VirtualSize
        if section_start <= address < section_end:
            return True 
    return False

def process(cDLL, num_bytes):
    pe = pefile.PE(cDLL)
    if not hasattr(pe, 'DIRECTORY_ENTRY_EXPORT'):
        print(f'{cDLL} contains no export table.')
        return
    
    base_address = pe.OPTIONAL_HEADER.ImageBase
    exports = pe.DIRECTORY_ENTRY_EXPORT.symbols
    mask = 0
    for exp in exports:
        if exp.name:
            func_name = exp.name.decode()
            file_offset = pe.get_offset_from_rva(exp.address)
            with open(cDLL, 'rb') as dll_file:
                dll_file.seek(file_offset)
                first_bytes = dll_file.read(num_bytes)
                
            isNear, hookFuncAddr, __ = isNearJmp(first_bytes)
            isAbsolute, hookFuncAddrAbs, instructions, isWow64 = isAbsoluteJmp(first_bytes)
            if isWow64:
                mask = 0xffffffff
            else:
                mask = 0xffffffffffffffff
                
            if isNear:
                if not isAddressValid(pe, file_offset+hookFuncAddr, base_address, func_name):
                    print(f'[+] {func_name} :\n\tjmp {hex(hookFuncAddr & 0xffffffff)}\n')
            elif isAbsolute:
                if not isAddressValid(pe, file_offset+hookFuncAddrAbs, base_address, func_name):
                    instruction1, instruction2 = instructions.split('::')
                    print(f'[+] {func_name} :\n\t{instruction1}, {hex(hookFuncAddrAbs & mask)}\n\t{instruction2}\n')          

if __name__ == '__main__':
    cDLL = 'pefile.dll'
    num_bytes = 12
    process(cDLL, num_bytes)

 
Nous avons défini la fonction isAddressValid pour éviter autant que possible les faux positifs car il est possible de trouver un saut relatif légitime dans une fonction exportée, mais ce saut doit être réalisé dans une adresse valide. Si ce n'est pas le cas, ca veut donc dire que le saut est fait à destination d'un autre module, c'est pourquoi nous cherchons à savoir si l'adresse est comprise dans la plage d'une section.

Résultats observés:
Image
Pour tester toutes les fonctions de tous les modules chargés dans chaque processus capturé, nous pouvons ajuster le script et l'exécuter en ciblant le point de montage de MemProcFs. Voici les éléments ajoutés au script :

def checkHookInFunc(cDLL, num_bytes, cProcess, cModule):
    countHook = 0
    try:
        pe = pefile.PE(cDLL)
    except pefile.PEFormatError:
        return countHook
    if not hasattr(pe, 'DIRECTORY_ENTRY_EXPORT'):
        return countHook
    
    base_address = pe.OPTIONAL_HEADER.ImageBase
    exports = pe.DIRECTORY_ENTRY_EXPORT.symbols
    mask = 0
    for exp in exports:
        if exp.name:
            func_name = exp.name.decode()
            if func_name in ['GetFileBandwidthReservation', '_mbscpy_s', '_spawnve', '_wexeclpe', 'NtUserAllowForegroundActivation', 'NtUserEnablePerMonitorMenuScaling', 'NtUserIsQueueAttached', 'NtUserYieldTask', 'CPNameUtil_ConvertToRoot', 'g_module_open_utf8']: # trop de faux positif, sonne pour chaque processus
                continue
            
            try:
                file_offset = pe.get_offset_from_rva(exp.address)
            except pefile.PEFormatError:
                continue
            
            with open(cDLL, 'rb') as dll_file:
                dll_file.seek(file_offset)
                first_bytes = dll_file.read(num_bytes)
                
            isNear, hookFuncAddr, __ = isNearJmp(first_bytes)
            isAbsolute, hookFuncAddrAbs, instructions, isWow64 = isAbsoluteJmp(first_bytes)
            if isWow64:
                mask = 0xffffffff
            else:
                mask = 0xffffffffffffffff
                
            if isNear:
                if not isAddressValid(pe, file_offset+hookFuncAddr, base_address, func_name):
                    countHook += 1
                    print(f'[+] {cProcess}::{cModule}::{func_name} :\n\tjmp {hex(hookFuncAddr & 0xffffffff)}\n')
            elif isAbsolute:
                if not isAddressValid(pe, file_offset+hookFuncAddrAbs, base_address, func_name):
                    countHook += 1
                    instruction1, instruction2 = instructions.split('::')
                    print(f'[+] {cProcess}::{cModule}::{func_name}  :\n\t{instruction1}, {hex(hookFuncAddrAbs & mask)}\n\t{instruction2}\n')
                    
    return countHook
        
        
def process(MemProcFsPath, num_bytes):
    MemProcFsPath += '\\name\\'
    ProcessList = os.listdir(MemProcFsPath)
    for cProcess in ProcessList:
        if cProcess in ['System-4']:
            continue
        ModulesList = os.listdir(MemProcFsPath + cProcess + '\\modules\\')
        totalCountHook = 0
        for cModule in ModulesList:
            if ".dll" not in cModule.lower():
                continue
            cDLL = MemProcFsPath + cProcess + '\\modules\\' + cModule + '\\pefile.dll'
            countHook = checkHookInFunc(cDLL, num_bytes, cProcess, cModule)
            totalCountHook += countHook
        if totalCountHook == 0:
            print(f'No hook found on {cProcess.split("-")[0]}::{cProcess.split("-")[1]} process')
        else:
            print(f'number of hook -> {totalCountHook}')


if __name__ == '__main__':
    MemProcFsPath = sys.argv[1] if len(sys.argv) > 1 else 'M:'
    num_bytes = 12
    process(MemProcFsPath, num_bytes)

 
Résultats observés :
Image


Conclusion

Dans cette étude, nous avons exploré la technique de l'API hooking, une méthode efficace pour intercepter et contrôler les appels de fonctions au sein du système. En adoptant une solution hybride combinant un driver en espace noyau et des injecteurs en espace utilisateur, nous avons démontré comment surveiller et manipuler l'exécution des processus de manière discrète, sans altérer leur comportement normal.

Cette technique est utilisée aussi bien par des solutions de sécurité, comme les EDR, que par des logiciels malveillants cherchant à se dissimuler, ce qui souligne l'importance de savoir la détecter. Grâce à des outils comme MemProcFs pour analyser les captures de mémoire, ou encore un débugger et des outils comme Process Hacker pour identifier des modules chargés, il devient possible de repérer les signes d'une telle manipulation. Cette recherche met en lumière la nécessité d'être vigilant lors des investigations, où chaque fonction peut potentiellement être la cible d'un hook.