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 :
- Générer une erreur en cas d'échec d'allocation de mémoire (plus assez de mémoire disponible) ;
- Lister la mémoire (les pointeurs) non libérée (libérés) ;
- 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. ;
- Vérifier les écritures en dehors de la zone mémoire allouée (avant : « underflow », et après : « overflow ») ;
- Donner la possibilité d'activer ou désactiver les différentes fonctions (à la compilation) ;
- 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 vise 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 offre 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 :
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 :
// 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 :
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 :
// 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 :
for
(
unsigned
int
i=
0
; i<
nbr_element; i++
) delete liste[i];
Ce qui donne :
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 :
// 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 :
// Un pointeur chaîné
struct
PtrChaineMalloc
{
struct
PtrChaineMalloc *
precedent; // Élément suivant
struct
PtrChaineMalloc *
suivant; // Élé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 :
// 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és 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 (ajouter 1024 octets sur 1024 pointeurs consomme « inutilement » 1 Mo…). J'ai choisi 16 octets, mais 4 octets pourraient suffire ! Je rappelle que le but de nos modifications ne vise 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 :
// 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 :
// 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 ne 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 incorrecte (%s:%u) !
"
,
info ->
nom_fich, info ->
num_ligne);
// Vérifie qu'on n'a 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é ! (évite 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 à appeler 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 :
- halloc.h : Entête de la librairie ;
- halloc.cpp : Entête de la librairie (pour du code en C, il suffit de renommer le fichier en « halloc.c ») ;
- main.c : Exemple en C ;
- main.cpp : Exemple en C++ ;
- les 4 fichiers en archive ZIP.
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.