Ce cours est visible gratuitement en ligne.

Paperback available in this course

Ce cours existe en eBook.

Certificate of achievement available at the end this course

Got it!
Apprenez à programmer en C !
Last updated on Thursday, September 25, 2014
  • 4 semaines
  • Moyen

La saisie de texte sécurisée

La saisie de texte est un des aspects les plus délicats du langage C. Vous connaissez la fonction scanf, que vous avez vue au début du cours. Vous vous dites : quoi de plus simple et de plus naturel ? Eh bien figurez-vous que non, en fait, c'est tout sauf simple.

Ceux qui vont utiliser votre programme sont des humains. Tout humain qui se respecte fait des erreurs et peut avoir des comportements inattendus. Si vous lui demandez : « Quel âge avez-vous ? », qu'est-ce qui vous garantit qu'il ne va pas vous répondre « Je m'appelle François je vais bien merci » ?

Le but de ce chapitre est de vous faire découvrir les problèmes que l'on peut rencontrer en utilisant la fonction scanf et de vous montrer une alternative plus sûre avec la fonction fgets.

Les limites de la fonction scanf

La fonction scanf(), que je vous ai présentée dès le début du cours de C, est une fonction à double tranchant :

  • elle est facile à utiliser quand on débute (c'est pour ça que je vous l'ai présentée)…

  • … mais son fonctionnement interne est complexe et elle peut même être dangereuse dans certains cas.

C'est un peu contradictoire, n'est-ce pas ? En fait, scanfa l'air facile à utiliser, mais elle ne l'est pas en pratique. Je vais vous montrer ses limites par deux exemples concrets.

Entrer une chaîne de caractères avec des espaces

Supposons qu'on demande une chaîne de caractères à l'utilisateur, mais que celui-ci insère un espace dans sa chaîne :

#include <stdio.h>
#include <stdlib.h>
 
int main(int argc, char *argv[])
{
    char nom[20] = {0};
 
    printf("Quel est votre nom ? ");
    scanf("%s", nom);
    printf("Ah ! Vous vous appelez donc %s !\n\n", nom);
 
    return 0;
}
Quel est votre nom ? Jean Dupont
Ah ! Vous vous appelez donc Jean !

Pourquoi le « Dupont » a disparu ?

Parce que la fonction scanf s'arrête si elle tombe au cours de sa lecture sur un espace, une tabulation ou une entrée.

Vous ne pouvez donc pas récupérer la chaîne si celle-ci comporte un espace.

On peut utiliser la fonction scanf de telle sorte qu'elle lise les espaces, mais c'est assez compliqué. Si vous voulez apprendre à bien vous servir de scanf, on peut trouver des cours très détaillés sur le web, notamment un tutoriel de Developpez.com (attention, c'est assez difficile).

Entrer une chaîne de caractères trop longue

Il y a un autre problème, beaucoup plus grave encore : celui du dépassement de mémoire.

Dans le code que nous venons de voir, il y a la ligne suivante :

char nom[5] = {0};

Vous voyez que j'ai alloué 5 cases pour mon tableau de char appelé nom. Cela signifie qu'il y a la place d'écrire 4 caractères, le dernier étant toujours réservé au caractère de fin de chaîne \0.
Revoyez absolument le cours sur les chaînes de caractères si vous avez oublié tout cela.

La fig. suivante vous présente l'espace qui a été alloué pour nom.

Allocation de mémoire

Que se passe-t-il si vous écrivez plus de caractères qu'il n'y a d'espace prévu pour les stocker ?

Quel est votre nom ? Patrice
Ah ! Vous vous appelez donc Patrice !

A priori, il ne s'est rien passé. Et pourtant, ce que vous voyez là est un véritable cauchemar de programmeur !

On dit qu'on vient de faire un dépassement de mémoire, aussi appelé buffer overflow en anglais.

Comme vous le voyez sur la fig. suivante, on avait alloué 5 cases pour stocker le nom, mais en fait il en fallait 8. Qu'a fait la fonction scanf ? Elle a continué à écrire à la suite en mémoire comme si de rien n'était ! Elle a écrit dans des zones mémoire qui n'étaient pas prévues pour cela.

Dépassement dans la mémoire

Les caractères en trop ont « écrasé » d'autres informations en mémoire. C'est ce qu'on appelle un buffer overflow (fig. suivante).

Le buffer overflow

En quoi cela est-il dangereux ?

Sans entrer dans les détails, car on pourrait en parler pendant 50 pages sans avoir fini, il faut savoir que si le programme ne contrôle pas ce genre de cas, l'utilisateur peut écrire ce qu'il veut à la suite en mémoire. En particulier, il peut insérer du code en mémoire et faire en sorte qu'il soit exécuté par le programme. C'est l'attaque par buffer overflow, une attaque de pirate célèbre mais difficile à réaliser.
Si cela vous intéresse, vous pouvez lire l'article « Dépassement de tampon » de Wikipédia (attention c'est quand même assez compliqué).

