-ftrivial-auto-var-init : le prix du progrès

Magazine
Marque
MISC
Numéro
127
Mois de parution
mai 2023
Spécialité(s)


Résumé

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>.


Body

Tout relecteur à l’œil un peu exercé saura trouver (au moins) une erreur dans le code suivant :

#include <stdio.h>
void scan_int(char const* data) {
    int value;
    sscanf(data, "%d", &value);
    printf("value=%d\n", value);
}

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 :

==658164== Use of uninitialised value of size 8
==658164==    at 0x48C398B: _itoa_word (_itoa.c:177)
==658164==    by 0x48DF574: __vfprintf_internal (vfprintf-internal.c:1516)
==658164==    by 0x48C9A2E: printf (printf.c:33)
==658164==    by 0x40116F: scan_int (a.c:7)

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 :

#include <stdio.h>
void scan_int(char const* data) {
    int value = 0;
    sscanf(data, "%d", &value);
    printf("value=%d\n", value);
}

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 :

#include <string.h>
int len(char const* data) {
    int value = 0;
    if (data) value = strlen(value);
    else value = 0;
    return value;
}

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 :

#include <stdio.h>
int len(char const* data) {
    int value;
    if (data) value = strlen(value);
    return value;
}

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 :

#include <sys/stat.h>
int main(int argc, char ** argv) {
    __attribute__((uninitialized)) struct stat sb;
if (lstat(argv[1], &sb) == -1) {
    perror("lstat");
    exit(EXIT_FAILURE);
}
/* do stuff with sb */
return 0;
}

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



Article rédigé par

Par le(s) même(s) auteur(s)

Les derniers articles Premiums

Les derniers articles Premium

Le combo gagnant de la virtualisation : QEMU et KVM

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

C’est un fait : la virtualisation est partout ! Que ce soit pour la flexibilité des systèmes ou bien leur sécurité, l’adoption de la virtualisation augmente dans toutes les organisations depuis des années. Dans cet article, nous allons nous focaliser sur deux technologies : QEMU et KVM. En combinant les deux, il est possible de créer des environnements de virtualisation très robustes.

Brève introduction pratique à ZFS

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Il est grand temps de passer à un système de fichiers plus robuste et performant : ZFS. Avec ses fonctionnalités avancées, il assure une intégrité des données inégalée et simplifie la gestion des volumes de stockage. Il permet aussi de faire des snapshots, des clones, et de la déduplication, il est donc la solution idéale pour les environnements de stockage critiques. Découvrons ensemble pourquoi ZFS est LE choix incontournable pour l'avenir du stockage de données.

Générez votre serveur JEE sur-mesure avec Wildfly Glow

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Et, si, en une ligne de commandes, on pouvait reconstruire son serveur JEE pour qu’il soit configuré, sur mesure, pour les besoins des applications qu’il embarque ? Et si on pouvait aller encore plus loin, en distribuant l’ensemble, assemblé sous la forme d’un jar exécutable ? Et si on pouvait même déployer le tout, automatiquement, sur OpenShift ? Grâce à Wildfly Glow [1], c’est possible ! Tout du moins, pour le serveur JEE open source Wildfly [2]. Démonstration dans cet article.

Les listes de lecture

11 article(s) - ajoutée le 01/07/2020
Clé de voûte d'une infrastructure Windows, Active Directory est l'une des cibles les plus appréciées des attaquants. Les articles regroupés dans cette liste vous permettront de découvrir l'état de la menace, les attaques et, bien sûr, les contre-mesures.
8 article(s) - ajoutée le 13/10/2020
Découvrez les méthodologies d'analyse de la sécurité des terminaux mobiles au travers d'exemples concrets sur Android et iOS.
10 article(s) - ajoutée le 13/10/2020
Vous retrouverez ici un ensemble d'articles sur les usages contemporains de la cryptographie (whitebox, courbes elliptiques, embarqué, post-quantique), qu'il s'agisse de rechercher des vulnérabilités ou simplement comprendre les fondamentaux du domaine.
Voir les 67 listes de lecture

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous