
Bonnes pratiques en C : manipuler les strings sans risque
En février 2024, la Maison Blanche a publié un rapport sur la sécurité des langages C et C++. Ce rapport, rédigé par l’Office of the National Cyber Director (ONCD), souligne que la gestion de la mémoire dans les logiciels écrits en C et C++ représente un risque majeur pour la sécurité du cyberespace. Ayant travaillé sur des projets en C, j’ai constaté que certains bogues étaient dus à une mauvaise gestion de la mémoire, notamment lors de la manipulation de strings. Dans cet article, je vais vous présenter des bonnes pratiques pour manipuler les strings en C en toute sécurité.
Les strings en C : un cauchemar pour les développeurs
Des erreurs de segmentation
Lors de notre dernier chantier, mon équipe et moi devions assurer la compatibilité d’un logiciel écrit en C avec une nouvelle version de l’OS. Cette montée de version incluait également la mise à jour de GCC, le compilateur C utilisé pour le projet. Pendant cette migration, à plusieurs reprises, nous avons constaté des arrêts inattendus du logiciel liées essentiellement à des erreurs SEGFAULT
.
Le code source à l’origine de l’erreur : une manipulation de strings
Après avoir activé les core dumps, nous avons pu analyser les fichiers générés et identifier la source de l’erreur. Le problème provenait du code source d’un processus fils qui manipulait des strings lors de son initialisation.
Voici un extrait du fichier d’en-tête C en question :
// main.h
// ...
#ifndef _MAIN_H
#define _MAIN_H
// ...
/// @brief Path to the configuration file.
char configurationPath[256];
// ...
#endif
Et voici l’extrait du code source du processus fils :
// main.c
// ...
#include <string.h>
#include <stdio.h>
// ...
#include "config.h"
#include "main.h"
// ...
int main() {
// ...
memset(configurationPath, 0, 356);
sprintf(configurationPath, "%s/config.ini", getProjectHomePath());
// ...
return 0;
}
Analyse de l’erreur : un dépassement de la capacité de la chaîne de caractères
Au-delà de la déclaration discutable de la variable configurationPath
dans un fichier d’en-tête, le code source présente deux problèmes majeurs :
- La fonction
memset
initialise la variableconfigurationPath
avec des zéros, mais la taille passée en paramètre est incorrecte (356 au lieu de 256). - La fonction
sprintf
concatène le chemin du fichier de configuration avec le chemin du répertoire du projet. Cependant, la taille du chemin retourné est inconnue, ce qui peut entraîner un buffer overflow (dépassement de tampon).
Pourquoi le changement d’OS a-t-il révélé ce bogue ?
Bien que le code source précédent contienne un bogue, il a fonctionné sans problème pendant des années sur l’ancienne version de l’OS. Pourquoi ce bogue est-il apparu après la migration vers la nouvelle version de l’OS ?
La réponse réside dans la gestion de la mémoire par le compilateur. En effet, le compilateur GCC de la nouvelle version de l’OS a modifié la manière dont le programme alloue la mémoire des variables.
Dans la version précédente de GCC, l’allocation de mémoire pour la variable configurationPath
était suivie d’au moins 100 octets alloués en écriture. Le dépassement de tampon n’a donc jamais causé de problème de segmentation, et par chance, aucune corruption de la mémoire non plus.
Cependant, dans la nouvelle version de GCC, l’allocation de mémoire pour la variable configurationPath
est maintenant suivie d’une zone mémoire en lecture seule ou non allouée. Le dépassement de tampon a donc provoqué l’erreur de segmentation.
Les fonctions sûres de manipulation de strings en C
Le risque de buffer overflow
Nous l’avons vu dans l’exemple précédent, si on n’y prête pas l’attention nécessaire, les fonctions de manipulation des chaînes de caractères en C peuvent entraîner des erreurs de segmentation.
L’avantage du langage C réside dans sa capacité à manipuler directement la mémoire. Mais cette force peut devenir un piège si le développeur ne prend pas les précautions nécessaires.
Heureusement, même si le langage nous autorise à faire n’importe quoi avec la mémoire, il existe des fonctions sûres et des bonnes pratiques à suivre pour manipuler les strings en toute sécurité.
Les fonctions de manipulation de strings sûres
Dans cet article, je vais vous présenter les fonctions C de manipulation de strings sûres couramment utilisées.
stpncpy plutôt que stpcpy
La fonction stpcpy
copie la chaîne source dans la chaîne de destination et renvoie un pointeur sur le dernier caractère copié. Cependant, si la chaîne de destination est trop petite pour contenir la chaîne source, un buffer overflow peut se produire.
La fonction stpncpy
est une alternative sûre à stpcpy
. Elle copie au maximum n
caractères de la chaîne source dans la chaîne de destination, sans dépasser la taille de la chaîne de destination.
Voici un exemple d’utilisation de la fonction stpncpy
:
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
int main() {
char buffer[12];
char *start = buffer;
memset(buffer, '_', sizeof(buffer)); // <1>
start = stpncpy(start, "Hello", sizeof(buffer) - (start - buffer));
start = stpncpy(start, " world", sizeof(buffer) - (start - buffer));
printf("%s\n", buffer);
memset(buffer, '_', sizeof(buffer)); // <1>
start = buffer;
start = stpncpy(start, "Hello", sizeof(buffer) - (start - buffer));
start = stpncpy(start, " world!", sizeof(buffer) - (start - buffer));
if (start >= buffer + sizeof(buffer)) { // <2>
buffer[sizeof(buffer) - 1] = '\0';
}
printf("%s\n", buffer);
return 0;
}
Initialisation du buffer avec des caractères _
afin de visualiser les modifications.
Le pointeur start
est positionné à la fin du buffer, donc une zone mémoire non accessible. Il faut donc ajouter un caractère \0
pour éviter à printf
de lire en dehors du buffer.
Ces deux printf
affichent Hello world
. Dans le premier cas, stpncpy
a suffisamment de place pour copier les deux chaînes et ajouter le caractère de fin de chaîne.
avant: _ _ _ _ _ _ _ _ _ _ _ _
après: H e l l o w o r l d 0x00
Dans le second cas, le !
est également copié, mais c’est le dernier caractère du buffer. Le caractère de fin de chaîne doit donc remplacer le !
pour éviter au printf
de continuer sa lecture en dehors du buffer.
avant: _ _ _ _ _ _ _ _ _ _ _ _
après: H e l l o w o r l d !
correction: H e l l o w o r l d 0x00
strncpy plutôt que strcpy
Les fonctions strcpy
et strncpy
copient la chaîne source dans la chaîne de destination. Cependant, strcpy
ne vérifie pas la taille de la chaîne de destination, ce qui peut entraîner un dépassement de tampon. Leur fonctionnement est assez proche de celui de stpcpy
et stpncpy
. Voici un exemple d’utilisation de la fonction strncpy
:
#include <stdio.h>
#include <string.h>
int main() {
char buffer[12];
memset(buffer, '_', sizeof(buffer));
strncpy(buffer, "Hello world", sizeof(buffer));
printf("%s\n", buffer);
memset(buffer, '_', sizeof(buffer));
strncpy(buffer, "Hello world!", sizeof(buffer));
if (strnlen(buffer, sizeof(buffer))) { // <1>
buffer[sizeof(buffer) - 1] = '\0';
}
printf("%s\n", buffer);
return 0;
}
Comme pour l’exemple précédent, le caractère de fin de chaîne doit être ajouté manuellement si le buffer est plein.
Le comportement de ce programme est similaire à celui de l’exemple précédent. La première chaîne est copiée sans problème, tandis que la seconde nécessite une vérification pour éviter un buffer overflow.
strncat plutôt que strcat
Les fonctions strcat
et strncat
concatènent la chaîne source à la chaîne de destination. Cependant, strcat
ne vérifie pas la taille de la chaîne de destination, ce qui peut entraîner un dépassement de tampon. Voici un exemple d’utilisation de la fonction strncat
:
#include <stdio.h>
#include <string.h>
int main() {
char buffer[12];
memset(buffer, '_', sizeof(buffer));
strncpy(buffer, "Hello", sizeof(buffer));
strncat(
buffer,
" world",
sizeof(buffer) - strnlen(buffer, sizeof(buffer))
);
printf("%s\n", buffer);
memset(buffer, '_', sizeof(buffer));
strncpy(buffer, "Hello", sizeof(buffer));
strncat(
buffer,
" world!",
sizeof(buffer) - strnlen(buffer, sizeof(buffer))
);
if (strnlen(buffer, sizeof(buffer))) {
buffer[sizeof(buffer) - 1] = '\0';
}
printf("%s\n", buffer);
return 0;
}
Les deux printf
affichent Hello world
. Dans le premier cas, strncat
a suffisamment de place pour concaténer les deux chaînes.
memset: _ _ _ _ _ _ _ _ _ _ _ _
strncpy: H e l l o 0x00 _ _ _ _ _ _
strncat: H e l l o w o r l d 0x00
Dans le second cas, le !
est également concaténé, mais c’est le dernier caractère du buffer. Le caractère de fin de chaîne doit donc remplacer le !
pour éviter au printf
de continuer sa lecture en dehors du buffer.
memset: _ _ _ _ _ _ _ _ _ _ _ _
strncpy: H e l l o 0x00 _ _ _ _ _ _
strncat: H e l l o w o r l d !
correction: H e l l o w o r l d 0x00
strndup plutôt que strdup
La fonction strndup
est une alternative sûre à strdup
. Elle duplique la chaîne passée en paramètre, mais en limitant la lecture aux n
premiers caractères. Voici un exemple d’utilisation de la fonction strndup
:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int main() {
char buffer[12];
strncpy(buffer, "Hello world!", sizeof(buffer));
char *dup = strndup(buffer, sizeof(buffer));
int rc = 0;
if (dup == NULL) {
perror("strndup");
rc = 1;
} else {
printf("%s\n", dup);
free(dup);
}
return rc;
}
Dans cet exemple, la chaîne Hello world!
est dupliquée en limitant la lecture aux 12 premiers caractères. La fonction strndup
renvoie un pointeur sur un nouveau buffer de 13 caractères contenant la chaîne dupliquée et le caractère de fin de chaîne. Par ailleurs, la fonction strndup
alloue automatiquement la mémoire nécessaire pour stocker la chaîne dupliquée. Il est donc nécessaire de libérer cette mémoire avec la fonction free
.
snprintf plutôt que sprintf
La fonction snprintf
est une alternative sûre à sprintf
. Elle formate une chaîne de caractères et la stocke dans un buffer. Cependant, sprintf
ne vérifie pas la taille du buffer, ce qui peut entraîner un dépassement de tampon. Voici un exemple d’utilisation de la fonction snprintf
:
#include <stdio.h>
#include <string.h>
int main() {
char buffer[12];
memset(buffer, '_', sizeof(buffer));
if (((size_t) snprintf(buffer, sizeof(buffer), "Hello %s!", "world")) >= sizeof(buffer)) {
printf("[WARN] la chaîne a été tronquée\n");
}
printf("%s\n", buffer);
return 0;
}
Ce programme affiche le message [WARN] la chaîne a été tronquée
suivi de Hello world
. La fonction snprintf
formate la chaîne Hello world!
et la stocke dans le buffer. Cependant, la taille du buffer est limitée à 12 caractères, ce qui entraîne une troncature de la chaîne pour ajouter le caractère de fin de chaîne et éviter un buffer overflow.
D’ailleurs, le compilateur GCC génère l’avertissement format-truncation
pour signaler ce type de problème. Dans cet exemple, le compilateur peut analyser le code et détecter ce type l’erreur du développeur. Cependant, si vous travaillez avec des chaînes de caractères dynamiques, le compilateur ne saura pas à l’avance quelle sera la taille de la chaîne. Il est donc important de vérifier la taille retournée par la fonction snprintf
afin d’éviter des comportements inattendus.
Par exemple, si vous construisez dynamiquement le chemin d’un fichier et que la taille du buffer de destination est trop petite, le chemin sera tronqué et le fichier ne sera pas trouvé.
strncmp plutôt que strcmp
Les fonctions strcmp
et strncmp
comparent deux chaînes de caractères entre-elles. Cependant, strcmp
ne vérifie pas la taille des chaînes, ce qui peut entraîner un dépassement de tampon.
#include <stdio.h>
#include <string.h>
#define MIN(a, b) (((a) < (b)) ? (a) : (b))
int main() {
char shortBuffer[12];
char longBuffer[13];
stpncpy(shortBuffer, "Hello world", sizeof(shortBuffer));
stpncpy(longBuffer, "Hello world!", sizeof(longBuffer));
printf(
"strncmp full: %d\n",
strncmp(
shortBuffer,
longBuffer,
MIN(sizeof(shortBuffer), sizeof(longBuffer))
)
);
printf(
"strncmp partial: %d\n",
strncmp(
shortBuffer,
longBuffer,
MIN(sizeof(shortBuffer), sizeof(longBuffer)) - 1
)
);
return 0;
}
Dans cet exemple, la fonction strncmp
compare les deux chaînes shortBuffer
et longBuffer
. La macro MIN
détermine la taille de la plus petite chaîne pour éviter un dépassement de tampon.
Pour le premier cas, la fonction renvoie -33
car elle détecte une différence sur le dernier caractère (\0
vs !
). Pour le second cas, la fonction renvoie 0
car elle ne compare que les 11 premiers caractères des deux chaînes, qui sont identiques.
Cela signifie que pour obtenir un résultat cohérent lors de la comparaison de chaînes, la chaîne avec le plus petit buffer doit se terminer par un caractère de fin de chaîne. Sinon, la comparaison pourrait indiquer à tort une égalité.
Le danger du C : un problème d’essence ou de compétence ?
Je n’ai présenté ici que quelques fonctions de manipulation de strings en C. Il existe également d’autres fonctions comme memcpy
, memmove
, memcmp
, memset
, ainsi que des instructions d’arithmétique de pointeurs. Ces fonctions et instructions peuvent aussi être sources d’erreurs, entraînant des erreurs de segmentation ou des fuites de mémoire si elles sont mal utilisées.
Bien que potentiellement problématiques, ces fonctions et instructions peuvent considérablement améliorer les performances d’un programme. Leur utilité est donc indéniable, mais leur utilisation nécessite une grande rigueur et une connaissance approfondie du langage C. La compétence du développeur est donc essentielle pour garantir la sécurité des applications écrites en C.
Conclusion
En résumé, le langage C, bien que puissant et performant, présente des risques importants liés à la gestion de la mémoire, notamment lors de la manipulation des strings. Les erreurs de segmentation et les dépassements de tampon sont des problèmes courants qui peuvent compromettre la sécurité et la stabilité des applications. Pour éviter ces pièges, il est important d’adopter des bonnes pratiques et d’utiliser des fonctions sûres telles que stpncpy
, strncpy
, strncat
, strndup
, snprintf
, et strncmp
.
Cependant, même avec ces précautions, le développement en C nécessite une rigueur et une vigilance constantes. C’est pourquoi de plus en plus de développeurs se tournent vers des langages modernes comme Rust, qui offre des garanties de sécurité mémoire tout en conservant des performances comparables à celles du C.
Vous avez rencontré d’autres cas d’erreurs de buffer overflow en C, ou vous avez d’autres astuces et bonnes pratiques pour sécuriser les opérations en C ? N’hésitez pas à les partager dans les commentaires.