Le but de ce chapitre sera de sécuriser la saisie de nos données, en empêchant l'utilisateur de faire déborder et de provoquer un buffer overflow. Bien sûr, on pourrait allouer un très grand tableau (10 000 caractères), mais ça ne changerait rien au problème : une personne qui veut faire dépasser de la mémoire n'aura qu'à envoyer plus de 10 000 caractères et son attaque marchera tout aussi bien.

Aussi bête que cela puisse paraître, tous les programmeurs n'ont pas toujours fait attention à cela. S'ils avaient fait les choses proprement depuis le début, une bonne partie des failles de sécurité dont on entend parler encore aujourd'hui ne serait jamais apparue !

Récupérer une chaîne de caractères

Il existe plusieurs fonctions standards en C qui permettent de récupérer une chaîne de texte. Hormis la fonction scanf (trop compliquée pour être étudiée ici), il existe :

  • gets : une fonction qui lit toute une chaîne de caractères, mais très dangereuse car elle ne permet pas de contrôler les buffer overflow !

  • fgets : l'équivalent de gets mais en version sécurisée, permettant de contrôler le nombre de caractères écrits en mémoire.

Vous l'aurez compris : bien que ce soit une fonction standard du C, gets est très dangereuse. Tous les programmes qui l'utilisent sont susceptibles d'être victimes de buffer overflow.

Nous allons donc voir comment fonctionne fgets et comment on peut l'utiliser en pratique dans nos programmes en remplacement de scanf.

La fonction fgets

Le prototype de la fonction fgets, situé dans stdio.h, est le suivant :

char *fgets( char *str, int num, FILE *stream );

Il est important de bien comprendre ce prototype. Les paramètres sont les suivants.

    • str : un pointeur vers un tableau alloué en mémoire où la fonction va pouvoir écrire le texte entré par l'utilisateur.

    • num : la taille du tableau str envoyé en premier paramètre.

