É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 souvent.
Pour être plus complet, vous pourriez également rencontrer, en
PHP
, une dernière variante pcntl_exec() qui fonctionne commeexecve
:
- 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.
- 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.
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 -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).
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ésEt 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/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 😉.