Le correctif fiable contre l'injection de commandes OS consiste à ne plus jamais transmettre à un shell de chaînes influencées par l'utilisateur. Améliorer l'échappement est une option plus faible. Le travail concret consiste à trouver chaque endroit où votre code lance un processus, puis à décider pour chacun s'il a vraiment lieu d'exister.
L'injection de commandes se produit quand une application transmet des données utilisateur non sûres à un shell système, et les commandes injectées s'exécutent généralement avec les privilèges de l'application. CWE-78 ajoute un détail gênant : puisque c'est l'application qui exécute les commandes, l'activité peut sembler provenir de l'application ou de son propriétaire.
Pourquoi l'échappement est la mauvaise première réponse
La fonction escapeshellarg() de PHP place une valeur entre apostrophes pour que le shell la traite comme un seul argument. Cela empêche & echo "hello" de s'exécuter comme une seconde commande. En revanche, cela n'empêche pas l'attaquant de choisir quel argument unique le programme reçoit.
Toute injection de commandes OS est aussi une injection d'arguments. Dans l'aide-mémoire OWASP, escape("--help") transmis à curl affiche tout de même le texte d'aide. Selon le programme, l'impact va de la divulgation d'informations à l'exécution de code à distance.
Une note de lecteur sur la page du manuel PHP consacrée à escapeshellarg() montre la même faille avec tar. Chaque nom de fichier est correctement échappé, mais une valeur commençant par un tiret est quand même interprétée comme une option, et tar possède une option qui exécute une autre commande. Ajouter -- avant les noms de fichiers règle le problème, mais la note souligne que tous les programmes ne prennent pas en charge ce séparateur.
Sous Windows, le manuel indique que escapeshellarg() utilise des guillemets doubles et remplace les signes pourcent, les points d'exclamation et les guillemets doubles par des espaces. Une note de lecteur précise que le résultat est sûr, mais avec perte. Dans les deux cas, c'est une raison d'éviter le shell, pas un correctif.
Trouver les appels
Lancez ces recherches sur tous vos dépôts. Adaptez les types de fichiers à votre organisation :
rg -n 'shell=True|os\.system|os\.popen' --type py
rg -n "subprocess\.(run|Popen|call|check_output)\(\s*[f\"']" --type py
rg -n '\b(system|exec|shell_exec|passthru|popen|proc_open)\s*\(' --type php
rg -n '`[^`]*\$' --type php
rg -n 'Runtime\.getRuntime\(\)\.exec|ProcessBuilder' --type java
rg -n 'ProcessStartInfo|Process\.Start|UseShellExecute' --type cs
rg -n '\bsystem\s*\(|popen\s*\(|exec[lv]p?e?\s*\(' --type c
Le second motif Python repère une chaîne unique passée comme commande. Sans shell=True, cela ne fonctionne que si la chaîne désigne un programme et rien d'autre, donc chaque résultat demande un examen manuel. Le motif PHP sur les backticks est bruyant, car il correspond aussi aux backticks dans les commentaires et le SQL. Cherchez également dans les scripts shell, les scripts de déploiement et les wrappers cron, qui construisent des commandes à partir de variables aussi souvent que le code applicatif.
| Langage | À chercher | Remplacer par |
|---|---|---|
| PHP | system(), exec(), shell_exec(), passthru(), popen(), proc_open(), l'opérateur backtick | Des fonctions natives comme mkdir() |
| Python | shell=True ; os.system ; os.popen ; une chaîne de commande unique | subprocess.run avec une liste |
| Java | Runtime.exec(String) | ProcessBuilder avec le programme et chaque argument comme éléments distincts |
| .NET | cmd.exe, powershell.exe, sh -c, une chaîne Arguments assemblée | ProcessStartInfo avec ArgumentList |
| C | system() | Un appel de bibliothèque |
Incluez les commandes écrites en dur. L'article de la communauté OWASP décrit un programme CGI privilégié qui exécute cd /var/yp && make sans aucune entrée utilisateur dans la chaîne. Il n'utilisait pas de chemin absolu pour make, donc un attaquant pouvait modifier $PATH et faire exécuter son propre binaire avec les privilèges du programme.
Trier les résultats
Une liste brute de résultats n'est pas une liste de constats. Pour chacun, remontez la valeur à sa source et demandez-vous si une requête peut l'atteindre. Les paramètres de route et de requête, les champs de formulaire, les cookies et les en-têtes HTTP sont les sources évidentes. Les noms de fichiers téléversés, les valeurs issues d'une base de données où les utilisateurs peuvent écrire, les messages de file d'attente et les variables d'environnement sont celles qu'on oublie.
Classez les résultats en trois piles : atteignables depuis une requête non authentifiée, atteignables uniquement après connexion, et non atteignables. Corrigez d'abord la première pile. Gardez la troisième sur la liste, car un refactoring ultérieur peut la relier à une requête sans que personne ne s'en aperçoive.
Remplacer l'appel par une API
Les fonctions de bibliothèque natives ne peuvent pas être manipulées pour faire autre chose que leur tâche prévue. Utilisez mkdir() plutôt que system("mkdir /dir_name"). Utilisez l'API de messagerie Java javax.mail.* plutôt que d'appeler mail via un shell. CWE-78 donne le même conseil pour la conception : utiliser des appels de bibliothèque plutôt que des processus externes.
Quand il faut lancer un processus
Certains outils n'ont pas d'équivalent en bibliothèque. Dans ce cas, combinez deux couches : une liste d'arguments sans shell, et une validation.
La documentation du module subprocess de Python indique qu'une séquence d'arguments est généralement préférable, car le module gère l'échappement et le quoting. Elle invite à lire la section Security Considerations avant d'utiliser shell=True. Elle recommande aussi un chemin complet pour l'exécutable, ce qui supprime la recherche dans PATH exploitée dans l'exemple make ci-dessus.
import re, subprocess
if not re.fullmatch(r"[a-z0-9]{3,10}", name):
raise ValueError("rejected")
subprocess.run(["/usr/bin/tar", "cf", "out.tar", "--", name], check=True)
Le motif rejette déjà un tiret initial, donc -- semble redondant. C'est de la défense en profondeur : si quelqu'un assouplit plus tard l'expression régulière, le séparateur empêche toujours name d'être lu comme une option.
En .NET, utilisez un chemin fixe et complet dans FileName, définissez UseShellExecute = false et ajoutez chaque argument via ArgumentList. Ne transmettez jamais d'entrée non fiable à cmd.exe, powershell.exe ou sh -c. Séparer les arguments n'est pas de la validation, ni en Java ni en .NET.
Validez ensuite les valeurs. Vérifiez la commande par rapport à une liste de commandes autorisées, et chaque argument par rapport à des valeurs autorisées ou à un motif avec une longueur maximale, en écartant les métacaractères et les espaces.
Pour curl, -- ne suffit pas, car les arguments suivants restent des opérandes d'URL et d'autres transferts sont possibles. Ne transmettez qu'une seule URL validée, utilisez --globoff et appliquez des protections contre le SSRF.
Exécutez l'application avec les privilèges minimaux dont elle a besoin, et donnez aux tâches à usage unique leur propre compte isolé. Un bac à sable comme chroot, AppArmor ou SELinux aide, mais CWE-78 juge son efficacité limitée. Il réduit ce qu'un attaquant peut atteindre sans supprimer la faille.
Par où commencer
- Lancez les recherches ci-dessus sur tous les dépôts, y compris les scripts de déploiement, les jobs CI et les wrappers cron. Listez chaque résultat avec l'entrée qui l'atteint, et triez la liste selon qu'une requête peut l'atteindre ou non.
- Pour chaque résultat, demandez-vous si une bibliothèque ou une fonction native fait le même travail. Si oui, remplacez l'appel et supprimez le lancement de processus.
- Réécrivez les appels restants avec une liste d'arguments, sans shell et avec un chemin absolu vers l'exécutable. Ajoutez une validation par liste d'autorisation et le séparateur
--lorsque le programme le prend en charge. - Exécutez les processus sous un compte dédié à faibles privilèges, afin qu'un cas oublié offre moins de prise.
- Testez chaque appel restant avec une valeur comme
bob.txt;sleep 5et avec une valeur commençant par un tiret. Mesurez le temps de réponse plutôt que d'attendre une sortie. La valeur préfixée par un tiret doit être rejetée ou traitée comme un nom de fichier. Pour une vérification externe, un test d'intrusion essaiera les deux, ou notre équipe de développement logiciel sécurisé peut revoir le code avec vous.