Notez que si vous avez alloué un tableau de 10 char, fgets lira 9 caractères au maximum (il réserve toujours un caractère d'espace pour pouvoir écrire le \0 de fin de chaîne).

  • stream : un pointeur sur le fichier à lire. Dans notre cas, le « fichier à lire » est l'entrée standard, c'est-à-dire le clavier. Pour demander à lire l'entrée standard, on enverra le pointeur stdin, qui est automatiquement défini dans les headers de la bibliothèque standard du C pour représenter le clavier. Toutefois, il est aussi possible d'utiliser fgets pour lire des fichiers, comme on a pu le voir dans le chapitre sur les fichiers.

La fonction fgets retourne le même pointeur que str si la fonction s'est déroulée sans erreur, ou NULL s'il y a eu une erreur. Il suffit donc de tester si la fonction a renvoyé NULL pour savoir s'il y a eu une erreur.

Testons !

#include <stdio.h>
#include <stdlib.h>
 
int main(int argc, char *argv[])
{
    char nom[10];
 
    printf("Quel est votre nom ? ");
    fgets(nom, 10, stdin);
    printf("Ah ! Vous vous appelez donc %s !\n\n", nom);
 
    return 0;
}
Quel est votre nom ? Mateo
Ah ! Vous vous appelez donc Mateo
!

Ça fonctionne très bien, à un détail près : quand vous pressez « Entrée », fgets conserve le \n correspondant à l'appui sur la touche « Entrée ». Cela se voit dans la console car il y a un saut à la ligne après « Mateo » dans mon exemple.

On ne peut rien faire pour empêcher fgets d'écrire le caractère \n, la fonction est faite comme ça. En revanche, rien ne nous interdit de créer notre propre fonction de saisie qui va appeler fgets et supprimer automatiquement à chaque fois les \n !

Créer sa propre fonction de saisie utilisant fgets

Il n'est pas très difficile de créer sa propre petite fonction de saisie qui va faire quelques corrections à chaque fois pour nous.

Nous appellerons cette fonction lire. Elle renverra 1 si tout s'est bien passé, 0 s'il y a eu une erreur.

Éliminer le saut de ligne \n

La fonction lire va appeler fgets et, si tout s'est bien passé, elle va rechercher le caractère \n à l'aide de la fonction strchr que vous devriez déjà connaître. Si un \n est trouvé, elle le remplace par un \0 (fin de chaîne) pour éviter de conserver une « Entrée ».

Voici le code, commenté pas à pas :

#include <stdio.h>
#include <stdlib.h>
#include <string.h> // Penser à inclure string.h pour strchr()
 
int lire(char *chaine, int longueur)
{
    char *positionEntree = NULL;
 
    // On lit le texte saisi au clavier
    if (fgets(chaine, longueur, stdin) != NULL)  // Pas d'erreur de saisie ?
    {
        positionEntree = strchr(chaine, '\n'); // On recherche l'"Entrée"
        if (positionEntree != NULL) // Si on a trouvé le retour à la ligne
        {
            *positionEntree = '\0'; // On remplace ce caractère par \0
        }
        return 1; // On renvoie 1 si la fonction s'est déroulée sans erreur
    }
    else
    {
        return 0; // On renvoie 0 s'il y a eu une erreur
    }
}

Vous noterez que je me permets d'appeler la fonction fgets directement dans un if. Ça m'évite d'avoir à récupérer la valeur de fgets dans un pointeur juste pour tester si celui-ci est NULL ou non.

À partir du premier if, je sais si fgets s'est bien déroulée ou s'il y a eu un problème (l'utilisateur a rentré plus de caractères qu'il n'était autorisé).

Si tout s'est bien passé, je peux alors partir à la recherche du \n avec strchr et remplacer cet \n par un \0 (fig. suivante).

Remplacement du saut de ligne

Ce schéma montre que la chaîne écrite par fgets était « Mateo\n\0 ». Nous avons remplacé le \n par un \0, ce qui a donné au final : « Mateo\0\0 ».
Ce n'est pas grave d'avoir deux \0 d'affilée. L'ordinateur s'arrête au premier \0 qu'il rencontre et considère que la chaîne de caractères s'arrête là.

Le résultat ? Eh bien ça marche.

int main(int argc, char *argv[])
{
    char nom[10];
 
    printf("Quel est votre nom ? ");
    lire(nom, 10);
    printf("Ah ! Vous vous appelez donc %s !\n\n", nom);
 
    return 0;
}
Quel est votre nom ? Mateo
Ah ! Vous vous appelez donc Mateo !
Vider le buffer

Nous ne sommes pas encore au bout de nos ennuis.
Nous n'avons pas étudié ce qui se passait si l'utilisateur tentait de mettre plus de caractères qu'il n'y avait de place !

Quel est votre nom ? Jean Edouard Albert 1er
Ah ! Vous vous appelez donc Jean Edou !

La fonction fgets étant sécurisée, elle s'est arrêtée de lire au bout du 9e caractère car nous avions alloué un tableau de 10 char (il ne faut pas oublier le caractère de fin de chaîne \0 qui occupe la 10e position).

Le problème, c'est que le reste de la chaîne qui n'a pas pu être lu, à savoir « ard Albert 1er », n'a pas disparu ! Il est toujours dans le buffer. Le buffer est une sorte de zone mémoire qui reçoit directement l'entrée clavier et qui sert d'intermédiaire entre le clavier et votre tableau de stockage. En C, on dispose d'un pointeur vers le buffer, c'est ce fameux stdin dont je vous parlais un peu plus tôt.

Je crois qu'un petit schéma ne sera pas de refus pour mettre les idées au clair (fig. suivante).

Lecture du buffer du clavier

Lorsque l'utilisateur tape du texte au clavier, le système d'exploitation (Windows, par exemple) copie directement le texte tapé dans le buffer stdin. Ce buffer est là pour recevoir temporairement l'entrée du clavier.

Le rôle de la fonction fgets est justement d'extraire du buffer les caractères qui s'y trouvent et de les copier dans la zone mémoire que vous lui indiquez (votre tableau chaine).
Après avoir effectué son travail de copie, fgets enlève du buffer tout ce qu'elle a pu copier.

Si tout s'est bien passé, fgets a donc pu copier tout le buffer dans votre chaîne, et ainsi le buffer se retrouve vide à la fin de l'exécution de la fonction. Mais si l'utilisateur entre beaucoup de caractères, et que la fonction fgets ne peut copier qu'une partie d'entre eux (parce que vous avez alloué un tableau de 10 char seulement), seuls les caractères lus seront supprimés du buffer. Tous ceux qui n'auront pas été lus y resteront !

Testons avec une longue chaîne :

int main(int argc, char *argv[])
{
    char nom[10];
 
    printf("Quel est votre nom ? ");
    lire(nom, 10);
    printf("Ah ! Vous vous appelez donc %s !\n\n", nom);
 
    return 0;
}
Quel est votre nom ? Jean Edouard Albert 1er
Ah ! Vous vous appelez donc Jean Edou !

La fonction fgets n'a pu copier que les 9 premiers caractères comme prévu. Le problème, c'est que les autres se trouvent toujours dans le buffer (fig. suivante) !

Lecture du buffer du clavier avec débordement

Cela signifie que si vous faites un autre fgets ensuite, celui-ci va aller récupérer ce qui était resté en mémoire dans le buffer !

Testons ce code :

int main(int argc, char *argv[])
{
    char nom[10];
 
    printf("Quel est votre nom ? ");
    lire(nom, 10);
    printf("Ah ! Vous vous appelez donc %s !\n\n", nom);
    lire(nom, 10);
    printf("Ah ! Vous vous appelez donc %s !\n\n", nom);
 
    return 0;
}

Nous appelons deux fois la fonction lire. Pourtant, vous allez voir qu'on ne vous laisse pas taper deux fois votre nom : en effet, la fonction fgets ne demande pas à l'utilisateur de taper du texte la seconde fois car elle trouve du texte à récupérer dans le buffer !

Quel est votre nom ? Jean Edouard Albert 1er
Ah ! Vous vous appelez donc Jean Edou !
 
Ah ! Vous vous appelez donc ard Alber !

Si l'utilisateur tape trop de caractères, la fonction fgets nous protège contre le débordement de mémoire, mais il reste toujours des traces du texte en trop dans le buffer. Il faut vider le buffer.

On va donc améliorer notre petite fonction lire et appeler — si besoin est — une sous-fonction viderBuffer pour faire en sorte que le buffer soit vidé si on a rentré trop de caractères :

void viderBuffer()
{
    int c = 0;
    while (c != '\n' && c != EOF)
    {
        c = getchar();
    }
}
 
int lire(char *chaine, int longueur)
{
    char *positionEntree = NULL;
 
    if (fgets(chaine, longueur, stdin) != NULL)
    {
        positionEntree = strchr(chaine, '\n');
        if (positionEntree != NULL)
        {
            *positionEntree = '\0';
        }
        else
        {
            viderBuffer();
        }
        return 1;
    }
    else
    {
        viderBuffer();
        return 0;
    }
}

La fonction lire appelle viderBuffer dans deux cas :

  • si la chaîne était trop longue (on le sait parce qu'on n'a pas trouvé de caractère \n dans la chaîne copiée) ;

  • s'il y a eu une erreur (peu importe laquelle), il faut vider là aussi le buffer par sécurité pour qu'il n'y ait plus rien.

La fonction viderBuffer est courte mais dense. Elle lit dans le buffer caractère par caractère grâce à getchar. Cette fonction renvoie un int (et non un char, allez savoir pourquoi, peu importe).
On se contente de récupérer ce int dans la variable temporaire c. On boucle tant qu'on n'a pas récupéré le caractère \n et le symbole EOF (fin de fichier), qui signifient tous deux « vous êtes arrivé à la fin du buffer ». On s'arrête donc de boucler dès que l'on tombe sur l'un de ces deux caractères.

C'est un peu compliqué au premier abord et assez technique, mais ça fait son travail. N'hésitez pas à relire ces explications plusieurs fois si nécessaire pour comprendre comment ça fonctionne.

Convertir la chaîne en nombre

Notre fonction lire est maintenant efficace et robuste, mais elle ne sait lire que du texte. Vous devez vous demander : « Mais comment fait-on pour récupérer un nombre ? »

En fait, lire est une fonction de base. Avec fgets, vous ne pouvez récupérer que du texte, mais il existe d'autres fonctions qui permettent de convertir ensuite un texte en nombre.

strtol : convertir une chaîne en long

Le prototype de la fonction strtol est un peu particulier :

long strtol( const char *start, char **end, int base );

La fonction lit la chaîne de caractères que vous lui envoyez (start) et essaie de la convertir en long en utilisant la base indiquée (généralement, on travaille en base 10 car on utilise 10 chiffres différents de 0 à 9, donc vous mettrez 10). Elle retourne le nombre qu'elle a réussi à lire.
Quant au pointeur de pointeur end, la fonction s'en sert pour renvoyer la position du premier caractère qu'elle a lu et qui n'était pas un nombre. On ne s'en servira pas, on peut donc lui envoyer NULL pour lui faire comprendre qu'on ne veut rien récupérer.

La chaîne doit commencer par un nombre, tout le reste est ignoré. Elle peut être précédée d'espaces.
Quelques exemples d'utilisation pour bien comprendre le principe :

long i;
 
i = strtol( "148", NULL, 10 ); // i = 148
i = strtol( "148.215", NULL, 10 ); // i = 148
i = strtol( "   148.215", NULL, 10 ); // i = 148
i = strtol( "   148+34", NULL, 10 ); // i = 148
i = strtol( "   148 feuilles mortes", NULL, 10 ); // i = 148
i = strtol( "   Il y a 148 feuilles mortes", NULL, 10 ); // i = 0 (erreur : la chaîne ne commence pas par un nombre)

Toutes les chaînes qui commencent par un nombre (ou éventuellement par des espaces suivis d'un nombre) seront converties en long jusqu'à la première lettre ou au premier caractère invalide (., +, etc.).

La dernière chaîne de la liste ne commençant pas par un nombre, elle ne peut pas être convertie. La fonction strtol renverra donc 0.

On peut créer une fonction lireLong qui va appeler notre première fonction lire (qui lit du texte) et ensuite convertir le texte saisi en nombre :

long lireLong()
{
    char nombreTexte[100] = {0}; // 100 cases devraient suffire
 
    if (lire(nombreTexte, 100))
    {
        // Si lecture du texte ok, convertir le nombre en long et le retourner
        return strtol(nombreTexte, NULL, 10);
    }
    else
    {
        // Si problème de lecture, renvoyer 0
        return 0;
    }
}

Vous pouvez tester dans un main très simple :

int main(int argc, char *argv[])
{
    long age = 0;
 
    printf("Quel est votre age ? ");
    age = lireLong();
    printf("Ah ! Vous avez donc %d ans !\n\n", age);
 
    return 0;
}
Quel est votre age ? 18
Ah ! Vous avez donc 18 ans !

strtod : convertir une chaîne en double

La fonction strtod est identique à strtol, à la différence près qu'elle essaie de lire un nombre décimal et renvoie un double :

double strtod( const char *start, char **end );

Vous noterez que le troisième paramètre base a disparu ici, mais il y a toujours le pointeur de pointeur end qui ne nous sert à rien.

Contrairement à strtol, la fonction prend cette fois en compte le « point » décimal. Attention, en revanche : elle ne connaît pas la virgule (ça se voit que ça a été codé par des Anglais).

À vous de jouer ! Écrivez la fonction lireDouble. Vous ne devriez avoir aucun mal à le faire, c'est exactement comme lireLong à part que cette fois, on appelle strtod et on retourne un double.

Vous devriez alors pouvoir faire ceci dans la console :

Combien pesez-vous ? 67.4
Ah ! Vous pesez donc 67.400000 kg !

Essayez ensuite de modifier votre fonction lireDouble pour qu'elle accepte aussi le symbole virgule comme séparateur décimal. La technique est simple : remplacez la virgule par un point dans la chaîne de texte lue (grâce à la fonction de recherche strchr), puis envoyez la chaîne modifiée à strtod.

En résumé

  • La fonction scanf, bien qu'en apparence simple d'utilisation, est en fait très complexe et nous oppose certaines limites. On ne peut pas, par exemple, écrire plusieurs mots à la fois facilement.

  • Un buffer overflow survient lorsqu'on dépasse l'espace réservé en mémoire, par exemple si l'utilisateur entre 10 caractères alors qu'on n'avait réservé que 5 cases en mémoire.

  • L'idéal est de faire appel à la fonction fgets pour récupérer du texte saisi par l'utilisateur.

  • Il faut en revanche éviter à tout prix d'utiliser la fonction gets qui n'offre pas de protection contre le buffer overflow.

  • Vous pouvez écrire votre propre fonction de saisie du texte qui fait appel à fgets comme on l'a fait afin d'améliorer son fonctionnement.

Example of certificate of achievement
Example of certificate of achievement