Aller au contenu

Détournement de bibliothèque partagée Linux et ld.so

Détournement de bibliothèque partagée Linux et ld.so

Section intitulée « Détournement de bibliothèque partagée Linux et ld.so »

Le chargeur dynamique Linux résout les dépendances d’un binaire au lancement, dans un ordre précis et documenté. Qui écrit dans l’un des répertoires consultés avant le bon obtient l’exécution de code dans le processus cible. C’est le pendant Linux du DLL hijacking, avec ses propres règles de priorité et son propre garde-fou.

→ Lire l’explication complète de la marmotte

L’ordre de recherche de ld.so, pierre angulaire du détournement

Section intitulée « L’ordre de recherche de ld.so, pierre angulaire du détournement »

Le chargeur dynamique (ld.so, ou ld-linux.so selon l’architecture) résout chaque bibliothèque partagée (fichier .so, pour shared object) dont un exécutable ELF (Executable and Linkable Format) dépend. Il consulte des emplacements dans un ordre figé, et s’arrête au premier fichier qui porte le bon nom (SONAME). Tout le détournement tient dans cette règle : le premier trouvé gagne, peu importe qu’il soit légitime.

L’ordre, pour un binaire ordinaire, est le suivant : d’abord les bibliothèques préchargées via LD_PRELOAD, puis le DT_RPATH du binaire (hérité, et prioritaire sur la variable d’environnement, ce qui en fait le piège le plus ancien), puis LD_LIBRARY_PATH, puis le DT_RUNPATH, puis le cache /etc/ld.so.cache généré par ldconfig, et enfin les répertoires par défaut /lib et /usr/lib (et leurs variantes multiarch).

Chaîne d'étapes : un binaire ELF demande libfoo.so.1. D'abord LD_PRELOAD, liste explicite hors recherche par nom : les .so préchargés passent d'abord et leurs symboles priment. Puis la recherche parcourt, dans l'ordre, le DT_RPATH du binaire (ignoré si RUNPATH présent), LD_LIBRARY_PATH, le DT_RUNPATH du binaire, /etc/ld.so.cache, et en dernier recours /lib puis /usr/lib (multiarch). À chaque étape, si la bibliothèque est trouvée elle est chargée ; sinon on passe à l'étape suivante.

Ce schéma dit l’essentiel : chaque niveau qui contient un répertoire inscriptible par un attaquant, et placé avant celui où réside la vraie bibliothèque, ouvre un détournement. Le point qui surprend le plus : si un binaire porte un DT_RPATH, celui-ci l’emporte sur LD_LIBRARY_PATH, alors que DT_RUNPATH, lui, passe après. D’où la règle, vieille comme les distributions, d’abandonner RPATH au profit de RUNPATH.

LD_PRELOAD force le chargement d’un .so avant toute autre dépendance : ses symboles masquent ceux des bibliothèques suivantes. C’est l’outil légitime du traçage et du débogage, et l’instrument classique du détournement quand un processus l’hérite de son environnement. Cet angle précis, l’injection via l’environnement d’un processus enfant, est traité ailleurs : voir la pollution de variables d’environnement. Ici le sujet est la résolution par nom, pas la variable qui la court-circuite.

DT_RPATH et DT_RUNPATH sont deux entrées inscrites dans l’en-tête ELF à la compilation (dans la section dynamique). Elles listent des répertoires de recherche propres au binaire. La différence fonctionnelle tient en une ligne : RPATH est consulté avant LD_LIBRARY_PATH, RUNPATH après. RPATH est déprécié depuis des années, mais il traîne dans des binaires recompilés à la hâte, des éditeurs de liens mal configurés et des SDK tiers qui n’ont jamais lu le changelog.

Le jeton $ORIGIN est l’autre pièce clé. Dans un RPATH ou un RUNPATH, il est remplacé au chargement par le répertoire du binaire exécuté. Il sert à rendre une application relogeable ($ORIGIN/../lib), ce qui est commode pour les paquets autonomes. Le danger : si le binaire vit dans un répertoire inscriptible, ou s’il peut être copié dans un tel répertoire par un attaquant, $ORIGIN pointe désormais vers un terrain qu’il contrôle. Un RUNPATH à base de $ORIGIN n’est sûr que si l’emplacement du binaire ne l’est pas moins que /usr/lib.

