Peu de vulnérabilités illustrent aussi bien qu’un simple oubli syntaxique peut exposer l’intégralité de la pile d’un programme. La faille de chaîne de format en est l’exemple canonique : elle naît d’une fonction variadique utilisée sans discipline.

Les fonctions variadiques

En C, une fonction peut accepter un nombre quelconque d’arguments, déclarés dynamiquement, avec la syntaxe :

void* func(int argc, ...);

printf en est l’exemple le plus familier : chaque spécificateur de format (%d, %s, %p…) fait, par convention, référence au -ième argument passé — ou qui devrait l’être.

printf("%d %s %f %lu %p %p %p %p");

Sans arguments réellement fournis, cet appel affiche malgré tout quelque chose : par convention d’appel, les premiers spécificateurs correspondent aux six premiers registres généraux, puis les suivants piochent directement dans la pile. Le programme affiche donc des données arbitraires, faute d’avoir eu quoi que ce soit à afficher de légitime.

D’où naît la faille

Le danger apparaît dès qu’une entrée utilisateur devient elle-même la chaîne de format, plutôt qu’un simple argument :

int main() {
    char buf[64];
    gets(buf);
    printf(buf);
    printf("\n");
    return 0;
}

CAUTION

Un tel programme permet d’afficher les valeurs des registres et de la pile dès lors que l’entrée contient des spécificateurs de format.

Exécuter ce binaire avec ./bin %p %p %p ... %p en argument révèle ces emplacements mémoire — on peut s’en convaincre sous GDB en visualisant la pile (hexdump byte $rsp --size 128). Chaque %p fuite ainsi 8 octets, au prix de seulement 3 caractères dans le buffer d’entrée : un rendement qui permet, entre autres, de fuiter le saved RBP et le saved RIP d’une stack frame.

De la fuite à l'exploitation

Une fois saved RBP révélé, la taille des stack frames étant fixe et déterministe, on en déduit l’offset exact vers le buffer où insérer un exploit. Attention néanmoins : le buffer visé ne se situe pas nécessairement au sommet de la pile.

Cibler un argument précis

Plutôt que de compter les %p un à un, on peut préciser directement l’index de l’argument voulu :

%29$p

— ce qui accélère considérablement la mise au point d’un exploit sur une pile profonde.

%n : de la fuite à l’écriture arbitraire

Le spécificateur %n change radicalement de registre : il ne lit rien, il écrit. Il insère, à l’adresse pointée par l’argument correspondant, le nombre d’octets déjà écrits par l’appel en cours — c’est un attribut d’écriture, pas de lecture.

De cette seule primitive découle une écriture arbitraire à une adresse choisie, en composant un payload de la forme :

aaaaaaa%7$n<adresse_lsb>aaaa...aaaa%K$n<adresse_lsb+1>...

où l’on ajuste, par un padding calculé, le nombre d’octets déjà écrits avant chaque %n pour contrôler précisément la valeur insérée à chaque adresse ciblée.

%lc : un padding calibré

Le spécificateur %lc complète l’arsenal en permettant d’introduire un remplissage de taille l exacte, la valeur du caractère étant elle-même comprise dans ce compte — utile pour ajuster au byte près le compteur d’écriture que %n viendra ensuite exploiter.

Ce que cette faille enseigne

La chaîne de format illustre, mieux qu’aucune autre vulnérabilité, la différence entre un bug de lecture et un bug d’écriture : %p et consorts ne font que fuiter ce qui existe déjà en mémoire, quand %n transforme cette même primitive en capacité d’écriture arbitraire — l’un des vecteurs les plus puissants qui soient, puisqu’il ne dépend d’aucun débordement de buffer, seulement d’un appel à printf mal formé.