En sécurité sous les drapeaux... du compilateur, ces fameux -fstack-protector-strong et autres -D_FORTIFY_SOURCE=2 que l’on retrouve dans de plus en plus de logs de compilation. Cet article se propose de parcourir quelques-uns des drapeaux les plus célèbres, en observant les artefacts dans le code généré qui peuvent indiquer leur utilisation, tout en discutant de leur support par gcc et clang. Après tout, nombre d’entre eux sont utilisés par défaut sous Debian ou Fedora, ils méritent bien qu’on s’y intéresse un peu.
Quand une faille matérielle sort (au hasard, SPECTRE), plusieurs approches non exclusives sont possibles : attendre la sortie d’une nouvelle génération de processeurs, patienter jusqu’à la sortie d’une mise à jour du micro-code, changer son code source (quand c’est possible), ou demander au compilateur de mettre en place une contre-mesure, sacrifiant ainsi un peu de performance pour une meilleure sécurité. Cet article traite de cette dernière option, en se mettant dans le cadre des compilateurs gcc et clang, sous Linux.
En inspectant les drapeaux de compilation utilisés par défaut pour la construction de paquets par la distribution Fedora, on a sous les yeux une belle illustration de cette maxime de Thomas Fuller (homme d’Église et historien anglais du XVIIème siècle) : Le moyen d'être sauf, c'est de ne pas se croire en sécurité. Voyez plutôt :
Si certains de ces drapeaux sont liés à l’optimisation (de la vitesse d’exécution pour -O2 et ou de la taille du binaire pour -Wl,--as-needed), l’architecture cible (-m64 et -mtune=generic) ou les symboles de debug (-g, et dans une certaine mesure -fexceptions et -fasynchronous-unwind-tables), plus de la moitié de ces drapeaux a pour but de durcir le binaire pour prévenir différents types d’attaques. On est parti pour une levée des couleurs un peu inhabituelle !
1. -Werror=format-security
L’analyse statique (analyse qui a lieu pendant la compilation, sans instrumentation du binaire généré), c’est un peu le Graal de la protection : on détecte des problèmes avant de livrer le binaire, et ce sans impact sur les performances à l’exécution. Ça parait génial, et ça l’est, mais malheureusement cela n’est applicable qu’à une certaine classe de problèmes, généralement limitée.
Dans le cas de -Werror=format-security, le compilateur va pouvoir détecter certains motifs d’appels à printf et consorts jugés vulnérables :
Attention cependant, un appel à printf(s, 1) sera considéré valide. Cette option est supportée par clang et gcc.
Dans une veine similaire, en bien plus avancé, -Werror=array-bounds permet de détecter certains accès de tableau illégaux, et ainsi éviter quelques dépassements de tampon. Il y a bien sûr plein de limitations sur l’analyse interprocédurale et les tailles de tableaux dynamiques comme seul C99 sait en préparer, mais le résultat reste pertinent dans de nombreux cas :
Cette option est supportée par clang et gcc, mais avec plus de travaux récents côté gcc.
2. -D_FORTIFY_SOURCE=2
Certaines fonctions de la libc sont connues de longue date comme permettant différentes attaques si mal utilisées : sprinf, memcpy, strcpy, etc. Le cas classique, rappelons-le pour ceux du fond, consiste en l’utilisation d’un buffer (de taille éventuellement fixe) plus petit qu’attendu. Cela reste d’ailleurs valable pour snprintf ou strncpy quand la taille size de buffer passée en paramètre est plus grande que la taille réelle.
La glibc propose des versions alternatives de ces fonctions, débloquées par -D_FORTIFY_SOURCE={1,2}, dont l’implémentation repose sur un builtin du compilateur, builtin_object_size, qui renvoie la taille en octets de l’objet pointé. Ces versions alternatives comparent la taille de buffer effective aux autres arguments de la fonction et déclenchent une erreur à l’exécution (ou éventuellement à la compilation, dans l’esprit de la section précédente).
On peut détecter la présence de ces versions durcies en regardant la table des symboles, en cherchant des symboles dotés du suffixe _chk :
La sortie de man 7 feature_test_macros permet d’en apprendre plus sur _FORTIFY_SOURCE. Par exemple, cette macro requiert un niveau d’optimisation supérieur ou égal à 1 (-O1 dans l’exemple ci-dessus), et le niveau 2, s’il active plus de vérifications, peut également renvoyer quelques faux positifs.
Les intrinsèques nécessaires sont supportés par clang et gcc, mais avec plus de travaux récents côté gcc.
3. -D_GLIBCXX_ASSERTIONS
Voilà une protection un peu moins connue, et pourtant largement présente dans la libstdc++ :
Mais quel est donc ce __glibcxx_assert débloqué par -D_GLIBCXX_ASSERTIONS ? Regardons cet extrait de la fonction basic_string::operator[] (size_type __pos) const :
On ne rêve pas, c’est bien une version avec vérification des accès à l’exécution ! Le surcoût est bien sûr loin d’être négligeable, un vrai compromis sécurité/performances. Il parait par contre tout à fait raisonnable d’activer ce drapeau dans les suites de test.
Il n’y a pas de dépendance à des intrinsèques dans l’implémentation de cette fonctionnalité.
4. -fstack-protector-strong
Protéger la pile en déposant, en fin de pile un stack canary dont la valeur est choisie à l’exécution est une manière maintenant bien éprouvée pour détecter certains dépassements de tampon : si l’attaquant écrase la pile sans précaution, il écrasera aussi la valeur du stack canary, et un test en fin de fonction le détectera et arrêtera l’exécution.
On peut noter que le compilateur décide, pour chaque fonction, s’il est intéressant de la protéger. Cette décision se base sur la présence sur la pile de tableaux, structure avec au moins un champ de tableau, variables locales dont l’adresse est manipulée. On peut court-circuiter cette stratégie à l’aide de -fstack-protector ou -fstack-protector-all, qui permettent d’appliquer la protection aux fonctions qui contiennent des tableaux de 8 octets ou plus, ou à toutes les fonctions.
Le code assembleur suivant résulte d’une compilation avec -fstack-protector-all. On notera la lecture de %fs:40 (%fs est utilisé comme segment mémoire local à un thread, et l’octet 40 est initialisé à une valeur aléatoire, le fameux canary dont on parle plus haut) en début de fonction, et la vérification en fin de fonction.
On notera aussi qu’une façon naïve, mais efficace de vérifier qu’une fonction est protégée de cette manière est de regarder si elle se conclut par un appel à __stack_chk_fail.
Cette option est supportée par clang et gcc.
5. -frecord-gcc-switches
Changeons un peu de perspective : il est parfois utile de savoir qu’un binaire a été protégé, comment, bref de connaître les options de compilation qui ont été utilisées (ou pas !) pour le générer. Les compilateurs gcc et clang permettent tout deux, à travers -frecord-gcc-switches (ou -grecord-gcc-switches si on veut stocker l’information dans une section de debug) d’enregistrer dans une note ELF l’ensemble des drapeaux de compilation utilisés. Par exemple :
Cela peut être utilisé pour faire de la vérification a posteriori de la qualité des binaires générés. Notons à ce propos l’excellent plugin de compilateur annobin (cf. https://developers.redhat.com/blog/2018/02/20/annobin-storing-information-binaries/) qui généralise cette approche en formalisant le format de stockage et l’inspection de ces propriétés. Il supporte originellement gcc, mais également clang depuis 2020.
6. -fstack-clash-protection
L’attaque connue sous le joyeux nom stack clash exploite la situation où la pile et le tas ont tellement grossi qu’ils en arrivent à se toucher. Dans ce cas, augmenter la pile revient à empiéter sur le tas. Pour prévenir ce genre de situation, le noyau Linux maintient une page de garde qui déclenche une erreur quand on tente d’accéder à son contenu. Cependant, si l’augmentation de la pile dépasse la taille de cette page de garde (typiquement PAGE_SIZE, dont on peut examiner la valeur à travers % getconf PAGE_SIZE), il est possible de sauter par-dessus cette page de garde…
La protection mise en place par -fstack-clash-protection consiste à ne plus allouer la pile d’un coup, mais par blocs de PAGE_SIZE, suivis d’un accès mémoire pour éventuellement déclencher la page de garde. On peut par exemple examiner le prélude d’une fonction allouant un tableau de grande taille (je me suis fait un peu plaisir sur la ligne de sed) :
On retrouve bien une augmentation partielle de la taille de la pile subq $4096, %rsp suivie d’un accès mémoire orq $0, (%rsp).
Cette option est supportée par clang (mais seulement sur X86, SystemZ et PowerPC) et gcc.
7. -fcf-protection
Les attaques de type return-to-libc puis Return Oriented Programming ont donné bien du fil à retordre aux développeurs de compilateurs. L’option -mmitigate-rop parait encourageante, mais d’après la page info de gcc, it should not be relied on to provide serious protection (l’option modifie la génération de code afin d’éviter de générer certaines séquences d’instruction).
Une approche en pur software a été implémentée dans clang à travers -fsanitize=cfi, mais elle reste très coûteuse, et dépend de l’activation de la compilation lors de l’édition de lien (Link Time Optimisation, à travers -flto).
Intel et ARM fournissent cependant une alternative matérielle intéressante, à travers les technologies CET (Control-Flow Enforcement Technology) et Pointer Authentication. Avec l’aide du compilateur qui génère les appels idoines, CET gère une deuxième stack pour stocker les valeurs de retour de fonction (Shadow Stack). La Pointer Authentication signe (en utilisant des fats pointers) les adresses de retour de fonction, rendant la prise de contrôle de cette dernière plus complexe.
L’exemple suivi illustre l’usage des instructions spécifiques à Intel CET :
La première instruction est inhabituelle : endbr64. Cette instruction est équivalente à un NOP pour les architectures non supportées, mais aussi si CET est disponible, mais dans ce dernier cas, elle marque aussi une destination valide pour un saut, à l’opposée des sauts injectés par ROP.
Cette option est supportée par clang et gcc.
8. -Wl,-z,relro -Wl,-z,now
Finissons par un petit classique, du côté de l’éditeur de liens. Les deux drapeaux présentés ci-dessus mettent à jour des champs particuliers du format ELF, respectivement GNU_RELRO et BIND_NOW. Le second force la résolution des symboles dynamiques au chargement du programme (par opposition à un chargement différé), et le second met les relocations en lecture seule. La combinaison de ces deux options contre une famille d’attaque où l’attaquant arrive à prendre le contrôle des relocations, ce qui lui permet d’effectuer un POKE_TEXT à l’endroit de son choix…
Ces options sont supportées par ld.gold, ld.bfd et lld.
9. -pie, -fpie, -fPIE, -fpic et -fPIC
Le noyau Linux propose, si /proc/sys/kernel/randomize_va_space est à 1 ou 2, de choisir une valeur aléatoire pour l’adresse de base de la pile, les régions mémoires partagées, le segment donné. Cette protection, largement connue et utilisée, empêche l’attaquant d’utiliser des valeurs codées en dur lors de l’élaboration de son attaque.
Pour pouvoir tirer parti de cette fonctionnalité, un exécutable et les bibliothèques partagées associées doivent être relogeables, c’est-à-dire ne pas comporter de références constantes à d’autres adresses du programme, mais utiliser uniquement des références relatives. Les options afférentes sont -pie et -fpie ou -fPIE pour les binaires (-pie est spécifique à l’éditeur de liens, et pour certaines architectures -fpie génère un code plus rapide et concis que -fPIE, avec certaines limitations.) et -fpic ou -fPIC pour les bibliothèques partagées (là encore -fpic génère un code plus rapide et concis quand il est applicable). On peut les illustrer à travers un petit exemple :
On peut compiler ce code sans -fPIC, auquel cas l’accès à data se fait de manière directe, ce qui n’est pas compatible avec un code relogeable :
On peut aussi compiler ce code avec -fPIC, auquel cas l’accès à data passe par la GOT (Global Offset Table), relativement à rip, ce qui rend le code relogeable :
On reconnaît un binaire relogeable à la mention DYN (Shared object file) dans les en-têtes ELF du binaire (ou à travers la commande file). Pour les bibliothèques partagées, la présence de relocations est rédhibitoire pour prétendre au statut relogeable.
Ces options sont supportées par ld.gold, ld.bfd et lld.
10. -specs=...
Petit bonus de fin d’article, afin de mieux comprendre la ligne de commandes donnée au début de celui-ci : gcc (mais pas clang) permet de fournir dans un fichier à part des directives de compilation supplémentaires. Contrairement à l’option de clang -config qui permet de lire les options depuis un fichier, le format supporté par gcc introduit un nouveau mini langage de manipulation d’option (rattachement de plusieurs options à un groupe, manipulation de ces groupes, etc.). Prenons cet exemple tiré de la page de manuel :
Il a pour effet de renommer le groupe lib en old_lib, puis d’introduire un nouveau groupe lib qui ajoute aux options précédentes les options --start-group -lgcc -lc -leval1 –end-group.
/usr/lib/rpm/redhat/redhat-hardened-cc1 contient les directives suivantes :
On comprend vite qu’elles se réfèrent aux options vues sur la section dédiée à l’ASLR. Plus précisément, si ni les drapeaux -r, -fpie, -fPIE, -fpic, -fPIC ou -fno_pic ne sont passés au compilateur, utiliser le drapeau -fPIE.
/usr/lib/rpm/redhat/redhat-annobin-cc1 contient les directives suivantes :
Elles contrôlent l’usage du plugin annobin également mentionné dans cet article.
/usr/lib/rpm/redhat/redhat-hardened-ld contient les directives suivantes :
Elles se rapportent également à l’ASLR, à savoir que si ni -static, -shared ou -r ne sont passés au compilateur, -pie est utilisé.
11. Goodies
Les personnes intéressées pourront retrouver sur le dépôt https://github.com/serge-sans-paille/hardening-artefacts un ensemble de fragments de code permettant d’illustrer les drapeaux de compilation présentés dans cet article, ainsi que pour quelques drapeaux juste évoqués en conclusion.
Le dépôt https://github.com/serge-sans-paille/fortify-test-suite contient quant à lui un ensemble de codes sources permettant d’illustrer finement le comportement des fonctions affectées par -D_FORTIFY_SOURCE=1, et de tester si clang et gcc (ou tout autre compilateur d’ailleurs) se comportent de manière identique dans chacun des cas.
Conclusion
Cet article passe en revue quelques-uns des drapeaux de compilation de gcc et clang les plus répandus, à travers l’inspection des drapeaux utilisés par défaut sous Fedora. Les plus simples attaques ont peu de chance de réussir contre un binaire correctement durci, ce qui monte un peu la barre pour les nouveaux arrivants. C’est un sujet d’actualité et de nombreux travaux sont publiés au fur et à mesure de la sortie de nouvelles attaques. On notera par exemple le tout récent -ftrivial-auto-var-init=pattern, spécifique à clang, pour éviter certains comportements indéfinis et/ou des fuites de données, ou la contre-mesure à spectre (v1) fournie par -mspeculative-load-hardening, spécifique elle aussi à clang et qui empêche l’attaquant de manipuler le cache à travers l’unité de prédiction de branchement.
Attaque |
Contre-mesures |
Buffer overflow |
-fstack-protector-strong -Werror=array-bounds -D_FORTIFY_SOURCE=2 -D_GLIBCXX_ASSERTIONS |
GOT/PLT Powning |
-Wl,-z,relro -Wl,-z,now |
Format attack |
-D_FORTIFY_SOURCE=2 -Werror=formatsecurity |
Address Space Layout Randomization |
-fpic -fPIC -fpie -fPIE -pie |
ROP |
-fcf-protection |
Stack Clash |
-fstack-clash-protection |
Spectre (V1) |
-mspeculative-load-hardening (clang) |
Tableau 1 : Récapitulatif des options de sécurité pour GCC et Clang.
Remerciements
Un grand merci à gapz pour m’avoir motivé à écrire cet article, et à Adrien Guinet, Juan Manuel, Martinez Sylvestre Ledru et Florian Weimer pour m’avoir fourni de nombreux conseils et éclaircissements lors des travaux préparatoires.
Références
[1] F. Weimer : https://developers.redhat.com/blog/2018/03/21/compiler-and-linker-flags-gcc/
[2] S. Guelton et A. Guinet :
https://blog.quarkslab.com/clang-hardening-cheat-sheet.html (oui, je m’auto-cite)
[3] J. Perrot :
https://connect.ed-diamond.com/GNU-Linux-Magazine/GLMFHS-076/Les-options-de-securite-de-gcc