Aller au contenu

Shellshock CVE-2014-6271 : exécution de code via l'environnement Bash

Shellshock CVE-2014-6271 : exécution de code via l’environnement Bash

Section intitulée « Shellshock CVE-2014-6271 : exécution de code via l’environnement Bash »

En septembre 2014, Stéphane Chazelas signale que Bash, en reconstituant une fonction exportée depuis une variable d’environnement, exécute aussi tout ce qui suit la définition de la fonction (CVE-2014-6271). Le défaut dormait dans le code depuis plus de vingt ans. Il devient critique parce qu’une foule de programmes recopient des données venues du réseau dans l’environnement d’un Bash enfant : serveurs web en CGI, clients DHCP, comptes SSH à commande forcée.

→ Lire l’explication complète de la marmotte

Bash permet d’exporter une fonction vers ses processus enfants avec export -f. Comme le noyau ne transmet que des chaînes NOM=valeur via execve(), Bash sérialisait la fonction dans une variable portant son nom, dont la valeur commençait par () {. Au démarrage, tout Bash parcourait son environnement, repérait les valeurs commençant par () { et les passait à son analyseur pour recréer la fonction.

Le défaut tient en une ligne : l’analyseur ne s’arrêtait pas à l’accolade fermante. Il évaluait la chaîne entière comme du code. Une valeur () { :; }; commande définissait une fonction vide, puis exécutait commande, au démarrage du shell, avant la moindre ligne du script appelé. Le nom de la variable n’avait aucune importance : HTTP_USER_AGENT, TERM ou FOO faisaient l’affaire.

Deux propriétés rendent la faille aussi grave :

  • aucune interaction n’est requise : le simple lancement de Bash suffit, le script n’a pas besoin de lire la variable ;
  • le nom est libre : toute variable dont l’attaquant contrôle la valeur devient un vecteur, quel que soit son usage prévu.

Le compromis de conception est limpide. On gagnait une fonctionnalité de confort (des fonctions disponibles dans les sous-shells sans les redéfinir), on payait un analyseur de code complet branché sur une entrée que personne ne considérait comme une entrée. Vingt ans de métier apprennent que les fonctionnalités dont personne ne se souvient sont précisément celles qui écoutent sur le réseau.

La faille n’est exploitable à distance que si un composant recopie une donnée externe dans l’environnement d’un Bash. Trois chemins ont dominé.

CGI (Common Gateway Interface). La RFC 3875 impose au serveur web de transmettre les en-têtes HTTP (HyperText Transfer Protocol) au programme CGI sous forme de variables HTTP_*. Un en-tête User-Agent piégé devient HTTP_USER_AGENT. Si le script CGI est en Bash, ou s’il appelle system() sur un système où /bin/sh est Bash, la commande s’exécute avec les droits du serveur web. Le schéma suivant montre où la donnée change de statut : de chaîne HTTP inerte à code exécuté.

Schéma de séquence entre cinq acteurs en colonnes : Attaquant, Apache (mod_cgi), Processus CGI, Bash et Système. Étape 1, l'attaquant envoie à Apache une requête GET /cgi-bin/status.cgi dont l'en-tête User-Agent contient la charge () { :; }; /bin/id. Étape 2, Apache lance le processus CGI par execve() en plaçant la charge dans la variable HTTP_USER_AGENT. Étape 3, le processus démarre un shell si le script est en Bash ou s'il appelle system() avec /bin/sh égal à bash. Étape 4, Bash exécute /bin/id sur le système avant même la première ligne du script : la donnée devient code. Étape 5, la sortie remonte jusqu'à l'attaquant dans la réponse HTTP si le script relaie stdout.

DHCP (Dynamic Host Configuration Protocol). Le client dhclient de l’Internet Systems Consortium (ISC) transmet les options reçues du serveur (nom de domaine, nom d’hôte) à dhclient-script, un script shell, via l’environnement. Un serveur DHCP pirate sur le même segment obtient alors l’exécution de code en root sur la machine qui demande une adresse. Sur un réseau Wi-Fi public, le vecteur relève de l’évidence.

SSH (Secure Shell) avec commande forcée. Une clé déclarée avec command="..." dans authorized_keys, ou un ForceCommand dans sshd_config, restreint ce qu’un utilisateur peut lancer. Mais la commande demandée par le client arrive dans SSH_ORIGINAL_COMMAND, et les variables autorisées par AcceptEnv (souvent LANG, LC_*) sont recopiées telles quelles. Si l’interpréteur de la commande forcée est Bash, la restriction saute : les comptes Git restreints et les sauvegardes par rsync contraint étaient exactement dans ce cas.

D’autres chemins existent dès qu’un démon passe des données externes à un shell : filtres de messagerie appelant un script, agents de supervision exécutant des sondes, interfaces d’administration d’équipements embarqués. Ces dernières sont les plus durables, car leur Bash est figé dans un micrologiciel que personne ne met à jour.

Le facteur aggravant tient au choix de /bin/sh. Sur Debian et Ubuntu, /bin/sh pointe vers dash, qui ignore les fonctions exportées : un system() y était inoffensif. Sur les distributions de la famille Red Hat, /bin/sh est Bash, et chaque system() ou popen() d’un programme C, PHP ou Perl devenait un vecteur potentiel.

Un correctif incomplet et cinq CVE complémentaires

Section intitulée « Un correctif incomplet et cinq CVE complémentaires »

Le premier correctif, publié le jour de la divulgation, empêchait l’exécution des commandes placées après l’accolade. Il ne supprimait pas le fait que Bash soumette des données externes à son analyseur. Dans les heures qui suivent, Tavis Ormandy montre qu’une construction malformée laisse encore l’analyseur dans un état exploitable : c’est CVE-2014-7169 (Common Vulnerabilities and Exposures). Suivent CVE-2014-7186 et CVE-2014-7187 (débordements dans la gestion des redirections imbriquées et des boucles), puis CVE-2014-6277, trouvée par Michal Zalewski par fuzzing du même analyseur, et CVE-2014-6278, trouvée par Florian Weimer.

La correction de fond a été conceptuelle, pas ponctuelle. Bash ne reconnaît plus une fonction exportée qu’à partir d’un nom de variable préfixé et suffixé, de la forme BASH_FUNC_nom%%. Un serveur web ne produit jamais une variable nommée ainsi, puisque ses noms sont HTTP_* ; l’analyseur cesse donc de voir les valeurs arbitraires. La série de correctifs Bash 4.3 s’étend de bash43-025 à bash43-030, avec des rétroportages équivalents pour les branches antérieures.

Deux règles générales en découlent. Un correctif publié sous pression traite le symptôme signalé, pas la classe de défaut : le premier patch d’une faille d’analyseur appelle presque toujours un second. Et une version marquée « corrigée » dans un bulletin ne dit rien des autres CVE de la même famille ; l’inventaire doit vérifier le niveau de correctif complet, pas la présence d’un premier patch.

Les scans massifs ont commencé dans les heures suivant la divulgation, avec des charges qui téléchargeaient un binaire de botnet par wget ou curl puis l’exécutaient. Entre la publication et l’application du correctif définitif, la fenêtre d’exposition se comptait en jours pour les serveurs maintenus, en années pour les équipements embarqués.

Le test de référence lance un Bash enfant avec une variable piégée. Si le mot VULNERABLE s’affiche, l’import de fonction exécute le code de fin de chaîne.

Fenêtre de terminal
# Test CVE-2014-6271 : la variable "x" contient une fonction vide suivie d'une commande
env x='() { :; }; echo VULNERABLE' bash -c 'echo test'
# Bash vulnérable : affiche "VULNERABLE" puis "test"
# Bash corrigé : affiche seulement "test" (éventuellement un avertissement)
# Test CVE-2014-7169 (correctif initial incomplet) : crée un fichier nommé "echo" si vulnérable
cd /tmp && env X='() { (a)=>\' bash -c 'echo date'; cat echo 2>/dev/null && rm -f echo
# Vérifier la forme moderne d'export de fonction (préfixe BASH_FUNC_ et suffixe %%)
f() { :; }; export -f f; env | grep '^BASH_FUNC_'

Côté détection, la signature réseau est la séquence () { dans un en-tête HTTP, une option DHCP ou une variable SSH. Elle se recherche dans les journaux d’accès (User-Agent, Referer, Cookie) et sur un IDS (Intrusion Detection System). Elle est triviale à contourner par espacement ou encodage de l’en-tête et sert à mesurer le bruit de fond, pas à protéger. Le signal le plus fiable reste la lignée de processus : un httpd, dhclient ou sshd qui engendre bash, lequel engendre wget, curl ou nc, ne correspond à aucun usage légitime.

L’environnement hérité, une entrée non fiable

Section intitulée « L’environnement hérité, une entrée non fiable »

Shellshock est la forme la plus spectaculaire d’une classe plus large, décrite dans la page sur la pollution de variables d’environnement : un processus enfant hérite d’un environnement que son parent n’a pas filtré, et une variable détourne son comportement. LD_PRELOAD et LD_LIBRARY_PATH le font pour l’éditeur de liens (voir le détournement de bibliothèque partagée), PYTHONPATH et PERL5LIB pour les interpréteurs, BASH_ENV pour Bash lui-même.

La position à tenir est sans nuance : tout composant qui lance un processus enfant construit son environnement par liste blanche. Concrètement :

  • lancer les enfants avec execve() et un tableau d’environnement explicite, ou env -i suivi des seules variables nécessaires ;
  • ne jamais passer par un shell quand un execve() direct suffit : system("cmd " + arg) cumule injection d’arguments et héritage d’environnement ;
  • pointer /bin/sh vers un shell minimal (dash) sur les distributions qui le permettent, et réserver Bash aux scripts qui en ont besoin ;
  • conserver env_reset dans la configuration de sudo et restreindre AcceptEnv dans sshd_config au strict nécessaire ;
  • retirer CGI des serveurs exposés : le modèle « un processus par requête, données dans l’environnement » n’a plus de justification face à un serveur applicatif.

Le compromis se résume ainsi : la liste blanche casse des scripts qui comptaient sur une variable héritée par accident (un PATH personnalisé, un proxy dans http_proxy), et ces casses apparaissent au déploiement. Le filtrage par liste noire ne casse rien, et c’est bien le problème : il laisse passer la variable qu’on n’avait pas imaginée. Les correctifs de sécurité ont l’élégance de ne jamais demander l’avis du script de 2009 qui n’a plus de propriétaire.

Un Bash à jour ferme l’import de fonctions arbitraires. Il laisse ouverts plusieurs chemins voisins.

  • BASH_ENV : un Bash non interactif lit le fichier désigné par cette variable avant d’exécuter son script. Quiconque contrôle BASH_ENV et peut écrire un fichier sur la machine obtient l’exécution de code, correctif ou pas.
  • L’injection dans le script lui-même : un script qui fait eval "$HTTP_X_FOO" ou qui utilise une variable sans guillemets dans une commande reste injectable. Shellshock frappait avant le script ; l’injection classique frappe dedans.
  • Le contrôle des noms de variables : le préfixe BASH_FUNC_ protège parce que les vecteurs distants contrôlent les valeurs, pas les noms. Un composant qui laisse un tiers choisir des noms de variables (paramètres d’appel transformés en environnement sans préfixe) rouvre le chemin.
  • Les copies embarquées : images de conteneurs anciennes jamais reconstruites, micrologiciels d’appliances, environnements de compilation figés. Le paquet système corrigé ne corrige pas ces copies-là.
  • Les intrusions antérieures : patcher ne retire ni le binaire de botnet déposé, ni la tâche planifiée ajoutée, ni la clé SSH glissée dans authorized_keys.

Prérequis et conditions d’exploitation de la remédiation

Section intitulée « Prérequis et conditions d’exploitation de la remédiation »

La remédiation suppose un inventaire de Bash par version et par niveau de correctif, y compris dans les images de conteneurs, les machines virtuelles dormantes et les équipements réseau ou de stockage dont l’interface d’administration repose sur des scripts. Un scanner de vulnérabilités qui ne lit que le paquet du système hôte rate la moitié du parc.

Pour l’exploitation courante :

  • supervision : alerte sur la lignée de processus (démon réseau engendrant un shell puis un outil de transfert), revue des connexions sortantes inattendues depuis les serveurs web ;
  • reconstruction : les images de conteneurs se reconstruisent depuis une base corrigée, elles ne se « patchent » pas en place ; une image sans date de reconstruction connue est présumée vulnérable ;
  • reprise : une machine dont l’exposition remonte à avant le correctif se réinstalle depuis une source saine plutôt que de se nettoyer, faute de pouvoir prouver l’absence de persistance ;
  • secrets : tout secret lisible par le compte du serveur web (fichiers de configuration, variables d’environnement contenant des mots de passe de base de données) se considère comme exposé et se renouvelle.

Le dernier point rappelle une évidence que l’industrie redécouvre régulièrement : stocker les secrets dans l’environnement du processus, c’est les remettre à quiconque obtient l’exécution de code dans ce processus ou ses enfants. Les pages voisines sur l’upload de shells déguisés et sur l’élévation de privilège par SUID décrivent la suite habituelle une fois le premier shell obtenu ; le catalogue complet des failles documente les autres.

Un serveur sous PHP ou Python était-il vulnérable sans script CGI en Bash ?

Section intitulée « Un serveur sous PHP ou Python était-il vulnérable sans script CGI en Bash ? »

Seulement si un shell Bash était lancé avec un environnement contenant des données externes. Avec mod_php, les en-têtes HTTP ne sont pas recopiés dans l’environnement hérité par system() ou exec() : le risque restait faible. Un déploiement CGI ou FastCGI qui transmet les en-têtes en variables, sur un système où /bin/sh est Bash, restait en revanche exploitable.

Une règle de pare-feu applicatif suffisait-elle comme mesure temporaire ?

Section intitulée « Une règle de pare-feu applicatif suffisait-elle comme mesure temporaire ? »

Une règle de WAF (Web Application Firewall) bloquant () { dans les en-têtes réduisait le bruit des scanners automatiques. Elle ne couvrait ni DHCP, ni SSH, ni les variantes d’encodage. Elle achetait quelques heures, pas une protection.

Pourquoi la faille a-t-elle survécu plus de vingt ans ?

Section intitulée « Pourquoi la faille a-t-elle survécu plus de vingt ans ? »

Parce que l’import de fonctions était vu comme une fonctionnalité interne de Bash, et que l’environnement n’apparaissait dans aucun modèle de menace comme une entrée contrôlée par un tiers. Aucun outil d’analyse ne cherche une faille dans un endroit que personne ne considère comme une frontière.

  • CVE-2014-6271, MITRE.
  • CVE-2014-7169, MITRE.
  • CVE-2014-7186, MITRE.
  • CVE-2014-7187, MITRE.
  • CVE-2014-6277, MITRE.
  • CVE-2014-6278, MITRE.
  • RFC 3875, IETF.

Sans filtrage de l’environnement transmis aux processus enfants, chaque démon qui lance un shell devient une porte d’exécution pour qui contrôle une seule valeur : un en-tête HTTP, une option DHCP, une variable acceptée par SSH. Le code de l’attaquant s’exécute avec les droits du démon, parfois root dans le cas du client DHCP, avant toute ligne du script légitime, donc hors de portée de toute validation applicative. Les restrictions par commande forcée deviennent décoratives. Les secrets placés dans l’environnement du serveur partent avec la première requête. Et comme le processus fautif est un shell banal engendré par un démon banal, la journalisation applicative ne voit rien : seule la lignée de processus trahit l’intrusion, à condition de la surveiller.

Sur un ordinateur, quand un programme en lance un autre, il lui transmet une liste de petites informations appelées variables d’environnement : un nom et une valeur, par exemple la langue ou le dossier de travail. Bash est un programme qui exécute des commandes. Il avait une particularité : si la valeur d’une variable commençait par certains caractères, il la prenait pour un morceau de programme à recréer. Le défaut, découvert en 2014, est qu’il exécutait aussi tout ce qui était écrit après ce morceau. Or beaucoup de logiciels recopient dans ces variables des informations venues d’Internet. Un serveur web, par exemple, recopie le nom du navigateur envoyé par le visiteur. Il suffisait donc d’envoyer un faux nom de navigateur contenant une commande : le serveur lançait Bash, Bash lisait la variable, et la commande s’exécutait sur le serveur. La même chose marchait avec la machine qui distribue les adresses sur un réseau, ou avec certains accès à distance limités. La première réparation était incomplète, et cinq autres défauts proches ont été trouvés ensuite. La vraie solution a changé la façon dont Bash reconnaît ces morceaux de programme. Mais la leçon dépasse Bash : tout ce qu’un programme reçoit de son parent peut venir d’un inconnu, et il faut ne transmettre que les informations strictement nécessaires.

À lire aussi

Pages voisines