
Technique d'exécution/injection de code utilisant la manipulation de la structure du module PEB des DLL
Exécution furtive de code via la modification du EntryPoint des modules chargés à l'exécution.
Les processus Windows ont divers modules chargés à l'exécution. Chacun de ces modules possède une fonction DllMain() définie, qui sera appelée lors de la création/destruction d'un processus ou d'un thread (quatre scénarios possibles).
Afin d'appeler correctement ces fonctions pendant la durée de vie du processus, les fonctions du chargeur Windows (ntdll!Ldrp*) se réfèrent à une liste d'entrées contenant des paramètres clés (dont le champ EntryPoint) pour chaque module.
En écrasant ce EntryPoint pour une DLL, nous garantissons que l'exécution du code sera redirigée vers un endroit de notre choix.
Cela peut être utilisé à la fois comme une primitive d'exécution de code, et pour du proxy d'API, par exemple pour exécuter certaines API avec une pile d'appels non suspecte puisqu'elles seront invoquées par des fonctions Windows légitimes.
Cela peut également être utilisé pour déclencher l'exécution dans un processus distant, à condition que l'attaquant ait la capacité de lire et d'écrire la mémoire sur ce processus cible. De manière similaire à Threadless Injection, cela offre la capacité d'exécuter du code dans un processus sans invoquer les API classiques liées à l'exécution (CreateRemoteThread, QueueUserAPC).
Le chargement/déchargement des modules au sein d'un processus Windows est un sujet complexe qui présente de nombreux défis, des risques d'instabilité, de courses critiques et de plantages. Un obstacle bien connu lié à l'exécution de code dans une fonction DllMain() par exemple, réside dans le fait qu'un verrou du chargeur (Loader Lock) est en place et que nous tournons dans un thread qui n'a pas été complètement initialisé, ou qui est en cours de terminaison.
Par conséquent, j'ai essayé de documenter correctement ce qui est possible et ce qui ne l'est pas. Par exemple, bien que la plupart des appels API habituels puissent être effectués, exécuter un beacon complet nécessite certaines contraintes pour être dans un processus séparé, afin d'éviter les interblocages causés par les fonctions utilisées dans wininet.dll ou winhttp.dll.
Chaque processus maintient une liste de structures _LDR_DATA_TABLE_ENTRY à l'exécution. Ces structures contiennent de nombreux détails pertinents sur la DLL, tels que son EntryPoint (que nous allons écraser), son nom, certains hachages, des horodatages, divers indicateurs, etc. Certaines de ces structures sont documentées, d'autres non.
On peut les visualiser via cette commande WinDbg :
dt nt!_LDR_DATA_TABLE_ENTRY 0xdeadbeef
0:006> dt nt!_LDR_DATA_TABLE_ENTRY 0x18d1c4838c0
ntdll!_LDR_DATA_TABLE_ENTRY
+0x000 InLoadOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485de0 - 0x0000018d`1c4832b0 ]
+0x010 InMemoryOrderLinks : _LIST_ENTRY [ 0x0000018d`1c485df0 - 0x0000018d`1c4832c0 ]
+0x020 InInitializationOrderLinks : _LIST_ENTRY [ 0x0000018d`1c4832d0 - 0x0000018d`1c482c80 ]
+0x030 DllBase : 0x00007ffe`e87e0000 Void
+0x038 EntryPoint : 0x00007ffe`e8838d00 Void
+0x040 SizeOfImage : 0x2fe000
+0x048 FullDllName : _UNICODE_STRING "C:\WINDOWS\System32\KERNELBASE.dll"
+0x058 BaseDllName : _UNICODE_STRING "KERNELBASE.dll"
+0x068 FlagGroup : [4] "???"
+0x068 Flags : 0x8a2cc
+0x068 PackagedBinary : 0y0
+0x068 MarkedForRemoval : 0y0
+0x068 ImageDll : 0y1
(...)
+0x0e0 MappingInfoIndexNode : _RTL_BALANCED_NODE
+0x0f8 OriginalBase : 0x00007ffe`e87e0000
+0x100 LoadTime : _LARGE_INTEGER 0x01db5f86`2fa735fc
+0x108 BaseNameHashValue : 0x235bec4
+0x10c LoadReason : 0 ( LoadReasonStaticDependency )
+0x110 ImplicitPathOptions : 0x4000
+0x114 ReferenceCount : 1
+0x118 DependentLoadFlags : 0x800
+0x11c SigningLevel : 0 ''
L'adresse de la structure peut être obtenue en parcourant une structure doublement chaînée référencée dans le PEB du processus, dans une structure PEB_LDR_DATA.
dt nt!_PEB_LDR_DATA 0xb4b4c3c3
Notez le drapeau DontCallForThreads. Comme son nom l'indique, si ce drapeau est défini, le système d'exploitation n'appellera PAS le DllMain() de ce module pour les événements de thread (c'est-à-dire DLL_THREAD_ATTACH ou DLL_THREAD_DETACH).
Lors de la création d'une DLL, le modèle suivant doit être suivi pour fonctionner de concert avec les fonctions du chargeur OS :
BOOL WINAPI DllMain(
HINSTANCE hinstDLL, // handle to DLL module
DWORD fdwReason, // reason for calling function
LPVOID lpvReserved ) // reserved
{
// Perform actions based on the reason for calling.
switch( fdwReason )
{
case DLL_PROCESS_ATTACH:
// Initialize once for each new process.
// Return FALSE to fail DLL load.
break;
case DLL_THREAD_ATTACH:
// Do thread-specific initialization.
break;
case DLL_THREAD_DETACH:
// Do thread-specific cleanup.
break;
case DLL_PROCESS_DETACH:
if (lpvReserved != nullptr)
{
break; // do not do cleanup if process termination scenario
}
// Perform any necessary cleanup.
break;
}
return TRUE; // Successful DLL_PROCESS_ATTACH.
}
Comme décrit ci-dessus, la technique écrase temporairement le EntryPoint d'une DLL afin de rediriger l'exécution. Puisque nous n'avons aucun contrôle au-delà de la redirection de l'exécution, certains arrangements doivent être faits du côté pour gérer ce que nous voulons exécuter, avec quels arguments, et comment récupérer la valeur de retour.
Cela se fait en définissant une structure DATA_T sur le tas, de manière à ce qu'elle reste accessible tout au long des différentes étapes.
Cette structure est définie comme suit :
typedef struct _DATA_T {
// LDR structures manipulation
ULONG_PTR runner; // malicious entry point to execute
ULONG_PTR bakOriginalBase; // backup of overwritten OriginalBase
ULONG_PTR bakEntryPoint; // backup of overwritten EntryPoint
HANDLE event; // event signalling that the Runner has executed
// function call
ULONG_PTR ret; // return value
DWORD createThread; // run this API call in a new thread (required for wininet/winhttp)
ULONG_PTR function; // Windows API to call
DWORD dwArgs; // number of args
ULONG_PTR args[MAX_ARGS]; // array of args
} DATA_T, * PDATA_T;
Pour configurer une exécution d'API, ces champs doivent être préparés. La valeur ret est celle qui récupérera la valeur de retour après l'exécution. L'event est utilisé pour la synchronisation, afin de signaler que l'exécution est terminée. Tous les autres champs sont des entrées définissant quelle API appeler (function), avec quels arguments (dwArgs et args[]), l'adresse de la fonction Runner() où l'exécution est redirigée, et les sauvegardes des entrées originales de la DLL écrasée (bakOriginalBase et bakEntryPoint).
Le champ createThread doit être défini à 1 pour les fonctions API complexes qui ne fonctionnent pas bien dans une configuration DllMain() (cela inclut de nombreuses bibliothèques wininet et winhttp).
Voici un exemple de configuration d'un appel à MessageBoxA() comme visible dans le PoC :
pDataT->dwArgs = 4;
pDataT->runner = (ULONG_PTR)Runner;
pDataT->function = (ULONG_PTR)MessageBoxA;
pDataT->args[0] = (ULONG_PTR)0;
pDataT->args[1] = (ULONG_PTR)"Hello";
pDataT->args[2] = (ULONG_PTR)"LDRSHUFFLE";
pDataT->args[3] = (ULONG_PTR)MB_OKCANCEL;
pDataT->event = CreateEventA(NULL, FALSE, FALSE, "ExecEvt");
La fonction UpdateLdr() est responsable de l'exécution de la modification appropriée dans le _LDR_DATA_TABLE_ENTRY du module cible.
RestoreLdr() restaurera ces modifications à un stade ultérieur (invoquée par Runner()).
Ces fonctions localisent essentiellement le PEB et parcourent les structures de modules pour identifier la bonne DLL et ses champs. Dans les fichiers d'en-tête, je réutilise les définitions utilisées par Batsec dans son DarkLoadLibrary et j'encourage les lecteurs à consulter ce projet et le billet de blog MDSec associé pour bénéficier de l'excellent travail qu'il a effectué sur les mécanismes internes du chargement de modules sous Windows.
Remarque : ce PoC charge une DLL sacrificielle (SACRIFICIAL_DLL_NAME) et effectue ces modifications sur cette DLL. Cependant, il est tout à fait possible de modifier une DLL déjà chargée. C'est d'ailleurs l'approche adoptée pour l'injection inter-processus. Pour des raisons de stabilité, je recommande d'éviter de toucher aux DLL importantes comme ntdll ou kernel32, qui ont également tendance à être plus scrutées par les solutions de sécurité.
Lors de la création ou de la destruction d'un thread, l'exécution est redirigée vers Runner(), qui agit comme un faux DllMain() pour le module. Cette fonction va alors :
PDATA_T)RestoreLdr() à son état d'origineDllMain() (en faisant essentiellement un proxy de l'appel DLL normal).À ce stade, l'exécution "normale" du système d'exploitation a été effectuée. Elle continue ensuite avec nos charges utiles :
DATA_T. Si cette API a été marquée pour s'exécuter dans un nouveau thread (createThread = 1), cet appel sera effectué dans un nouveau thread.pDataT->event) pour que notre code principal sache que l'appel a été effectué.Lorsque Windows finit par invoquer notre faux EntryPoint (qui est l'adresse de la fonction Runner()), la pile d'appels ressemble à ceci :

Le PoC fourni contient un exemple invoquant MessageBoxA().
Il contient également une démonstration d'un téléchargement HTTP utilisant wininet. Définissez la variable HTTP pour activer ce code.

Les principes décrits ci-dessus se résument à lire et écrire dans l'espace mémoire du processus, afin de provoquer une exécution de code à un moment arbitraire du futur.
Avec quelques ajustements, ces opérations de lecture et d'écriture peuvent être appliquées à un processus distant pour écraser le EntryPoint de l'une de ses DLL.
Un prérequis est la capacité de lire et d'écrire dans l'espace mémoire du processus, c'est-à-dire :
OpenProcess(PROCESS_VM_READ, FALSE, dwPid) et OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION, FALSE, PID)
Un projet supplémentaire est présent dans le PoC, appelé LdrInject, démontrant comment effectuer ces étapes. En résumé, il fait ce qui suit :
dans ReadPEB(), il parcourt la liste _LDR_DATA_TABLE_ENTRY du processus cible pour identifier une DLL appropriée à écraser. Notez que cette DLL doit avoir DontCallForThreads == 0 car nous souhaitons que Windows invoque le EntryPoint de cette DLL lors de la création d'un thread. Nous ne choisissons pas non plus les premières DLL de la liste car elles ont tendance à être plus scrutées par les produits de sécurité (ntdll.dll, kernel32.dll…).
les détails de cette DLL sont stockés dans une structure de données PEBINJ_DATA.
du shellcode (dans ce cas un beacon) est écrit dans l'espace du processus distant avec InjectShellcodeToRemoteProcess()
deux appels WriteProcessMemory() écrasent le EntryPoint de la DLL et le sauvegardent dans OriginalBase afin qu'il puisse être restauré ultérieurement.
À ce stade, le prochain événement DLL_THREAD_ATTACH ou DLL_THREAD_DETACH entraînera l'invocation du shellcode. Cela comporte certaines limitations et mises en garde dans le contexte de l'exécution d'un beacon, détaillées dans la section suivante.
Cette technique entraîne l'exécution d'un shellcode dans une situation très spécifique. Le verrou du chargeur (Loader Lock) est actif (car le système d'exploitation pense être en train de charger/décharger une DLL) ; un thread est soit en cours de création, soit en cours de destruction ; et d'une manière générale, il existe un risque de problèmes de synchronisation de threads, d'interblocages, etc.
Lors des tests, deux défis ont été observés :
l'exécution d'un beacon Cobalt Strike typique entraînerait un interblocage lors de l'utilisation d'API dans wininet.dll ou winhttp.dll.
l'exécution lors de la destruction d'un thread provoque des problèmes de stabilité car nous tournons dans un thread en cours de destruction.
Pour augmenter la stabilité, nous devons :
nous assurer que le beacon s'exécutera dans un nouveau thread. Par conséquent, l'UDRL fera un CreateThread avant d'invoquer le point d'entrée habituel de la DLL réflexive de Cobalt Strike.
ne s'exécuter que dans un thread en cours de création, pas dans un thread mourant. Pour ce faire, nous nous assurons que lorsque le EntryPoint est appelé par le système d'exploitation, la raison invoquée est fdwReason == DLL_THREAD_ATTACH :
winApi.CreateThread(NULL, 4096, (LPTHREAD_START_ROUTINE)&runner, (LPVOID)&ct_data, 0, &dwThId);
au lieu de l'habituel
((DLLMAIN)entryPoint)((HINSTANCE)loaderStart, 4, NULL);
ULONG_PTR __cdecl ReflectiveLoader(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {
// only run for a Thread creation event
if (fdwReason != DLL_THREAD_ATTACH) {
return TRUE;
}
...
}
Ces deux étapes supplémentaires ont été intégrées dans une démo pour un UDRL.
VirtualAlloc
VirtualProtect
CreateThread
Sleep
MessageBoxA
InternetOpenW (doit s'exécuter avec createThread = 1)
InternetOpenUrlA (doit s'exécuter avec createThread = 1)