L'Off-by-one sur la pile
Dans l'article précédent on a vu qu'un off-by-one reste un buffer overflow comme un autre, juste avec un octet de trop au lieu de 200. Ici on prend exactement ce bug et on le place dans une stack frame, là où les articles sur le Stack Buffer Overflow ont déjà posé les bases. La question qui nous intéresse cette fois, c'est ce qu'un unique octet peut toucher quand il déborde d'un buffer local.
L'Off-by-one sur la pile
Le principe ne change pas, un buffer local reçoit un octet de plus que sa taille réservée. La différence tient uniquement à l'endroit où ce buffer vit. Un buffer global mal borné écrase toujours la même variable, dans le même ordre, à chaque exécution. Un buffer sur la pile n'a pas cette garantie, ce qui se trouve juste après dépend de la disposition choisie par le compilateur pour cette fonction précise.
Pourquoi un seul octet peut être critique
Une stack frame peut contenir d'autres variables locales, le RBP sauvegardé de l'appelant, ou l'adresse de retour. Rien n'impose qu'un octet en trop tombe sur l'un plutôt que sur l'autre, ça dépend entièrement de l'ordre dans lequel le compilateur a placé les variables et de l'alignement qu'il a choisi. Mais si cet octet tombe sur une donnée qui influence la suite de l'exécution, un flag de contrôle, un octet du frame pointer ou un octet de l'adresse de retour, un seul bit changé peut suffire à changer le comportement du programme.
Exemple concret
#include <stdio.h>
#include <string.h>
void verifier(const char *source) {
int ok = 1;
char buffer[16];
for (size_t i = 0; i <= strlen(source); i++) {
buffer[i] = source[i];
}
if (ok) {
printf("Accès autorisé\n");
} else {
printf("Accès refusé\n");
}
}
int main(void) {
verifier("AAAAAAAAAAAAAAAA"); // 16 caractères
return 0;
}
source fait 16 caractères, la boucle i <= strlen(source) écrit 17 octets dans buffer, un de trop. Le dernier octet écrit est source[16], le \0 de fin de chaîne. Avec ce compilateur et sans optimisation, ok se trouve juste après buffer en mémoire, ce qu'on va vérifier sous GDB plutôt que de le supposer.
$ ./offbyone_stack
Accès refusé
Le programme devait afficher "Accès autorisé" puisque ok vaut 1 à la déclaration. Un seul octet écrit en trop a suffi à inverser le résultat.
Observer la stack avec GDB
gdb ./offbyone_stack
(gdb) break verifier
(gdb) run
(gdb) print &buffer
$1 = (char (*)[16]) 0x7fffffffe420
(gdb) print &ok
$2 = (int *) 0x7fffffffe430
ok est bien placée immédiatement après buffer, à 16 octets d'écart. On regarde son contenu avant de laisser la boucle s'exécuter.
(gdb) x/4bx &ok
0x7fffffffe430: 0x01 0x00 0x00 0x00
Puis on avance après la boucle fautive.
(gdb) x/4bx &ok
0x7fffffffe430: 0x00 0x00 0x00 0x00
Un seul octet a changé, le premier, passé de 0x01 à 0x00. C'est exactement l'octet en trop écrit par la boucle, et c'est lui qui a suffi à faire passer ok de 1 à 0.
Quand l'Off-by-one touche le flot d'exécution
Ici, l'octet en trop tombe sur une variable locale banale. Mais si le RBP sauvegardé ou l'adresse de retour se trouvait juste après le buffer, le même octet les toucherait à la place. Les adresses sur x86-64 sont stockées en little-endian, donc l'octet de poids faible de l'adresse de retour est le premier après le buffer. Un débordement d'un seul octet ne changerait que cette dernière partie de l'adresse, de quoi faire sauter le programme un peu plus loin dans le code, mais sans donner la main sur une destination choisie librement.
Exploitable ou pas ?
Ça dépend entièrement du compilateur, des options d'optimisation, et de l'ordre dans lequel les variables ont été placées. Recompiler le même code peut suffire à déplacer ok, ou à ajouter du remplissage qui absorbe l'octet en trop sans rien changer. Avec un stack canary actif, cet octet tomberait sûrement dessus plutôt que sur une donnée utile, et le programme crasherait au lieu de se comporter bizarrement. Il n'y a pas de règle fixe ici, juste une disposition mémoire à vérifier au cas par cas.
Comment l'éviter
Les mêmes réflexes que pour n'importe quel off-by-one s'appliquent ici, vérifier ses bornes une par une et compter le \0 dans la taille réservée. L'article précédent détaille ces causes en profondeur, rien de spécifique à la pile ne change cette partie-là.
Conclusion
C'est le même bug que dans l'article précédent, mais la pile change la donne. Un buffer global écrase toujours la même voisine, un buffer sur la pile peut tomber sur une simple variable locale ou sur une donnée qui contrôle directement la suite de l'exécution, selon une disposition qui n'est jamais garantie d'avance. C'est justement cette incertitude qui rend un off-by-one sur la pile particulièrement intéressant à étudier, même quand il ne fait qu'un octet.