En C et en C++, les variables déclarées sur la pile sans initialisation n’ont pas de valeur par défaut. Accéder à leur valeur est à la fois une source de comportement indéfini et une source de fuite de données reconnue. De telles situations sont complexes à détecter à la compilation et coûteuses à détecter à l’exécution. Cet article propose d’explorer une alternative disponible dans les compilateurs clang et gcc : -ftrivial-auto-var-init=<value>.
Tout relecteur à l’œil un peu exercé saura trouver (au moins) une erreur dans le code suivant :
Et pourtant ni GCC ni Clang n’affichent d’avertissement à la compilation de cette fonction. Par contre si on l’appelle avec data positionné à "vive misc", on obtient un message d’erreur non équivoque lors d’une exécution dans Valgrind :
En effet, on se retrouve à afficher à l’écran une valeur lue depuis la pile, non initialisée, car c’est ce que permet le standard C et que c’est plus rapide de ne rien faire que de positionner une valeur par défaut.
On n’apprendra pas au lecteur de MISC l’impact potentiel d’un tel comportement : la valeur de value va dépendre de ce qui a été écrit sur la pile précédemment, fuitant ainsi de potentielles données importantes (stack cookie, clef ou mot de passe, adresse permettant de deviner la valeur de base utilisée par l’ASLR, etc.).
Ce problème est si fréquent dans les codes C que quelqu’un a eu l’idée assez triviale de forcer une valeur par défaut pour toute variable allouée sur la pile. C’est compatible avec le standard et cela réduit les fuites de données à travers la pile. Mais à quel prix ?
1. Bienvenue -ftrivial-auto-var-init=zero
L’idée parait simple : et si on forçait toutes les allocations sur la pile à zéro ? Après tout c’est déjà le comportement standard pour les variables globales et les variables statiques. Cette approche propose plusieurs avantages : elle est reproductible, elle est rapide et elle est facile à implémenter. En effet, cela revient à écrire dans le code mentionné en introduction :
Et cela suffit à satisfaire Valgrind. Notons néanmoins que le code n’est pas devenu correct (il faudrait vérifier la valeur de retour de sscanf), mais au moins il ne fuite plus d’information.
Cette approche a la bonne idée de parfois avoir un coût nul. Prenons en exemple le code suivant :
Un compilateur avec les optimisations adéquates (-O1 suffit pour Clang et GCC) saura supprimer l’assignation value = 0, puisque tous les chemins menant à la lecture de value passent par une autre écriture que cette initialisation. Malheureusement, ce n’est pas le cas en général, même quand l’initialisation est inutile (penser par exemple aux conséquences d’un quelconque appel à une fonction définie dans une autre bibliothèque).
Mais cette approche est-elle suffisante ? Il se trouve qu’elle a plusieurs défauts.
Premièrement pour des objets de grande taille alloués sur la pile, l’initialisation s’avère coûteuse. Et de telles initialisations sont plus fréquentes qu’on le penserait : un petit buffer char buffer[256] ou une structure standard de type struct statbuf suffisent.
Deuxièmement, forcer la valeur à zéro introduit un comportement non standard sur lequel on peut avoir envie de se reposer. On pourrait alors voir apparaître une « simplification » du code précédent :
Qui se comporterait comme la version originale, mais seulement si on utilise le bon drapeau de compilation, ce qui n’est pas franchement une bonne idée.
Troisièmement, la valeur zéro ayant souvent un sens, elle risque de faire en sorte que le programme tombe en marche. Un pointeur à zéro, un flottant à zéro, un entier à zéro, autant de cas qui peuvent être traités normalement par le programme sans déclencher d’alerte.
2. Bienvenue -ftrivial-auto-var-init=pattern
À défaut d’utiliser la valeur zéro, on peut utiliser un motif particulier, qui a de fortes chances de déclencher un comportement inattendu. L’auteur original du patch dans clang pour cette option propose astucieusement le « screaming pattern », à base de 0xAAAAAAAA. C’est une valeur entière incongrue. Si on la préfixe de 0xFFF on obtient un « screaming not a number » flottant (qui a la bonne idée de se propager à travers les opérations arithmétiques, et donc de faire surface plus facilement). Sur 64 bits, cela fait une adresse mémoire généralement non adressable (à moins d’avoir une mémoire de plus de 1e19 octets), etc.
Notons cependant que Linus Torvald préfère l’initialisation à zéro [1].
Niveau implémentation, le compilateur génère systématiquement un appel à memset après chaque allocation, et cet appel est ensuite soit supprimé (meilleur des cas), soit transformé en un ou plusieurs chargements mémoires explicites (cas des petites allocations de taille fixe), soit conservé (cas des grosses allocations ou des allocations à taille dynamique, i.e. appels à alloca ou allocation de variable length array).
3. Bienvenue __attribute__((uninitialized))
Si l’on accepte de passer à -ftrivial-auto-var-init=pattern, on peut vouloir contrôler finement la génération des initialisations, afin d’éviter le surcoût de l’initialisation quand on sait que l’initialisation n’est pas nécessaire, même si le compilateur n’arrive pas à le prouver. Ainsi, un peu à la manière du marqueur unsafe que l’on retrouve en Rust, on peut se contenter d’auditer le code en se concentrant sur les zones marquées explicitement.
Cela se traduit par un attribut porté par des variables initialisées sur la pile : __attribute__((uninitialized)) ou (en C++) [[clang::uninitialized]]. Cette approche est incrémentale, portable et permet d’avoir le meilleur des deux mondes.
Par exemple, dans le cas suivant le compilateur ne sait pas que lstat va correctement initialiser la variable sb si la valeur de retour est différente de -1, mais après une relecture minutieuse du code, le développeur peut se permettre de forcer la non-initialisation de cette variable :
4. Vers une évolution du standard ?
L’auteur du patch dans clang/LLVM a récemment (novembre 2022) proposé une évolution du standard C++ [2] pour harmoniser l’initialisation des variables allouées sur la pile avec celles allouées de manière globale : tout à zéro. Cette proposition suggère également la normalisation d’une porte de sortie à travers l’attribut [[uninitialized]].
Prévoyant une levée de boucliers pour des raisons de performance, en vertu du principe qu’on ne paie que pour ce que l’on demande, l’auteur de la proposition avance que d’une, les performances des options actuelles sont déjà très bonnes en pratique, et que de deux, les compilateurs ont encore une belle marge de manœuvre dans leur capacité à supprimer les initialisations inutiles, avec nombre patch à l’appui.
On pourrait rétorquer que la situation actuelle est déjà satisfaisante, et que si l’on veut explicitement initialiser une variable à zéro, un appel à memset suffit. En termes de posture, l’auteur défend une approche sécurisée par défaut, dont on peut se désengager localement pour des raisons de performance, alors que l’état actuel est une approche rapide par défaut dont on peut se désengager localement pour des raisons de sécurité.
Conclusion
L’utilisation de -ftrivial-auto-var-init=pattern combinée à __attribute__((uninitialized)) permet au développeur soucieux d’éviter les fuites de données à travers la lecture de variables de pile non initialisées. Le surcoût est généralement négligeable et quand ce n’est pas le cas, on peut le contrôler finement. Cette option est utilisée par défaut dans le noyau Linux.
Références
[0] RFC sur la liste de discussion de clang/llvm :
https://lists.llvm.org/pipermail/cfe-dev/2018-November/060172.html
[1] Fil de discussion sur le sujet sur la liste du noyau : https://lkml.org/lkml/2019/7/28/194
[2] Proposition d’évolution du standard C++ : https://isocpp.org/files/papers/P2723R0.html