Éviter les injections de commandes en PHP
Divulgâchage : Avec le temps, on en vient tous à lancer des commandes depuis nos applications web. Le problème, c'est lorsque les utilisateurs fournissent des paramètres, il faut alors être particulièrement prudent pour éviter des injections de commandes qui détourneraient votre application. Heureusement, la solution est facile à mettre en œuvre.
Lorsqu’on développe une application web, il arrive toujours un moment où on aimerait lancer une commande système ou un programme local. Sur des petits projets, on s’en sort très bien sans, mais plus le projet grossi, plus la probabilité d’en avoir besoin tend vers .
La plupart du temps, on peut se débrouiller dans notre langage de programmation favoris en trouvant des équivalents ou en recodant la commande. Mais parfois, le coût de développement, ou plutôt celui de la maintenance, nous dissuadent et on en vient alors, naturellement, à lancer des commandes externes.
Le problème, comme on va le voir aujourd’hui (en PHP), c’est lorsqu’on utilise des données fournies par les visiteurs. Comme ils ne sont pas tous forcément gentils, certains pourraient vont insérer n’importe quoi pour détourner notre belle application et lui faire exécuter ce qu’ils veulent.
La prudence s’impose et les maladresses coûtent cher.
La bonne nouvelle, c’est qu’il est possible d’éviter ces problèmes spécifiques d’injection de commande relativement facilement. Au point qu’on peut même automatiser la vérification et garantir un code sûr…
Exécution de commandes
Lorsqu’on a besoin d’exécuter une commande ou un programme externe, de nombreuses fonctions sont souvent disponibles. Par exemple, en C, on peut utiliser execve() sous Linux ou ShellExecuteA() sous Windows.
En PHP, on dispose d’autres fonctions du même genre pour exécuter une commande passée en paramètre, avec chacune sa particularité :
- passthru() et system() passent la sortie de la commande (ce qu’elle afficherait) directement au client de l’application dans la réponse web,
system()fournis, en plus, le code de retour de la commande, - shell_exec() et exec() vous retournent la sortie de la commande dans une chaîne de caractère pour pouvoir la manipuler,
exec()fournis en plus, le code de retour de la commande, - proc_open() vous met les mains dans le cambouis et permet un contrôle plus fin du processus.
Par exemple, si vous voulez lister (commande ls) tous les fichiers et répertoires (option -a) en détail (option -l) par ordre de création (option -t) croissante (option -r), vous pourriez utiliser ce bout de code (c’est en fait l’exemple officiel) :
<?php
$output = shell_exec('ls -lart');
echo "<pre>$output</pre>";D’expérience, on rencontre le plus souvent shell_exec() car elle correspond à la majorité des cas : lancer une commande, récupérer sa sortie pour la manipuler et poursuivre l’exécution en fonction. Les autres fonctions (passthru(), system(), exec() et surtout proc_open()) sont bien plus spécifiques et se rencontrent donc moins souvent1.
Injection de commandes
Qui dit application, dit données utilisateurs. Les commandes que vous allez lancer utilisent donc, de près ou de loin, des données fournies par les utilisateurs. Après plus ou moins de manipulation mais la plupart du temps, un bout de la commande dépend de ce que l’utilisateur vous fourni.
Voici un exemple de code, librement simplifié à partir des exercices d’injection shell de Damn Vulnerable Web Application. Ici, on proposes à un visiteur de lancer une requête ICMP ECHO REQUEST (un ping) vers une machine de son choix (ce genre d’application existe vraiment).
if( isset( $_REQUEST['ip'] ) ) {
$target = $_REQUEST[ 'ip' ];
$output = shell_exec( "ping -c 4 {$target}");
echo "<pre>{$output}</pre>\n" ;
}Un utilisateur normal fournira une adresse IP pour savoir si une machine est joignable et si le réseau fonctionne… Un peut comme avec la requête suivante :
Qu’on peut tout aussi bien lancer en ligne de commande avec curl. C’est moins visuel, mais permet à tout le monde de le lire :
tbowan@nop:~$ curl "http://localhost?ip=192.168.1.1"
<pre>PING 192.168.1.1 (192.168.1.1) 56(84) bytes of data.
64 bytes from 192.168.1.1: icmp_seq=1 ttl=63 time=1.50 ms
64 bytes from 192.168.1.1: icmp_seq=2 ttl=63 time=1.36 ms
64 bytes from 192.168.1.1: icmp_seq=3 ttl=63 time=1.13 ms
64 bytes from 192.168.1.1: icmp_seq=4 ttl=63 time=1.03 ms
--- 192.168.1.1 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3003ms
rtt min/avg/max/mdev = 1.032/1.258/1.500/0.187 ms</pre>Mais un utilisateur moins gentil pourrait ajouter ses propres commandes. Par exemple, en fournissant le paramètre ;uname -a pour obtenir des informations sur le système :
Qu’on peut bien sûr aussi lancer avec curl. Dans ce cas, il faut passer le paramètre uname%20-a, l’espace devant être url encodé (traduit en %20) pour être géré par le serveur web :
tbowan@nop:~$ curl "http://localhost?ip=;uname%20-a"
<pre>Linux nop 4.15.0-72-generic #81-Ubuntu SMP Tue Nov 26 12:20:02 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux</pre>Ça marche parce qu’une fois ajouté à la commande passée à shell_exec(), on exécute en fait la ligne de commande suivante :
pinc -c 4 ;uname -aLe ; sépare la ligne en deux commandes. D’abord ping, qui va échouer silencieusement (l’erreur n’est pas sur la sortie standard mais dans les fichiers de log comme /var/log/apache2/error.log). Ensuite uname, dont la sortie nous sera retournée.
Vous imaginez bien que si on peut faire ça, on peut tout faire (avec les droits du serveur web) : Lire, écrire et exécuter les fichiers, les commandes, … Suivant l’objectif, ça sera plus ou moins discret et plus ou moins destructeur.
Protection des arguments
Le problème, c’est que les fonctions d’exécution de commande ne savent pas faire la différence entre vos arguments à vous et ceux des utilisateur qui veulent détourner votre application. Dans le doute, il traite tout de la même manière en se disant que c’est votre problème (et il a raison).
Si vous voulez filtrer vous-même les caractères spéciaux et ce genre de chose, je vous le déconseille car, comme pour la cryptographie, bricoler un truc sois-même, ça marche jamais bien fort. D’autres exemples d’exercices d’injections de commandes peuvent vous montrer pourquoi :
- Natas 9 qui sert de base et ne filtre rien (comme l’exemple précédent),
- Natas 10 qui filtre l’entrée et interdit
&et;mais comme il ne filtre pas|vous pouvez quand même vous en sortir, - Natas 16 qui filtre encore plus mais oublie le
$, ce qui vous laisse encore des possibilités
Et on n’a même pas traité la pollution des arguments qui consiste, en ajoutant des espace et des guillemets (simples ou double suivant les cas) à ajouter plusieurs paramètres d’un coup et détourner les usages des commandes utilisées dans vos applications. Technique utilisable sur Natas 10 s’ils n’avaient oubliés aucun caractère spécifique.
Le truc, c’est que le PHP fourni justement deux fonctions pour échapper les caractères problématiques et éviter les injections de commandes. Alors plutôt que recoder votre propre moulinette, autant utiliser celle déjà disponible :
- escapeshellcmd() qui échappe, dans une ligne de commande complète, les caractères ayant un sens spécifique (Linux et Windows sont gérés), mais elle n’évite pas la pollution des arguments,
- escapeshellarg() qui protège les paramètres individuellement pour qu’ils ne puissent pas faire l’objet d’injection, mais nécessite de l’utiliser sur chaque paramètre (à minima ceux fournis par l’utilisateur).
Si on repart de l’exemple du ping, la correction consiste simplement à utiliser escapeshellarg() sur le paramètre $target puisqu’il est fourni par l’utilisateur :
if( isset( $_REQUEST['ip'] ) ) {
$target = $_REQUEST[ 'ip' ];
$output = shell_exec(
'ping -c 4 '
. escapeshellarg($target)
);
echo "<pre>{$output}</pre>\n" ;
}Cette fois, plus d’injection possible. Par contre, il faudra passer sur tous vos appels et vérifier manuellement les arguments qui nécessitent un échappement.
Décoration des commandes
Mais on peut aller plus loin en décorant shell_exec() par une couche d’échappements automatique sur tous les paramètres à passer à la commande.
Pour l’exemple, j’utilise ici une fonction variadique (permettant de gérer un nombre indéfini de paramètres) :
function escaped_shell_exec($cmd, ...$args) {
$line = escapeshellarg($cmd) ;
foreach ($args as $arg) {
$line .= " " . escapeshellarg($arg) ;
}
return shell_exec($line) ;
}Capilotraction : L’échappement appliqué au nom de commande lui-même (comme je l’ai fait ici) est sujet à discussions…
Il n’y a aucune raison de laisser un visiteur fournir le nom de la commande (e.g. il pourrait spécifier
/sbin/shutdownpour éteindre le serveur, ce genre de chose), et si vous ne laissez jamais de données utilisateur dans le nom de la commande, pas besoin de l’échapper…Si vous aviez besoin de le faire quand même (ce serait une faute de conception d’après moi) il faudrait un filtrage spécifique sur la commande avec une liste blanche des seules commandes autorisées. Avec une liste blanche, pas besoin d’échapper.
Mais comme ma fonction ne peut pas savoir dans quel contexte vous êtes, on peut imaginer que l’utilisateur fournisse une partie du nom de la commande et sans liste blanche pour vérifier, je préfère donc l’échapper aussi.
Avec cette fonction décorée, l’exemple du ping change à nouveau pour utiliser notre fonction et scinder la ligne de commande en paramètres individuels (je trouve que c’est plus lisible d’ailleurs) :
if( isset( $_REQUEST[ 'ip' ] ) ) {
$target = $_REQUEST[ 'ip' ];
$output = escaped_shell_exec(
'ping',
'-c', 4,
$target
);
echo "<pre>{$output}</pre>" ;
}Si on retente une injection de commande, celle-ci va échouer car le paramètre injecté (;uname -a) est considéré comme un paramètre à part entière et n’est plus interprété. Si vous la tentez quand même, le ping échouera avec un message d’erreur, visible dans les logs d’erreur du serveur web :
tbowan@nop:~$ tail -n 1 /var/log/apache2/error.log
ping: ;uname -a: Name or service not knownL’avantage d’une décoration sûre par construction (i.e. escaped_shell_exec()), c’est qu’on peut ensuite utiliser des outils d’analyse statique de code pour chercher et trouver les appels aux fonctions décorées (et vulnérables, shell_exec()). Un simple egrep sur votre base de code ne devrait pas vous retourner d’autre invocation que celle décorées :
egrep "\Wshell_exec" -r *Et après ?
La décoration que je vous ai proposé ici, avec escaped_shell_exec() est incomplète. D’un côté je ne gère pas les autres fonctions (passthru, system(), exec() et proc_open()) et le passage de leurs paramètres. De l'autre, je n'ai pas pris en compte les redirections d'entrées sorties (e.g. 2>&1) ni les enchaînements de commandes (e.g. ;) lorsqu’ils sont nécessaires. Ce n'est pas impossible à faire, mais ça sortait du cadre pour aujourd’hui. Une autre fois peut être 😉.