Allocation de mémoire sécurisée en C et C++

L'auteur

Site personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

Le but de cet article est d'écrire un allocateur de mémoire sécurisé. Par « sécurisé », j'entends qu'il devra être capable de :

  1. Générer une erreur en cas d'échec d'allocation de mémoire (plus assez de mémoire disponible).
  2. Lister la mémoire (les pointeurs) non libérée (libérés).
  3. Ajoute des informations à chaque pointeur alloué : nom du fichier et ligne du fichier où le pointeur a été alloué, taille du pointeur, signature (pour vérifier que le pointeur soit valide), etc.
  4. Vérifier les écritures en dehors de la zone mémoire allouée (avant : « underflow », et après : « overflow »).
  5. Donner la possibilité d'activer ou désactiver les différentes fonctions (à la compilation).
  6. Tout cela doit pouvoir s'appliquer sans modifier le code source existant.

… Facile ! Ne vous inquiétez pas, je vais vous guider pas à pas.

Cet article est basé sur une implémentation en langage C, avec une extension pour le langage C++. Je pense qu'on peut adapter le principe à d'autres langages, mais il faudra modifier le code source.

ATTENTION : Le but de nos modifications ne visent non pas à rendre un programme plus sûr et inviolable, mais à corriger des erreurs dans le code source d'un programme.

Vous vous en douterez sûrement, ces modifications ont un coût sur les performances. C'est pour cela que j'ai rajouté le point (6) dans notre cahier des charges : il faut pouvoir désactiver une ou plusieurs (toutes) options. Je pense que le point (1) devrait toujours être actif : écrire dans un pointeur NULL provoque une erreur de segmentation, quel que soit le système d'exploitation !!!

NOTE : Rien ne sert de s'amuser à copier/coller le code source, un exemple complet est fourni à la fin de cette page.

II. Allocation de mémoire en C/C++

Le langage C nous offre plusieurs fonctions d'allocation de mémoire :

  • malloc (n) : alloue n octets de mémoire ;
  • calloc (n, taille) : alloue n*taille octets de mémoire ;
  • realloc (ptr, n) : redimensionne le pointeur ptr pour qu'il atteigne la taille de n octets (qui peut être inférieur ou supérieur à l'ancienne taille de ptr) ;
  • free (ptr) : libère la mémoire qui était allouée à l'adresse ptr.

Et il existe encore des fonctions dérivées comme strdup qui est une combinaison de strlen (lit la longueur d'une chaîne de caractère), malloc (allocation de mémoire) et strcpy (copie les données d'une chaîne de caractères dans une autre).

Le langage C++ nous offres quatre opérateurs (et non plus fonctions) :

  • new objet : crée un objet ;
  • new objet[n] : crée un tableau de n objet(s) ;
  • delete ptr : efface un objet ;
  • delete []ptr : efface un tableau d'objets.

En plus d'allouer la mémoire, il va également appeler le « constructeur » de chaque objet après l'allocation. Ceci est géré automatiquement par l'opérateur new.

III. Prototype des fonctions d'allocations C

On va commencer par redéfinir les fonctions d'allocation de mémoire en C. Pour cela, il faut commencer par réécrire les fonctions en se basant sur le prototype standard du C :

 
Sélectionnez
void *malloc(size_t size);
void *realloc(void *block, size_t size);
void *calloc(size_t nitems, size_t size);
void free(void *block); 

Pour éviter les conflits de noms, on va les appeler : MallocSecurise, ReallocSecurise, CallocSecurise et FreeSecurise. Il faut maintenant rajouter automatiquement le nom du fichier et la ligne du fichier où le pointeur a été alloué. Pour cela, le langage C nous offre deux macros : __FILE__ (nom : const char *) et __LINE__ (ligne : unsigned int). Le problème est de les passer automatiquement en paramètre sans modifier le code source existant… Heureusement, le langage C nous laisse la possibilité d'écrire des macros (#define). On pourra donc utiliser :

 
Sélectionnez
// Prototypes
void *malloc(size_t size, const char* nomfich, unsigned int numligne);
void *realloc(void *block, size_t size, const char* nomfich, unsigned int numligne);
void *calloc(size_t nitems, size_t size, const char* nomfich, unsigned int numligne);
void free(void *block, const char* nomfich, unsigned int numligne);

