Pour écraser une adresse de retour à bon escient, ou pour lire correctement les arguments d’une fonction sous le débogueur, il faut savoir chacun d’eux se trouve au moment de l’appel. C’est tout l’objet de l’ABI (Application Binary Interface) System V, qui régit sous Linux/AMD64 la façon dont les arguments transitent entre l’appelant et l’appelé1.

Classer les arguments

Chaque argument est d’abord classifié selon son type — entier, virgule flottante, structure — puis sa taille est arrondie au multiple de huit octets (eightbyte) supérieur. Cette classification détermine ensuite où l’argument sera effectivement placé.

Où vont les arguments

Une fois classifiés, les arguments sont assignés aux registres dans l’ordre suivant :

  • si la classe est MEMORY, l’argument est passé sur la pile ;
  • si la classe est INTEGER, on utilise le prochain registre disponible parmi %rdi, %rsi, %rdx, %rcx, %r8, %r9 ;
  • si la classe est SSE, on utilise le prochain registre vectoriel, de %xmm0 à %xmm7 ;
  • si la classe est SSEUP, l’eightbyte est placé dans le prochain segment disponible du dernier registre vectoriel utilisé ;
  • si la classe est X87, X87UP ou COMPLEX_X87, l’argument est passé en mémoire.

Un booléen (_Bool) retourné ou passé en registre ou sur la pile n’occupe que le bit 0, les bits 1 à 7 devant rester nuls.

S’il ne reste plus de registre disponible pour ne serait-ce qu’un seul eightbyte d’un argument, c’est l’argument entier qui bascule sur la pile — y compris si des registres avaient déjà été assignés pour certains de ses eightbytes, auquel cas ces assignations sont annulées. Les arguments passés en mémoire sont ensuite empilés dans l’ordre inverse (de droite à gauche).

Enfin, pour un appel susceptible d’invoquer une fonction variadique (prototype absent, ou déclaration comportant des points de suspension ...), le registre %al sert d’argument caché, précisant le nombre de registres vectoriels effectivement utilisés.

Retenir l’essentiel pour l’exploitation

En pratique, sous Linux/AMD64, l’essentiel à mémoriser tient en une phrase : les six premiers arguments entiers passent, dans l’ordre, par %rdi, %rsi, %rdx, %rcx, %r8, %r9, et tout argument supplémentaire va sur la pile. C’est cette convention qu’exploitent systématiquement les chaînes ROP pour positionner les arguments d’un appel forgé — un gadget pop rdi; ret n’a d’intérêt que parce que l’ABI fait de %rdi le premier registre d’argument.

execve, un appel système qui ne revient pas

Un cas particulier mérite d’être noté : l’appel système execve remplace intégralement l’image du processus appelant — code, données et pile sont réinitialisés selon le programme invoqué — et, sauf erreur, ne retourne jamais au programme qui l’a appelé. Certains attributs survivent néanmoins à ce remplacement : les descripteurs de fichiers ouverts, le PID, et quelques autres propriétés du processus restent inchangés. C’est cette non-retour qui fait d’execve("/bin/sh", NULL, NULL), positionné via une chaîne ROP, la cible classique d’un ret2syscall : une fois l’appel effectué, il n’y a plus rien à nettoyer sur la pile.

Footnotes

  1. System V Application Binary Interface, AMD64 Architecture Processor Supplement, document de référence.