É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 11.

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.

Parfois, on doit mettre les mains dans le cambouis. MustangJoe @ pixabay

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é :

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 souvent.

Pour être plus complet, vous pourriez également rencontrer, en PHP, une dernière variante pcntl_exec() qui fonctionne comme execve :

  1. Plutôt que fournir une ligne de commande complète, cette fonction vous demande de la scinder ; en fournissant le chemin vers le programme puis des tableaux pour les arguments et les variables d’environnement. Elle ne sera pas vulnérable à l’injection.
  2. Elle va remplacer le processus courant par le programme appelé, il n’est donc pas possible de récupérer la sortie pour la manipuler et poursuivre l’exécution en PHP.

Elle n’est ainsi disponible que si PHP est lancé en CLI (ligne de commande) ou CGI (processus à part lancé par le serveur web). On la rencontre donc encore plus rarement que les versions précédentes.

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.

Les utilisateurs ne sont pas tous gentils. cocoparisienne @ pixabay.com

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 :

Exemple de résultat avec une adresse IP valide

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 :

Exemple de résultat avec une injection de commande

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 -a

Le ; 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).

Protéger les arguments un par un. Glady @ pixabay

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 :

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.

Cacher les problèmes avec une couche de protection. congerdesign @ pixabay

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/shutdown pour é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 known

L’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 😉.