// Macros
#define malloc(size) MallocSecurise(size, __FILE__, __LINE__)
#define realloc(ptr,new_size) ReallocSecurise(ptr, new_size, __FILE__, __LINE__)
#define calloc(n,size) CallocSecurise(n, size, __FILE__, __LINE__)
#define free(ptr) FreeSecurise(ptr, __FILE__, __LINE__) 

IV. Prototype des fonctions d'allocations C++

Pour le langage C++, on va redéfinir :

 
Sélectionnez
void* operator new (size_t size) throw (std::bad_alloc);
void* operator new[] (size_t size) throw (std::bad_alloc);
void operator delete (void *) throw ();
void operator delete[] (void *ptr) throw ();

Petit problème : on ne modifie plus des fonctions, mais des opérateurs. De plus, ces opérateurs sont spéciaux, car new doit appeler le créateur de l'objet après l'avoir alloué. Pas question donc de renommer ces opérateurs ! Après avoir fait des tests, j'ai compris qu'on pouvait changer le nombre de paramètres de l'opérateur new, mais pas de l'opérateur delete… Cela rend l'ajout automatique des paramètres encore un peu plus difficile. Il faut alors passer par une variable globale pour l'opérateur delete. Pour l'opérateur new, on va reprendre Ce qui va nous donner :

 
Sélectionnez
// Variables globales
extern const char *delete_FILE;
extern unsigned long delete_LINE;

// Opérateurs
void* operator new (size_t size, const char* nomfich, unsigned int numligne) throw (std::bad_alloc);
void* operator new[] (size_t size, const char* nomfich, unsigned int numligne) throw (std::bad_alloc);
void operator delete (void *) throw ();
void operator delete[] (void *ptr) throw ();

// Macro
#define new new (__FILE__, __LINE__)
#define delete delete_FILE=__FILE__, delete_LINE=__LINE__, delete

La macro « #define new new (__FILE__, __LINE__) » est un peu bizarre, « new nombre; » devient « new (__FILE__, __LINE__) nombre; ». C'est spécial, mais le principal n'est pas que ça marche ?

Utiliser des virgules pour « #define delete… » n'est pas très beau, mais ça marche ! Exemple de code qui poserait problème avec des points virgules :

 
Sélectionnez
for (unsigned int i=0; i<nbr_element; i++) delete liste[i];

Ce qui donne :

 
Sélectionnez
a) for (unsigned int i=0; i<nbr_element; i++) delete_FILE=__FILE__, delete_LINE=__LINE__, delete liste[i];

b) for (unsigned int i=0; i<nbr_element; i++) delete_FILE=__FILE__; delete_LINE=__LINE__; delete liste[i];

Vous ne voyez vraiment pas l'erreur dans b) ? Je vais vous aider : le compilateur bloque avec « symbole 'i' non défini »… Note : le code a) est tout à fait valide !

V. Lister les pointeurs et structures des informations

L'algorithme le plus adapté pour lister des pointeurs alloués tout au long de l'exécution du programme est la liste doublement chaînée. J'ai testé avec une liste simplement chaînée, mais il fallait parcourir toute la liste pour supprimer un élément (pour corriger l'élément précédent) : dès qu'on dépassait 1000 éléments, les performances chutaient rapidement…

Pour les informations ajoutées à chaque pointeur, je vous propose :

 
Sélectionnez
// Informations écrites avant les données du pointeur
typedef struct
{
  unsigned long nbr_magique; // Nombre magique (signature)
  bool est_alloue; // Pointeur alloué ou libéré ?
  size_t taille; // Taille allouée (sans informations de débogage)
  const char* nomfich; // Nom du fichier source
  ulong numligne; // Ligne du fichier
  struct PtrChaineMalloc *ptr_liste; // Pointeur dans la liste
  unsigned long somme_info; // Somme des informations (checksum)
} InfoMalloc;

