J'ai récemment appris comment l'ASLR (randomisation de l'espace d'adressage) fonctionne sous Linux. Au moins sur Fedora et Red Hat Enterprise Linux, il existe deux types de programmes exécutables:
Les exécutables indépendants de position (PIE) reçoivent une forte randomisation d'adresse. Apparemment, l'emplacement de tout est aléatoire, séparément pour chaque programme. Apparemment, les démons orientés réseau doivent être compilés en tant que TARTE (en utilisant le -pie -fpie
drapeaux du compilateur), pour garantir qu'ils reçoivent la randomisation pleine puissance.
D'autres exécutables reçoivent une randomisation d'adresse partielle. Le segment de code exécutable n'est pas aléatoire - il se trouve à une adresse fixe et prévisible qui est la même pour tous les systèmes Linux. En revanche, les bibliothèques partagées sont randomisées: elles sont chargées à une position aléatoire qui est la même pour tous ces programmes sur le système.
Je pense que je comprends pourquoi les exécutables non PIE ont la forme de randomisation la plus faible pour les bibliothèques partagées (cela est nécessaire pour le pré-lien, ce qui accélère la liaison et le chargement des exécutables). Je pense aussi que je comprends pourquoi les exécutables non PIE n'ont pas du tout leur segment exécutable randomisé: il semble que c'est parce que le programme doit être compilé en tant que PIE, pour pouvoir randomiser l'emplacement du segment de code exécutable.
Pourtant, laisser l'emplacement du segment de code exécutable non aléatoire est potentiellement un risque pour la sécurité (par exemple, cela facilite les attaques ROP), il serait donc bon de comprendre s'il est possible de fournir une randomisation complète pour tous les binaires.
Alors, y a-t-il une raison pour ne pas tout compiler en TARTE? Y a-t-il un surcoût de performance à compiler en PIE? Si tel est le cas, quelle est la surcharge de performances sur différentes architectures, en particulier sur x86_64, où la randomisation des adresses est la plus efficace?
Références:
Bien que les détails varient considérablement entre les architectures, ce que je dis ici s'applique aussi bien aux x86 32 bits, x86 64 bits, mais aussi ARM et PowerPC: face aux mêmes problèmes, à propos de toute l'architecture les concepteurs ont utilisé des solutions similaires.
Il existe (grosso modo) quatre types "d'accès", au niveau de l'assemblage, qui sont pertinents pour le système "indépendant de la position": il y a appels de fonction (call
opcodes) et accès aux données , et les deux peuvent cibler soit une entité dans le même objet (où un objet est un " objet partagé ", c'est-à-dire une DLL ou le fichier exécutable lui-même) ou dans un autre objet. Les accès aux données des variables de pile ne sont pas pertinents ici; Je parle d'accès aux données variables globales ou données constantes statiques (en particulier le contenu de ce qui apparaît, au niveau source, comme des chaînes de caractères littérales) . Dans un contexte C++, les méthodes virtuelles sont référencées par ce qui est, en interne, des pointeurs de fonction dans des tables spéciales (appelées "vtables"); pour les besoins de cette réponse, il s'agit également d'accès aux données , même si une méthode est du code.
L'opcode call
utilise une adresse cible qui est relative : il s'agit d'un décalage calculé entre le pointeur d'instruction courant (techniquement, le premier octet après l'argument de l'opcode call
) et l'adresse cible de l'appel. Cela signifie que les appels de fonction dans le même objet peuvent être entièrement résolus au moment de la liaison (statique); ils n'apparaissent pas dans les tables de symboles dynamiques et ils sont "indépendants de la position". D'un autre côté, les appels de fonction à d'autres objets (appels entre DLL ou appels du fichier exécutable vers une DLL) doivent passer par une indirection qui est gérée par l'éditeur de liens dynamique. L'opcode call
doit toujours sauter "quelque part", et l'éditeur de liens dynamique veut l'ajuster dynamiquement. Le format essaie d'atteindre deux caractéristiques:
Comme le partage se fait page par page, cela signifie qu'il faut éviter de modifier dynamiquement l'argument call
(les quelques octets après l'opcode call
). Au lieu de cela, le code compilé utilise un Global Offsets Table (ou plusieurs - je simplifie un peu les choses). Fondamentalement, le call
saute à un petit morceau de code qui fait l'appel réel, et est sujet à modification par l'éditeur de liens dynamique. Tous ces petits wrappers, pour un objet donné, sont stockés ensemble dans des pages que l'éditeur de liens dynamiques modifiera; ces pages sont à un décalage fixe du code, donc l'argument à call
est calculé au moment du lien statique et n'a pas besoin d'être modifié à partir du fichier source. Lorsque l'objet est chargé pour la première fois, tous les wrappers pointent vers une fonction de lieur dynamique qui effectue la liaison lors de la première invocation; cette fonction modifie l'encapsuleur lui-même pour pointer vers la cible résolue, pour les invocations suivantes. Le jonglage au niveau de l'assemblage est complexe mais fonctionne bien.
Accès aux données suivent un modèle similaire, mais ils n'ont pas d'adressage relatif. Autrement dit, un accès aux données utilisera une adresse absolue . Cette adresse sera calculée dans un registre, qui sera ensuite utilisé pour l'accès. La ligne CPU x86 peut avoir l'adresse absolue directement dans le cadre de l'opcode; pour les architectures RISC, avec des opcodes de taille fixe, l'adresse sera chargée en deux ou trois instructions successives.
Dans un fichier exécutable non PIE, l'adresse cible d'un élément de données est connue de l'éditeur de liens statique, qui peut le coder en dur directement dans l'opcode qui fait l'accès. Dans un exécutable PIE, ou dans une DLL, cela n'est pas possible car l'adresse cible n'est pas connue avant l'exécution (elle dépend des autres objets qui seront chargés en RAM, mais aussi d'ASLR). Au lieu de cela, le code binaire doit utiliser à nouveau le GOT. L'adresse GOT est calculée dynamiquement dans un registre de base. Sur x86 32 bits, le registre de base est conventionnellement %ebx
et le code suivant est typique:
call nextaddress
nextaddress:
popl %ebx
addl somefixedvalue, %ebx
Le premier call
saute simplement au prochain opcode (donc l'adresse relative ici est juste un zéro); puisqu'il s'agit d'un call
, il pousse l'adresse de retour (également celle de l'opcode popl
) sur la pile, et le popl
l'extrait. À ce moment, %ebx
contient l'adresse de popl
, donc un simple ajout modifie cette valeur pour pointer vers le début du GOT. Les accès aux données peuvent alors être effectués relativement à %ebx
.
Alors qu'est-ce qui est changé en compilant un fichier exécutable en tant que PIE? En fait, pas grand chose. Un "exécutable PIE" signifie faire de l'exécutable principal une DLL, le charger et le lier comme n'importe quelle autre DLL. Cela implique les éléments suivants:
La surcharge des accès aux données est due à l'utilisation d'un registre conventionnel pour pointer vers le GOT: une indirection supplémentaire, un registre utilisé pour cette fonctionnalité (cela a un impact sur les architectures affamées de registres comme x86 32 bits) et du code supplémentaire à recalculer le pointeur vers le GOT.
Cependant, les accès aux données sont déjà quelque peu "lents", par rapport aux accès aux variables locales, donc le code compilé met déjà ces accès en cache lorsque cela est possible (la valeur de la variable est conservée dans un registre et purgée uniquement en cas de besoin; et même lorsqu'elle est vidée, la variable adresse est également conservée dans un registre). Cela est d'autant plus vrai que les variables globales sont partagées entre les threads, de sorte que la plupart des codes d'application qui utilisent de telles données globales ne les utilisent qu'en lecture seule (lorsque les écritures sont effectuées, elles sont effectuées sous la protection d'un mutex , et saisir le mutex entraîne de toute façon un coût beaucoup plus élevé). La plupart du code gourmand en CPU fonctionnera sur les registres et les variables de pile, et ne sera pas impacté en rendant le code indépendant de la position.
Tout au plus, la compilation de code sous forme de TARTE impliquera une surcharge de taille d'environ 2% sur le code typique, sans impact mesurable sur l'efficacité du code, donc ce n'est guère un problème (j'ai obtenu ce chiffre en discutant avec des personnes impliquées dans le développement d'OpenBSD; le "+ 2%" était un problème pour eux dans la situation très spécifique d'essayer d'installer un système barebone sur une disquette de démarrage).
Cependant, le code non C/C++ peut avoir des problèmes avec PIE. Lors de la production de code compilé, le compilateur doit "savoir" si c'est pour un DLL ou pour un exécutable statique, pour inclure les morceaux de code qui trouvent le GOT. Il n'y aura pas beaucoup de paquets dans un système d'exploitation Linux qui peut entraîner des problèmes, mais Emacs pourrait être un problème, avec sa fonction de vidage et de rechargement LISP.
Notez que le code en Python, Java, C # /. NET, Ruby ... est complètement hors de portée de tout cela. PIE est pour le code "traditionnel" en C ou C++.
Une raison pour laquelle certaines distributions Linux peuvent hésiter à compiler tous les exécutables en tant que PIE (Position-Independent Executables), de sorte que le code exécutable est aléatoire, est due à des problèmes de performances. Le problème avec les performances, c'est que parfois les gens s'inquiètent des performances même quand ce n'est pas un problème. Il serait donc agréable d'avoir des mesures détaillées du coût réel.
Heureusement, l'article suivant présente quelques mesures du coût de compilation des exécutables en tant que TARTE:
Le document a analysé les frais généraux de performance de l'activation de PIE sur un ensemble de programmes gourmands en CPU (à savoir, les benchmarks SPEC CPU2006). Étant donné que nous nous attendons à ce que cette classe d'exécutables affiche les pires frais généraux de performance dus à l'IPF, cela donne une estimation prudente, dans le pire des cas, de l'estimation de la performance potentielle.
Pour résumer les principales conclusions du document:
Sur les architectures x86 32 bits, le surcoût des performances pourrait être substantiel: il s'agit en moyenne d'un ralentissement d'environ 10%, pour les benchmarks SPEC CPU2006 (programmes gourmands en CPU), et jusqu'à 25% de ralentissement environ pour quelques-uns des programmes.
Sur les architectures 64 bits x64, le surcoût de performance est beaucoup plus faible: un ralentissement moyen d'environ 3%, sur les programmes gourmands en CPU. Il est probable que la surcharge de performances serait encore moindre pour de nombreux programmes que les gens utilisent (car de nombreux programmes ne sont pas gourmands en CPU).
Cela suggère que l'activation de PIE pour tous les exécutables sur les architectures 64 bits serait une étape raisonnable pour la sécurité, et l'impact sur les performances est très faible. Cependant, l'activation de PIE pour tous les exécutables sur les architectures 32 bits serait trop coûteuse.
Assez évident pourquoi les exécutables dépendants de la position ne sont pas randomisés.
"Dépendant de la position" signifie simplement qu'au moins certaines adresses sont codées en dur. En particulier, cela peut s'appliquer aux adresses de succursales. Le déplacement de l'adresse de base du segment exécutable déplace également toutes les destinations de branche.
Il existe deux alternatives pour ces adresses codées en dur: soit les remplacer par des adresses relatives à l'IP (afin que le processeur puisse déterminer l'adresse absolue au moment de l'exécution), soit les corriger au moment du chargement (lorsque l'adresse de base est connue).
Vous avez bien sûr besoin d'un compilateur capable de générer de tels exécutables.