Chaîne de trois étapes : un binaire placé dans /opt/app/bin porte un RUNPATH égal à ORIGIN/../lib ; au lancement, ORIGIN est résolu en /opt/app/bin ; la recherche effective des bibliothèques se fait donc dans /opt/app/lib. Deux branches de risque en partent : si /opt/app/lib est inscriptible par l'attaquant, un .so malveillant est chargé ; si le binaire peut être recopié dans /tmp/x/bin, la recherche se fait dans /tmp/x/lib, répertoire contrôlé par l'attaquant.

Le mode secure-execution neutralise LD_PRELOAD sur les binaires privilégiés

Section intitulée « Le mode secure-execution neutralise LD_PRELOAD sur les binaires privilégiés »

Le garde-fou du noyau et de la glibc s’appelle le mode secure-execution. Il s’active automatiquement quand un binaire tourne avec une identité qui n’est pas celle de l’appelant : setuid/setgid (bit SUID, pour Set User ID), capabilities de fichier, ou tout écart entre UID réel et effectif. Dans ce mode, ld.so ignore purement et simplement LD_PRELOAD, LD_LIBRARY_PATH et plusieurs autres variables sensibles, et neutralise un RPATH/RUNPATH qui contiendrait $ORIGIN ou un chemin relatif.

Autrement dit, le vecteur par variable d’environnement est mort pour les binaires privilégiés depuis longtemps. Qui espère détourner un SUID via LD_PRELOAD perd son temps : ce chemin a été refermé il y a une vingtaine d’années. L’élévation de privilège via SUID passe aujourd’hui par d’autres défauts, détaillés dans l’exploitation des binaires SUID.

Ce que secure-execution ne couvre pas, et c’est capital : il ne protège que les binaires privilégiés. Un service applicatif lancé sous son propre compte, sans SUID ni capability, ne bénéficie d’aucune de ces protections. Son RUNPATH relatif, son $ORIGIN dans un répertoire inscriptible et son LD_LIBRARY_PATH restent pleinement exploitables. La protection suit le privilège, pas la sensibilité réelle du service, qui peut être énorme sans être root.

Exemple : auditer les RPATH et RUNPATH d’un binaire

Section intitulée « Exemple : auditer les RPATH et RUNPATH d’un binaire »

L’audit consiste à lire la section dynamique de l’ELF et à repérer tout chemin relatif, tout $ORIGIN suspect, et la présence de RPATH là où RUNPATH devrait régner.

Fenêtre de terminal
# Lire les entrées de recherche de bibliothèques d'un binaire
readelf -d /opt/app/bin/monservice | grep -E 'RPATH|RUNPATH'
# Sortie typique à proscrire : (RPATH) Library rpath: [.:lib:$ORIGIN/../lib]
# -> RPATH présent (déprécié), chemin relatif "." et "lib" (inscriptibles selon cwd)
# Détecter en masse tout RPATH résiduel sur un arbre de binaires
find /opt /usr/local -type f -executable -exec sh -c \
'readelf -d "$1" 2>/dev/null | grep -q RPATH && echo "RPATH: $1"' _ {} \;
# Vérifier ce que le chargeur résoudra réellement, sans exécuter
ldd /opt/app/bin/monservice
# Chaque "=> /chemin/trouvé" montre le .so retenu ; un chemin inattendu = détournement potentiel

La règle de lecture : un RUNPATH doit être absolu (/opt/app/lib), sans composant inscriptible par un compte non privilégié, et sans $ORIGIN quand le binaire peut vivre ailleurs que dans un répertoire verrouillé. Un . ou un répertoire relatif dans la liste est une faille ouverte, pas une coquetterie de build. ldd reste à manier avec prudence sur un binaire non fiable : sur certaines versions, il peut déclencher l’interpréteur, donc à réserver aux binaires qu’on contrôle.

Les contre-mesures se classent par ordre d’efficacité. D’abord, bannir RPATH de toute la chaîne de build et n’émettre que du RUNPATH absolu : sur la plupart des éditeurs de liens, -Wl,--disable-new-dtags produit encore du RPATH, c’est l’option à ne jamais laisser traîner, tandis que --enable-new-dtags donne du RUNPATH. Ensuite, proscrire $ORIGIN dès que le binaire n’est pas installé dans un chemin dont les permissions valent celles de /usr/lib (racine propriétaire, non inscriptible par les autres).