Le membre « somme_info » est la somme simple (sur, au moins, 32 bits) des autres membres. Le membre « ptr_liste » est un pointeur vers l'élément de la liste des pointeurs alloués. Je ne pense pas que les autres membres ont besoin d'explications supplémentaires. Pour les pointeurs, je vous propose cette structure :

 
Sélectionnez
// Un pointeur chaîné
struct PtrChaineMalloc
{
  struct PtrChaineMalloc *precedent; // Elément suivant
  struct PtrChaineMalloc *suivant; // Elément suivant
  ulong nbr_magique; // Nombre magique (signature)
  InfoMalloc *ptr_info; // Pointeur à libérer
};

On retrouve les membres « précédent » et « suivant » du même type que la structure, typiques des listes doublement chaînées. Le membre « nbr_magique » permet de vérifier que le pointeur est bien un pointeur « valide ».

Pour la structure de la liste elle-même, j'utilise :

 
Sélectionnez
// Le conteneur Malloc
struct
{
  struct PtrChaineMalloc *debut; // Pointeur vers le premier élément
  struct PtrChaineMalloc *fin; // Pointeur vers le dernier élément
} ConteneurMalloc = {NULL,NULL};

La liste est initialisée à {debut=NULL, fin=NULL} automatiquement !

Nous aurons besoin de deux fonctions :

  • ajout d'un élément dans la liste ;
  • suppression d'un élément de la liste.

VI. Vérification des écritures en dehors de la zone mémoire allouée

Vocabulaire :

  • une erreur d'underflow (mot anglais) est une écriture en dehors d'un tampon, avant la mémoire allouée ;
  • une erreur d'overflow (mot anglais) est une écriture en dehors d'un tampon, après la mémoire allouée.

Ces deux erreurs sont très dangereuses, car dans la plupart des ordinateurs le code source et les données sont placées dans le même espace mémoire. On peut donc modifier le programme en écrivant des instructions invalides. Dans le meilleur des cas, la mémoire n'est pas utilisée, et le programme continue comme si de ne rien n'était. Mais dans la plupart des cas, le programme réagit très bizarrement (plantages divers), ou alors une erreur de segmentation (SIGSEGV) est générée.

Il existe une méthode très simple à mettre en œuvre : on alloue quelques octets en plus, on initialise la mémoire ajoutée en plus, enfin on vérifie que cette zone n'a pas été modifiée. Pour l'initialisation, le mieux est d'écrire une chaîne aléatoire, mais je préfère écrire une chaîne simple (on part d'une valeur, puis on incrémente à chaque itération). La taille de cette mémoire ajoutée ne doit pas être trop importante, car chaque pointeur prendra cette taille en plus (ajoute 1024 octets sur 1024 pointeurs consomme « inutilement » 1 Mo…). J'ai choisi 16 octets, mais 4 octets pourrait suffire ! Je rappelle que le but de nos modifications ne visent pas à rendre un programme inviolable, mais corriger des erreurs…

Pour les erreurs d'underflow, la signature de nos informations et la « checksum » (somme de vérification) vont s'occuper de les détecter.

VII. Explications de l'implémentation en C/C++

Je ne vais pas détailler tout le code, mais je vais vous présenter les deux fonctions principales : malloc et free.

Notre fonction malloc :

 
Sélectionnez
// Taille du tampon anti-overflow (en octets)
#define TAILLE_TAMPON_OVERFLOW 16

// Nombres magiques (32 bits) pour valider un pointeur
#define MALLOC_NBR_MAGIQUE 0x1A71A25C

// Allocation de mémoire sécurisée (malloc)
void* MallocSecurise (size_t size, const char *nomfich, unsigned long numligne)
{
  // Tente d'allouer le tampon
  void* ptr;
  size_t taille_allouee;
  InfoMalloc *info;
  char *overflow;
  unsigned long i;
  unsigned char rand = 0xA0;

  // Taille nulle : Erreur!
  if (size == 0) ERREUR ("Taille du pointeur à allouer nulle !");

  // Ajoute la taille des informations sur le pointeur
  taille_allouee = size +sizeof(InfoMalloc) +TAILLE_TAMPON_OVERFLOW;

  // Alloue la mémoire (appelle le malloc du langage C)
  ptr = malloc(taille_allouee);

  // Pointeur NULL : erreur!
  if (ptr == NULL)
    ERREUR ("Allocation de mémoire impossible : pas assez de mémoire libre !");

  // Ajoute le pointeur au conteneur
  info = (InfoMalloc *)ptr;
  CONTENEUR_MALLOC_AJOUTE (info);

  // Ecrit les informations sur le pointeur
  info -> nbr_magique = MALLOC_NBR_MAGIQUE;
  info -> est_alloue = true;
  info -> taille = taille_allouee;
  info -> nom_fich = nomfich;
  info -> num_ligne = numligne;
  info -> somme_info = MallocCalculSomme (info);

  // Prépare le tampon anti-overflow
  overflow = (char *)info +taille_allouee;
  for (i=0; i<TAILLE_TAMPON_OVERFLOW; i++) *ptr++ = rand++;

  // Renvoie le pointeur vers l'espace alloué vierge
  return (info+1);
}

