
Framework de développement de shellcode en mode utilisateur Windows (WUMSDF)
v1.1Le projet SILVERPICK est un Windows User-Mode Shellcode Development Framework (WUMSDF) dont l'unique objectif est de permettre aux développeurs de capacités de créer des blobs de Position Independent Code (PIC) pour Windows x64 en C/C++ de manière simple, afin de réduire les coûts de développement d'une telle entreprise.
Il dérive du projet WILDBEAST et, à ce titre, s'appuie sur :
Visual Studio Code comme éditeur de codeMinGW-w64 comme chaîne de compilationGNU Make comme système de constructionVous trouverez les instructions d'installation ici : GCC-Clang-Setup-Windows
Veuillez noter que ce projet utilise MSYS2.
Écrire du shellcode dans des langages de programmation de haut niveau n'a rien de nouveau, et d'innombrables articles de blog et documents de recherche ont été publiés sur le sujet depuis 2010. Alors, qu'y a-t-il de nouveau dans SILVERPICK ?
Eh bien, je suis ravi que vous posiez la question.
SILVERPICK cache un joli petit sac d'astuces dans sa manche, mais surtout, c'est ma vision du sujet.
Sans plus attendre, je vous présente ma première astuce.
Depuis que Matt Graeber a popularisé l'écriture de shellcode en C, la plupart des gens utilisent son stub d'alignement de pile de 16 octets écrit en langage Assembly.
Bien que cela ne soit pas un problème, étant donné que nous ne sommes pas IKEA, l'assembleur ne devrait pas être nécessaire, et il ne l'est effectivement pas.
Il existe un attribut de fonction GCC qui émet le stub d'alignement de pile pour vous.
Voici l'attribut de fonction force_align_arg_pointer sous la forme d'une macro pratique ALIGN_STACK, qui génère l'assembleur suivant :
Disassembly of section .init:
<PicEntry>:
push rbp
mov rbp,rsp
and rsp,0xfffffffffffffff0
sub rsp,0x20
call <PicEntry+0x11> IMAGE_REL_AMD64_REL32 .text$payload
leave
ret
Qu'est-ce que la section .init, demanderez-vous ? Eh bien, cela constitue une belle transition vers ma deuxième astuce.
Matt Graeber a peut-être popularisé l'écriture de shellcode en C à un moment donné, mais c'est en réalité Paul Ungur qui a ravivé cet art noir avec Stardust.
Stardust utilise un script de liaison Binutils pour contrôler le placement des fonctions et des données dans la section PE appropriée, dans le bon ordre. Cette technique est elle-même dérivée des travaux d'Austin Hudson, et de nombreuses personnes utilisent une variante de ses scripts de liaison.
Bien que les scripts de liaison soient parfaits pour ordonner les sections du lieur, ils sont inutiles si tout ce dont vous avez besoin est de placer une fonction donnée au début de la section de code.
C'est ici qu'intervient l'attribut de fonction section avec un nom de section spécial appelé .init, qui indique au lieur que la fonction contient du code d'initialisation d'exécution pré-main() et qu'elle doit être première dans l'ordre de liaison.
À cette fin, la macro CODE_BEGIN a été créée.
Pour ma troisième astuce, je vous présente la macro STACK_STRING.
En C, vous pouvez créer une chaîne de pile (une chaîne construite dynamiquement sur la pile) en déclarant la chaîne littérale comme un tableau de caractères ANSI :
char charrHelloKitty[] = { 'H', 'e', 'l', 'l', 'o', 'K', 'i', 't', 't', 'y', '\0' };
En C++, vous pouvez créer une chaîne de pile en marquant simplement un tableau char comme constexpr :
constexpr char charrHelloKitty[]{ "HelloKitty" };
Cependant, ces deux techniques deviennent inutiles face aux optimisations du compilateur si les chaînes littérales sont suffisamment grandes, contrairement à notre solution, qui fonctionnera quelle que soit la longueur de la chaîne et le niveau d'optimisation du compilateur, grâce à une astuce intelligente de métaprogrammation de templates C++ offerte par Can Bölük.
L'utilisation de cette macro est assez simple :
STACK_STRING(sstrText, "an extra long hello world!");
STACK_STRING(sstrCaption, "Demo");
MessageBoxA(nullptr, sstrText.data(), sstrCaption.data(), MB_OK);
Cela générera l'assembleur suivant :
mov [rsp+58h+var_23], 61h ; 'a'
mov [rsp+58h+var_22], 6Eh ; 'n'
mov [rsp+58h+var_21], 20h ; ' '
mov [rsp+58h+var_20], 65h ; 'e'
mov [rsp+58h+var_1F], 78h ; 'x'
mov [rsp+58h+var_1E], 74h ; 't'
mov [rsp+58h+var_1D], 72h ; 'r'
mov [rsp+58h+var_1C], 61h ; 'a'
mov [rsp+58h+var_1B], 20h ; ' '
mov [rsp+58h+var_1A], 6Ch ; 'l'
mov [rsp+58h+var_19], 6Fh ; 'o'
mov [rsp+58h+var_18], 6Eh ; 'n'
mov [rsp+58h+var_17], 67h ; 'g'
mov [rsp+58h+var_16], 20h ; ' '
mov [rsp+58h+var_15], 68h ; 'h'
mov [rsp+58h+var_14], 65h ; 'e'
mov [rsp+58h+var_13], 6Ch ; 'l'
mov [rsp+58h+var_12], 6Ch ; 'l'
mov [rsp+58h+var_11], 6Fh ; 'o'
mov [rsp+58h+var_10], 20h ; ' '
mov [rsp+58h+var_2F], 0
mov [rsp+58h+var_F], 77h ; 'w'
mov [rsp+58h+var_E], 6Fh ; 'o'
mov [rsp+58h+var_D], 72h ; 'r'
mov [rsp+58h+var_C], 6Ch ; 'l'
mov [rsp+58h+var_B], 64h ; 'd'
mov [rsp+58h+var_A], 21h ; '!'
mov [rsp+58h+var_33], 44h ; 'D'
mov [rsp+58h+var_32], 65h ; 'e'
mov [rsp+58h+var_31], 6Dh ; 'm'
mov [rsp+58h+var_30], 6Fh ; 'o'
En parlant de C++, je vous présente le hachage de chaînes à la compilation pour ma quatrième astuce.
Bien que ce ne soit pas un concept nouveau, SILVERPICK offre quelques améliorations par rapport aux implémentations publiques existantes.
Premièrement, nous utilisons la variante 64 bits de la célèbre fonction de hachage non cryptographique FNV-1a afin de réduire la probabilité d'une attaque par collision de hachage réussie.
Deuxièmement, nous utilisons un paramètre modifié pour la fonction de hachage afin de nous défendre contre les recherches de tables de hachage précalculées, comme HashDB. Point crucial, cela ne change pas les propriétés de la fonction de hachage.
Pour hacher une chaîne courte au moment de l'exécution, utilisez simplement la macro HASH_STRING_RUN_TIME.
Pour hacher une chaîne littérale courte à la compilation, utilisez simplement la macro HASH_STRING_COMPILE_TIME. L'évaluation uniquement à la compilation est garantie via consteval.
Il s'avère que l'on peut implémenter pas mal de fonctions de la C Runtime Library (CRT) à l'aide des instructions de chaîne x86. Alors, bien sûr, j'ai dû les implémenter en utilisant un mélange d'intrinsèques du compilateur et d'assembleur inline.
Vous voulez utiliser la fonction msvcrt!memset dans votre code ? Utilisez plutôt la macro ZERO_MEMORY, qui utilise l'instruction rep stosb émise via un intrinsèque du compilateur.
Et pour la fonction msvcrt!memcpy ou msvcrt!memmove, demanderez-vous ? Voici la macro COPY_MEMORY comme remplaçant, qui utilise l'instruction rep movsb émise via un intrinsèque du compilateur.
Mais qu'en est-il d'une alternative à la fonction msvcrt!memcmp ? Il s'avère qu'il n'existe pas vraiment d'intrinsèque du compilateur permettant d'émettre l'instruction repe cmpsb. Nous écrivons donc une fonction compare_memory utilisant de l'assembleur inline à la place.
Enfin, si vous cherchez un remplaçant pour la fonction msvcrt!memchr, voici la fonction scan_memory, qui utilise là encore de l'assembleur inline, car aucun intrinsèque du compilateur n'est disponible pour émettre l'instruction repne scasb.
Oh, et ai-je oublié de mentionner que vous pouvez écrire votre propre version plus sûre de la fonction msvcrt!strlen en utilisant la routine scan_memory comme ceci :
DWORD_PTR dwptrExportNameLength = std::min(BIT_CAST(DWORD_PTR, scan_memory(strExportName, 0x00, MAX_EXPORTED_SYMBOL_NAME_LEN)) - BIT_CAST(DWORD_PTR, strExportName), MAX_EXPORTED_SYMBOL_NAME_LEN);
Veuillez noter que ces implémentations ne produisent pas forcément le code le plus performant, selon la microarchitecture du CPU cible. Cependant, elles sont garanties pour faire le travail.
Intéressé par d'autres tours de passe-passe ?
Il existe beaucoup d'autres petites macros dans Common.h qui servent à abstraire certaines complexités liées au contrôle du compilateur.
Une implémentation sans dépendance de la fonction GetModuleHandle est fournie dans UserModuleBase.cpp. Pour simplifier l'utilisation, une macro pratique nommée GET_USER_MODULE_BASE a été créée.
De même, une implémentation sans dépendance de la fonction GetProcAddress est fournie dans PEParse.cpp, puis encapsulée dans une macro pratique judicieusement nommée GET_EXPORTED_SYMBOL_ADDRESS. De plus, deux autres macros sont fournies pour faciliter la liaison dynamique à l'exécution : INITIALIZE_FUNCTION_POINTER pour déclarer et initialiser un pointeur de fonction à 0, et RESOLVE_FUNCTION_POINTER pour résoudre ce pointeur de fonction.
L'intégration de Visual Studio Code est intégrée au projet afin que les développeurs puissent utiliser le raccourci clavier Ctrl+Shift+B pour un processus de compilation sans tracas.
L'intégration de GitHub Actions est également intégrée au projet pour permettre les builds CI.
Le projet tire une certaine fierté de sa structure bien organisée, ainsi que de son code abondamment commenté et relativement propre.
Enfin, jetez un œil au Makefile du projet, qui contient la sélection la plus fine d'options de compilation et d'édition de liens générant un code petit, sécurisé et respectueux de l'OPSEC. Parallèlement, une journalisation verbeuse et un fichier de carte de liaison généré offriront une visibilité sur le processus de compilation pour faciliter une compréhension plus approfondie de la chaîne d'outils. De plus, chaque unité de traduction produit également un fichier de désassemblage qui, à l'inspection, vous fera souvent dire des choses comme « le compilateur a fait quoi, là ? », etc.
Si vous êtes convaincu par ce framework, cette section décrit comment l'utiliser.
L'extrait suivant provient de PicMain.cpp :
/// @brief PIC start function
/// @param None
/// @return None
EXTERN_C NO_INLINE VOID __stdcall payload(
VOID
) {
// Init local variables
PVOID pKernel32 = nullptr;
INITIALIZE_FUNCTION_POINTER(LoadLibraryA);
HMODULE hUser32 = nullptr;
STACK_STRING(sstrUser32, "user32.dll");
INITIALIZE_FUNCTION_POINTER(MessageBoxA);
STACK_STRING(sstrText, "an extra long hello world!");
STACK_STRING(sstrCaption, "Demo");
// Get the image base address of kernel32.dll
pKernel32 = GET_USER_MODULE_BASE("kernel32.dll");
if (pKernel32 == nullptr)
goto cleanup;
// Resolve kernel32!LoadLibraryA
RESOLVE_FUNCTION_POINTER(pKernel32, LoadLibraryA);
if (LoadLibraryA == nullptr)
goto cleanup;
// Load User32.dll into the process VAS
hUser32 = LoadLibraryA(sstrUser32.data());
if (hUser32 == nullptr)
goto cleanup;
// Resolve user32!MessageBoxA
RESOLVE_FUNCTION_POINTER(hUser32, MessageBoxA);
if (MessageBoxA == nullptr)
goto cleanup;
// Display a message box
MessageBoxA(nullptr, sstrText.data(), sstrCaption.data(), MB_OK);
// Cleanup
cleanup:
return;
}
Ça a l'air assez facile, non ?
Lorsque vous écrivez du PIC en C/C++ avec le framework SILVERPICK, vous devez prendre en considération les règles suivantes :
payload comme vous le feriez avec la fonction main d'un programme traditionnel, c'est-à-dire comme le (pseudo) point d'entrée.Windows API ou Native API ne peuvent être utilisées que via la liaison dynamique à l'exécution, après avoir vérifié que le prototype de la fonction est disponible dans le fichier d'en-tête correspondant.Cette section contient une liste non exhaustive d'améliorations planifiées qui seront intégrées dans un futur projet.
Clang/LLVM.GS.Export Address Filtering (EAF).Voici une liste de références classées par ordre chronologique qui m'ont été d'une valeur inestimable au cours de mes recherches et m'ont largement servi d'inspiration pour ce projet :
Writing Shellcode with a C Compiler par Nick Harbour (2010)
Shellcode with a C-compiler par Didier Stevens (2010)
Writing Optimized Windows Shellcode in C par Matt Graeber (2013)
Shellcode the better way, or how to just use your compiler par Justin Fisher (2016)
ShellcodeStdio par Jack Ullrich (2016)
Shellcode: A Windows PIC using RSA-2048 key exchange, AES-256, SHA-3 par Odzhan (2016)
Writing Optimized Windows Shellcode par Dimitri Fourny (2017)
Writing and Compiling Shellcode in C par Aleksandra Doniec et Mantvydas Baranauskas (2021)
Creating Shellcode from any Code Using Visual Studio and C++ par Hamid Memar (2021)
Writing Optimized Windows Shellcode in C par Philip Woldhek (2021)
From C, with inline assembly, to shellcode par Steve Salinas (2023)
How To Craft Your Own Windows x86/64 Shellcode with Visual Studio par Yazid Benjamaa (2023)
Modern implant design: position independent malware development par Paul Ungur (2024)
From C to shellcode (simple way) par Print3M (2024)
relocatable par Tijme Gommers (2025)
PIC Development Crash Course par Raphael Mudge (2025)
scfw par Petr Beneš (2026)
Ce qui m'a paru étrange, c'est la classification VirTool:Win64/Silepesz.A par Microsoft Defender.
Voici les octets couverts par la signature :
[+] Target file size: 2560 bytes
[+] Analyzing...
[!] Identified end of bad bytes at offset 0x4CB
000003CB 44 24 2F 32 C6 44 24 30 2E C6 44 24 31 64 C6 44 D$/2�D$0.�D$1d�D
000003DB 24 32 6C C6 44 24 33 6C C6 44 24 35 61 C6 44 24 $2l�D$3l�D$5a�D$
000003EB 36 6E C6 44 24 37 20 C6 44 24 38 65 C6 44 24 39 6n�D$7 �D$8e�D$9
000003FB 78 C6 44 24 3A 74 C6 44 24 3B 72 C6 44 24 3C 61 x�D$:t�D$;r�D$<a
0000040B C6 44 24 3D 20 C6 44 24 3E 6C C6 44 24 3F 6F C6 �D$= �D$>l�D$?o�
0000041B 44 24 40 6E C6 44 24 41 67 C6 44 24 42 20 C6 44 D$@n�D$Ag�D$B �D
0000042B 24 43 68 C6 44 24 44 65 C6 44 24 45 6C C6 44 24 $Ch�D$De�D$El�D$
0000043B 46 6C C6 44 24 47 6F C6 44 24 48 20 C6 44 24 29 Fl�D$Go�D$H �D$)
0000044B 00 C6 44 24 49 77 C6 44 24 4A 6F C6 44 24 4B 72 .�D$Iw�D$Jo�D$Kr
0000045B C6 44 24 4C 6C C6 44 24 4D 64 C6 44 24 4E 21 C6 �D$Ll�D$Md�D$N!�
0000046B 44 24 25 44 C6 44 24 26 65 C6 44 24 27 6D C6 44 D$%D�D$&e�D$'m�D
0000047B 24 28 6F E8 55 00 00 00 48 85 C0 74 4B 48 BA 58 $(o�U...H.AtKH�X
0000048B D0 CC C6 F8 E7 BF 0A 48 89 C1 E8 86 FD FF FF 48 DI�o��.H.A�.y��H
0000049B 85 C0 74 34 48 8D 4C 24 2A FF D0 48 85 C0 74 28 .At4H.L$*�DH.At(
000004AB 48 BA D9 92 FB 55 9A AC 70 E0 48 89 C1 E8 63 FD H�U.�U.�p�H.A�cy
000004BB FF FF 48 85 C0 74 11 48 8D 54 24 35 45 31 C9 4C ��H.At.H.T$5E1�L
Ceci correspond au code source suivant :
pKernel32 = GET_USER_MODULE_BASE("kernel32.dll");
if (pKernel32 == nullptr)
goto cleanup;
RESOLVE_FUNCTION_POINTER(pKernel32, LoadLibraryA); // mov rdx, 0x0ABFE7F8C6CCD058 (FNV-1a hash of "LoadLibraryA" with modified offset basis)
if (LoadLibraryA == nullptr)
goto cleanup;
hUser32 = LoadLibraryA(sstrUser32.data());
if (hUser32 == nullptr)
goto cleanup;
RESOLVE_FUNCTION_POINTER(hUser32, MessageBoxA); // mov rdx, 0xE070AC9A55FB92D9 (FNV-1a hash of "MessageBoxA" with modified offset basis)
if (MessageBoxA == nullptr)
goto cleanup;
MessageBoxA(nullptr, sstrText.data(), sstrCaption.data(), MB_OK); // arg setup only
Inutile de dire qu'il s'agit d'une détection extrêmement fragile qui ne détectera que l'exemple de code exact présenté ci-dessus. Elle souligne néanmoins l'importance d'utiliser des hachages API polymorphes.