Le cache /etc/ld.so.cache, régénéré par ldconfig, doit refléter uniquement les répertoires de /etc/ld.so.conf et /etc/ld.so.conf.d/. Un répertoire inscriptible ajouté à cette configuration, c’est un détournement pour tous les binaires de la machine d’un coup : la surface maximale. Le fichier de config et les répertoires qu’il déclare doivent appartenir à root et refuser l’écriture au reste du monde. Toute entrée pointant vers /tmp, un home ou un montage partagé est une anomalie à traiter comme un incident.

Deux zones se font face : à gauche le « Poste de build » avec le poste Build, à droite l'« Hôte de production » avec le serveur AppHost. Une flèche va de Build vers AppHost, étiquetée « paquet .deb/.rpm signé (RUNPATH absolu) ». Sous la topologie, le paquet est détaillé en quatre cases alignées : ELF, DT_RUNPATH=/opt/app/lib, pas de RPATH, pas de $ORIGIN.

Cette topologie montre la seule chaîne défendable : le binaire sort du build avec un RUNPATH absolu figé, il est empaqueté et signé, et l’hôte de production n’installe que des paquets signés. La signature de paquets ferme la porte à la substitution de .so au repos, que le détournement par ordre de recherche contourne précisément quand rien ne vérifie l’origine des fichiers. Pour les services conteneurisés, l’image doit être construite avec les mêmes garanties et des répertoires de bibliothèques non inscriptibles par l’utilisateur du conteneur ; le voisinage Windows de ce mécanisme est décrit dans le DLL hijacking.

Côté détection continue : un audit périodique des RPATH/RUNPATH relatifs via readelf -d sur les binaires déployés, la surveillance de l’intégrité de /etc/ld.so.conf* et de /etc/ld.so.cache, et le contrôle des permissions des répertoires listés dans RUNPATH. Un .so apparu dans un répertoire applicatif hors paquet doit déclencher une alerte, pas une régénération silencieuse du cache.

Sans RUNPATH absolu ni abandon de RPATH, un compte non privilégié qui écrit dans un répertoire consulté avant la vraie bibliothèque fait charger son .so dans le processus cible, donc exécute du code avec les droits de ce processus. Sans contrôle de $ORIGIN, un binaire recopié dans un répertoire inscriptible résout ses dépendances sur un terrain contrôlé par l’attaquant. Sans cache ld.so verrouillé, un seul répertoire inscriptible ajouté à la configuration détourne toutes les exécutions de la machine. Sans audit des entrées dynamiques ni paquets signés, la substitution de bibliothèque passe inaperçue : aucune trace, aucune alerte, et la persistance s’installe à chaque lancement du service.

Un programme est rarement complet à lui seul : il utilise des morceaux de code déjà écrits, rangés dans des fichiers séparés sur la machine. Au moment où le programme démarre, un composant du système part chercher ces fichiers. Il suit une liste d’endroits, toujours dans le même ordre, et prend le premier fichier qui porte le bon nom, sans vérifier s’il est authentique. C’est là tout le problème : si quelqu’un a le droit d’écrire dans un endroit regardé tôt dans la liste, il y dépose un faux fichier, et le programme charge ce faux fichier à la place du vrai. Le code de l’attaquant tourne alors avec les droits du programme visé. Certains programmes transportent eux-mêmes une liste d’endroits à regarder, inscrite au moment de leur fabrication ; si cette liste contient des emplacements relatifs ou modifiables, elle devient une porte d’entrée. Le système protège quand même les programmes puissants, ceux qui changent d’identité pour obtenir plus de droits : pour eux, il ignore les raccourcis qu’un utilisateur pourrait fournir. Mais cette protection ne couvre que ces programmes-là ; un service ordinaire, même très sensible, n’en profite pas. Les bonnes défenses sont donc : des listes d’endroits fixes, absolues, non modifiables, pas de raccourci relatif, un fichier de configuration verrouillé, et des fichiers de code signés pour prouver leur origine.

À lire aussi

Pages voisines

: les deux pages se citent l’une l’autre.