// Calcule la somme de contrôle des informations d'un pointeur
ulong MallocCalculeSomme (const InfoMalloc *info)
{
  // Calcule la somme des données d'un pointeur
  return
   ((ulong)(info -> nbr_magique)
   +(ulong)(info -> est_alloue)
   +(ulong)(info -> taille)
   +(ulong)(info -> est_local)
   +(ulong)(info -> nom_fich)
   +(ulong)(info -> num_ligne));
}

Notre fonction free :

 
Sélectionnez
// Fonction 'free' sécurisé
void FreeSecurise (const void *ptr,
                     const char* nomfich, const ulong numligne)
{
  infoMalloc *info;
  ulong somme;

  // Pointeur NULL : La norme demande de ne rien faire, on va pas se gêner!
  if (ptr == NULL) return;

  // Lit les informations du pointeur
  info = (InfoMalloc *)ptr-1;

  //--- Vérifie les informations ---
  if (info -> nbr_magique != MALLOC_NBR_MAGIQUE)
    ERREUR ("Pointeur invalide !");

  // Calcule la somme des données
  if (info -> somme_info != MallocCalculeSomme (info))
    ERREUR ("Somme de vérification incorrect (%s:%u) !",
            info -> nom_fich, info -> num_ligne);

  // Vérifie qu'on est pas écrit en dehors du tampon
  if (!Malloc_TamponOverflowIntact(info))
    ERREUR ("Erreur d'overflow (%s:%u) !",
            info -> nom_fich, info -> num_ligne);

  // Sort le pointeur du conteneur en vérifiant les
  // informations sur le pointeur
  CONTENEUR_MALLOC_EFFACE (info -> ptr_liste);

  // Le pointeur n'est plus alloué ! (evite de libérer deux fois le même
  // pointeur) Même si la mémoire est 'libérée', elle n'est pas effacée
  // avec un 'free'.
  info -> est_alloue = false;

  // Enfin, libère la mémoire
  free (info);
}

Notes :

  • ERREUR (format, …) est une fonction affichant un message d'erreur (ayant la forme de printf) puis quittant le programme ;
  • CONTENEUR_MALLOC_AJOUTE (info) est une fonction qui ajoute un pointeur dans la liste des pointeurs ;
  • CONTENEUR_MALLOC_EFFACE (info) est une fonction qui efface un pointeur de la liste des pointeurs.

Bien sûr, cet exemple est (volontairement) simplifié. Il est possible de mieux écrire le code, en particulier placer certaines parties dans des fonctions.

Les autres fonctions se résument à :

  • realloc : on utilise la fonction realloc du C, puis on retrouve le pointeur dans la liste des pointeurs, et on corrige son adresse ;
  • calloc : ça se résume à appler notre malloc, puis faire un memset ;
  • strdup : la bonne combinaison de strlen, (notre) malloc et strcpy ;
  • new et new[] : appelle simplement notre malloc ;
  • delete et delete[] : appelle simplement notre free.

VIII. Code complet à télécharger

AVERTISSEMENT : Le code source proposé est sous licence GPL, je vous invite à la lire avant de réutiliser ce code !

Télécharger le code source sous licence GPL :

Je vous conseille également de télécharger le code source de ma calculatrice, car vous y trouverez la dernière version de ma librairie HAlloc (dans le répertoire « include » du code source). D'ailleurs le code donné plus haut est une version (très) simplifiée, même si elle est utilisable.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2013 haypo. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.