Machines et développeurs ont parfois du mal à communiquer. D’un côté l’esprit humain, avec sa volonté d’abstraire et de penser fonctionnel. De l’autre côté, la machine et sa froide rigueur électronique. Entre les deux, il y a un pont : le compilateur. Mais comment prend-il en compte les aspects sécurité ?
Soyons honnêtes : quand il compile un code en langage C, quel que soit le niveau d’optimisation demandé, un compilateur comme Clang ne prend pas en compte les aspects liés à la sécurité informatique. Son seul objectif est de traduire le code source en code machine, en essayant de générer un code qui exploitera au mieux les capacités de calcul de la machine (-O2), parfois sous contrainte de taille de binaire (-Os) ou de facilité de debug (-g). Voilà qui laisse peu de place à la sécurité, voire qui offre plein de vecteurs d’attaque potentiels. Supprimer un memset qui remet à zéro une partie de la pile avant de quitter une fonction ? Aucun problème ! Utiliser une valeur de la pile non initialisée ? Aucun problème ! Écrire au-delà des bornes d’un tableau ? Aucun problème ! À coup d’undefined behavior, le compilateur s’autorise toutes les astuces lui permettant une génération de code efficace.
Fort heureusement, parmi la foultitude d’options supportées par Clang, plusieurs concernent l’amélioration de la sécurité, mais encore faut-il les connaître ! C’est donc un petit tour d’horizon que nous allons faire à travers cet article, en nous intéressant à quatre catégories d’options : les warnings, les sanitizers, le support matériel et... les autres :-).
1. Warnings : -Wsomething
Les compilateurs proposent un ensemble de drapeaux qui permettent de détecter de potentiels problèmes (accès hors des bornes, utilisation dangereuse d’un printf…) sans empêcher la génération de code : ce sont les avertissements que l’on trouve sous la forme -Wwarning-name. Plusieurs avertissements ont trait à la sécurité. On retiendra :
- -Wformat-security : affiche un message d’avertissement en cas d’usage de fonction de formatage avec une chaîne de formatage non littérale. Par exemple :
- Ce genre d’appel permettrait, à travers l’usage du format %n, d’écrire une valeur arbitraire sur la pile. On note la suggestion faite par Clang pour corriger l’avertissement.
- -Warray-bounds : détecte certains accès de tableau hors des bornes. Tous les accès invalides ne sont pas détectés, sans surprise la présence d’informations statiques aide grandement le compilateur.
- Cet accès écrit au-delà des bornes d’un tableau déclaré ici sur la pile, ce qui conduit à une modification d’autres valeurs de la pile.
- -Warray-bounds-pointer-arithmetic : cette option est similaire à -Warray-bounds et détecte certaines références à des zones mémoire non adressables.
- Si la fonction foo attend en entrée un pointeur sur une zone valide, elle risque là encore de modifier d’autres valeurs de la pile.
- -Walloca et -Wvla : ces deux options sont similaires, car elles détectent l’usage de mécanismes permettant d’allouer dynamiquement de la mémoire sur la pile, soit à travers un appel à la fonction alloca définie dans le fichier d’entête standard <alloca.h>, soit à travers les tableaux de taille dynamique (Variable Lenght Array) disponibles depuis C99. Ce genre d’allocation peut conduire à une explosion de la taille de la pile, avec un stack overflow ou un stack clash à la clef.
- Si n est contrôlé par l’attaquant, ce dernier peut demander à allouer une valeur plus grande que celle prévue pour la pile au démarrage (valeur que l’on peut obtenir depuis le programme à travers l’appel système getrlimit et la ressource RLIMIT_STACK). Cela conduit alors au crash du programme (déni de service).
- -Wcmse-union-leak : cet avertissement est assez particulier, puisqu’il n’a de sens que sur les architectures ARM disposant de Cortex-M Security Extension (CMSE).
Pour comprendre son fonctionnement, observons le code source suivant :
La fonction fn2 déclenche un changement de mode du processeur de secure à unsecure, ce qui est nécessaire dans le modèle proposé par CMSE pour l’accès, par exemple, aux périphériques non sécurisés. Or, la fonction prend une union en paramètre. Cette union peut contenir des bits de padding (pour des raisons d’alignements des différents champs, par exemple), qui pourraient exfiltrer des bits d’information. Et ne sachant pas quel champ de l’union est actif, le compilateur ne peut pas forcer la valeur des champs de padding. L’utilisation de -Wcmse-union-leak renvoie alors :
Le correctif ici serait alors de ne pas passer l’union, mais le champ actif de celle-ci lors de l’appel à la fonction non sécurisée.
2. Sanitizers : -fsanitize=something
Les sanitizers sont une classe d’outils qui combinent instrumentation de code et runtime pour détecter certaines erreurs de programmation. On peut par exemple citer UBSan qui détecte à l’exécution la présence d’undefined behavior, par exemple un overflow d’entier signé. On les active à travers les options -fsanitize=nom-du-sanitizer.
Plusieurs sanitizers ayant un impact sur la sécurité du programme existent :
- -fsanitize=address : permet de détecter des accès hors des bornes, après libération de la mémoire, une double libération de la mémoire, etc. L’impact de cette instrumentation sur le temps d’exécution est très important (la documentation mentionne un doublement du temps d’exécution), il n’est donc pas conseillé d’utiliser ce sanitizer en production, mais il permet de détecter de potentiels problèmes quand il est couplé à une batterie de tests ou un fuzzer.
- On peut observer l’impact de ce sanitizer sur la sortie de clang -S -o- -fsanitize=address -Os sur un simple int foo(int* x) { return *x;} :
- Le code commence par charger dans %al une valeur située dans une zone mémoire dédiée à l’instrumentation, puis si cette valeur est non nulle, elle est utilisée pour vérifier que la zone mémoire déréférencée est valide et éventuellement déclencher un appel à __asan_report_load4.
- -fsanitize=memory : permet de détecter la lecture de valeurs non initialisées, pouvant conduire à de la fuite d’information de valeurs résidant précédemment sur la pile. Là encore, l’instrumentation a un impact important sur le temps d’exécution (fois 2 à fois 3 suivant les options utilisées), ce qui limite son usage à des configurations de test et non en production.
- Le poids de l’instrumentation est particulièrement visible sur la sortie de clang -S -o- -fsanitize=memory -O2 sur int foo() { int x ; return x ; } :
- Ce qui devrait n’être qu’une simple lecture de registre se transforme en appel de fonction !
- -fsanitize=safe-stack : introduit une deuxième pile qui contient les valeurs pour lesquelles certains accès ne sont pas considérés comme sûrs. Les autres valeurs (dont l’adresse de retour...) restent dans la pile standard, considérée comme sûre. Cette séparation rend plus difficile l’écrasement des valeurs de la pile sûre depuis la pile non sûre. Cette instrumentation a peu d’impact sur les performances (peu de fonctions allouent plus d’une page sur la pile, et même dans ce cas, on se retrouve avec juste un prologue de fonction un peu plus long) ce qui permet de la déployer en production. Le code qui suit illustre la présence de cette pile supplémentaire :
- -fsanitize=cfi : introduit un ensemble de vérification lors de sauts indirects (par exemple, suite à des appels par pointeur de fonction, ou par fonction virtuelle), ce qui limite l’exploitation de ces sauts par des attaques de type JOP (Jump Oriented Programming). On notera que cette instrumentation requiert que l’ensemble du code soit compilé avec les optimisations lors de l’édition de liens (Link Time Optimization). L’impact sur les performances est documenté comme de l’ordre de +1 % sur le temps d’exécution et +15 % sur la taille du binaire.
- -fsanitize=shadow-call-stack : il implémente une forme restreinte, mais moins coûteuse de protection de la pile que -fsanitize=safe-stack, en stockant uniquement l’adresse de retour d’un appel de fonction dans une pile séparée, ce qui la rend plus difficile à atteindre par un débordement de tampon. Cette instrumentation n’est disponible que sur AArch64. La comparaison des deux sorties ci-dessous pour un appel à gcc -ffixed-x18 -O2 avec et sans -fsanitize=shadow-call-stack fait apparaître ce stockage supplémentaire :
3. Machine : -msomething
Les compilateurs permettent de spécifier des comportements spécifiques à un certain type de matériel. Les drapeaux que nous allons voir dans cette section permettent de tirer parti de certaines fonctionnalités matérielles qui protègent le binaire en cours d’exécution contre certains types d’attaques :
- -mbranch-protection est spécifique aux processeurs ARM. Elle permet d’activer toute une gamme de protections contre les attaques de type ROP (Return Oriented Programing) et JOP en utilisant une combinaison d’authentification de pointeurs (où certains bits inutilisés des pointeurs sont utilisés pour stocker une information d’authentification calculée à l’aide d’instructions dédiées) et d’identification des cibles de branchement à l’aide d’une instruction spécifique bti qui permet de vérifier la validité d’un saut indirect. Cette instruction accepte un argument, qui vaut soit c (pour call), et vérifie alors que le saut qui a conduit à cette instruction est bien un appel de fonction, j (pour jump) et vérifie alors que le saut qui a conduit à cette instruction est bien un branchement. Tout branchement indirect vers une instruction différente de bti dans une page protégée est considéré comme une erreur.
- -mshstk : permet d’activer une protection semblable à -fsanitize=shadow-call-stack, mais reposant sur le support matériel fournit par la technologie Intel CET qui maintient un shadow stack pointer sur une zone où sont stockées les adresses de retour de fonction. Ainsi, un débordement de tampon peut plus difficilement écraser l’adresse de retour pour un surcoût négligeable en termes de performance d’après la documentation officielle.
4. Autres drapeaux : -fsomething
Les drapeaux de la famille -fsomething affectent le processus de compilation de manière indépendante du matériel. Ils ont donc un avantage de portabilité par rapport aux drapeaux de la famille -msomething. Faisons donc connaissance avec...
-fstack-protector et ses camarades -fstack-protector-all, -fstack-protector-strong : un grand classique, puisqu’il installe un stack canary. Ses différentes versions contrôlent le type de fonctions qui sont concernées par ce drapeau : -fstack-protector pour les fonctions les plus propices à un dépassement de tampon : celles qui accroissent dynamiquement la taille de la pile (p. ex. via alloca) ou qui contiennent des tampons de plus de 8 octets, -fstack-protector-strong pour également protéger toutes les fonctions qui allouent un tableau sur la pile ou qui référencent une adresse de la pile, et -fstack-protector-all pour l’ensemble des fonctions. Offrons-nous un exemple de stack canary :
-fcf-protection=[full|branch|return|none|check] : apporte une protection contre les attaques qui modifient le flot de contrôle original du programme (type ROP / COP / JOP), en se reposant sur un contrôle de la validité de différents branchements indirects, appels indirects de fonction, etc. Si possible, Intel CET est utilisé pour améliorer les performances de l’implémentation.
-fstack-clash-protection : apporte une protection contre les attaques de type Stack Clash (collision entre la pile et le tas) en imposant un accès mémoire par page de pile nouvellement allouée.
-fsplit-stack : instrumente l’allocation sur la pile pour utiliser des zones mémoire disjointes quand l’espace initial est épuisé. Ce drapeau a généralement pour objectif de permettre de limiter la taille de la pile (p. ex. si l’on utilise un grand nombre de processus légers), mais un effet de bord amusant est d’offrir (à un coût supérieur à -fstack-clash-protection) une protection contre les attaques de type Stack Clash, et d’obtenir une organisation non standard de la pile, ce qui rend l’écrasement de cette dernière potentiellement plus délicat.
-fstack-usage : ce drapeau ne modifie pas le code en sortie, mais produit également un rapport (extension .su) où l’on trouve, pour chaque fonction générée, la quantité de mémoire requise sur la pile pour cette fonction, et le cas échéant si cette dernière à une taille variable (à cause d’un alloca, etc.). Ces informations, notamment la nature dynamique de l’allocation, peuvent donner des indices sur les fonctions à protéger de manière prioritaire.
Conclusion
GCC et Clang partagent de nombreux drapeaux, même si nous n’avons pas détaillé dans cet article les spécificités de chacun (architectures supportées, choix d’implémentation, etc.). Il n’en reste pas moins que le choix d’une bonne combinaison de drapeaux est complexe, puisqu’elle induit un compromis performance / sécurité propre à l’application.
Je vous laisse donc sur les drapeaux utilisés par défaut par Fedora Rawhide, en guise de référence. Tous n’ont pas été évoqués dans cet article, il faut bien garder un peu de mystère :-) :
Remerciement
Un grand merci à FloFlo pour sa relecture et cette chouette suggestion de titre d’article !