Dans cette première partie, nous reverrons plus ou moins en détails comment s'effectue un appel de fonction sous x86, ce que les connaisseurs peuvent sauter. Ensuite, nous parlerons du SSP et de la manière dont il modifie la convention d'appel.

Tout d'abord, un petit rappel sur la façon dont s'effectue un appel de fonction. A la façon des matriochkas, la fonction dite appelante empile les arguments. L'ordre importe peu, il peut être de gauche à droite ou de droite à gauche, du moment que les fonctions respectent les mêmes conventions d'appel.

push eax
push ebx
call _func

Lors du retour, la fonction n'a plus qu'à effectuer l'opération inverse en détruisant les arguments sur la pile, le résultat de l'appel étant stocké dans l'accumulateur eax. On note qu'il n'est pas nécessaire d'effectuer plusieurs pop successifs et qu'il suffit juste d'ajouter la taille des arguments au pointeur de pile esp, soit ici 8 octets.

add  esp, 0x08

Lors de l'appel, l'opérande call empile implicitement l'adresse de retour, soit l'adresse courant plus 4 octets, puis saute à l'adresse indiqué. L'opérande ret quand à elle effectue l'inverse, elle saute à l'adresse au sommet de la pile, puis la détruit en ajoutant 4 au pointeur de pile.

Intéressons-nous ensuite à la fonction dite appelée. Celle-ci va dans un premier temps créer sa frame. Pour cela, elle sauvegarde le pointeur de frame ebp sur la pile, donc au-dessus de l'adresse de retour, puis la remplace par son propre pointeur de frame qui n'est autre que le pointeur de pile courant.

push ebp
mov ebp, esp

La suite des opérations consiste à réserver l'espace pour les variables locales en soustrayant leur taille au pointeur de pile courant, puis à sauvegarder sur la pile tous les registres qui seront modifiés lors de l'exécution de la fonction.

sub esp, 0x20
push ebx

A partir de là, la fonction disposant de sa frame, il lui est possible d'accéder à différentes informations facilement à partir du pointeur de frame. Aux adresses ebp et ebp+0x04 se trouve respectivement le pointeur de frame précédent et l'adresse de retour. Au-delà dans les positifs se trouvent les arguments. Par exemple, si l'on suit la convention de GCC, à l'adresse ebp+0x08 se trouve le premier argument, à l'adresse ebp+0x0c le deuxième, et ainsi de suite. Dans les négatifs se trouvent les variables locales et encore au delà tout ce qui aura été mis ultérieurement sur la pile.

Pour le retour, on effectue les opérations en sens inverse. On détruit les variables locales en ajoutant leur taille au pointeur de pile, puis on restaure le pointeur de frame de la fonction précédente. Enfin, on peut retourner la main à la fonction appelante.

add esp, 0x20
pop ebp
ret

Sans entrer dans les détails de la méthode, on peut comprendre aisément le principe de dépassement de tampon. Un strcpy mal placé sur une variable locale permet de réécrire au delà de ce qui était prévu, écrasant alors le pointeur de frame précédent et tout le reste. En jouant correctement, il est possible de faire en sorte que lors du retour de l'exécution on saute par exemple vers une fonction de la libc.

Comment notre SSP nous protège de ce genre d'erreur? Celui-ci va, lorsque la fonction appelante est exécutée, injecter une valeur aléatoire, ou tout du moins choisie à l'avance, dans la pile nommée canary ainsi que réserver une zone tampon de taille variable. Par ailleurs, l'ordre des variables locales est changé afin de limiter les risques. En règle générale, le canary est choisi aléatoirement au démarrage du programme. Si ce n'était pas le cas, l'attaquant pourrait le prévoir et réécrire par-dessus sans le modifier.

mov ecx, [gs:0x14]
mov [ebp-0x0c], ecx
xor ecx, ecx

Le canary se situe ici à l'adresse 0x14 dans le segment gs. Il est recopié dans ecx puis sur la frame en troisième position, laissant huit octets vides derrière lui. Enfin, on efface ecx à l'aide de l'instruction xor pour limiter tout risque de fuite du canary.

Quand la fonction a fini de s'exécuter, avant de préparer le retour, on vérifie le canary.

mov edx, [ebp-0x0c]
xor edx, [gs:0x14]

Au cas où le test est non-concluant, le programme fait alors un saut vers une adresse précise situé juste après le retour, puis appelle la fonction __stack_chk_fail. Cette fonction peut alors prendre plusieurs décisions, mais une implémentation classique aura pour conséquences de provoquer la terminaison du programme.

On comprend alors qu'il devienne difficile de réaliser un dépassement de tampon. Non-seulement, il faut prévoir quelle est la valeur du canary, mais en plus connaître la taille du tampon supplémentaire qui a été réservé.

Maintenant, nous savons comment le SSP fonctionne dans la théorie, et à quoi ressemble son implémentation dans un programme tournant sous x86. Dans une seconde partie, nous verrons comment utiliser dans le cadre d'un programme en C ou C++, ainsi que son utilisation lorsque l'on développe from scratch sur x86.

partie II