Détails du déni de service Drupal : DRUPAL_SA_CORE_2014_003

Analyse de la nouvelle vulnérabilité DOS (Déni de service) DRUPAL_SA_CORE_2014_003 (CVE-2014-5019).

English version available on regilero's blog.

SA_CORE_2014_003

Cet été les versions 6.x et 7.29 & 7.31 de Drupal ont été livrées, elles contenaient des correctifs de sécurité importants. Il y a eu suffisament de temps depuis ces livraisons, je peux donc maintenant donner quelques détails sur la vulnérabilité SA-CORE-2014-003 - Drupal core - Multiple vulnerabilities. Cette annonce Drupal contenait 3 bugs. Celui que nous allons analyser ici est le bug de Déni de Service contenu dans le coeur de Drupal qui est aussi référencé dans la base de données CVE officielle comme CVE-2014-5019. Ce post est une analyse détaillée de la façon dont drupal utilise l'entête 'Host' de la requête HTTP pour trouver le bon fichier de configuration, et pourquoi ceci était utilisable pour provoquer une attaque par déni de service y compris sur les sites qui n'utilisent pas la fonctionnalité multi-site de Drupal. Il contient quelques éléments assez peu connus sur les manipulations d'entêtes 'Host', et donc j'espère que ceci pourra aider d'autres projets à éviter ce genre de problèmes.

Notez qu'il y avait une régression dans la version 7.29 de Drupal, avec les images et fichiers attachés aux termes de taxonomie, qui a été fixée sur la version suivante, la 7.30 (la régression ne concernait pas le patch discuté ici).

Notez aussi qu'un autre bug de sécurité du coeur a été fixé juste après celui-ci (SA-CORE-2014-004 - Drupal core - Multiple vulnerabilities), qui était une autre faille DOS venant du fichier xmlrpc.php, donc si vous n'avez pas en place quelque chose qui prévient l'exécution à distance de ce script vous devriez vraiment mettre à jour votre Drupal à la verion 7.31. Mon conseil est de modifier votre configuration Apache/Nginx pour n'autoriser qu'un seul fichier PHP, index.php, cela permet de résoudre un graaaand nombre de problèmes.

Si vous avez des peurs sur la mise à jour du coeur de Drupal vous pouvez au minimum appliquer le patch du DOS pour cette première faille DOS (ce n'est pas le patch pour le problème avec xmlrpc.php), qui est très simple:

diff --git a/includes/bootstrap.inc b/includes/bootstrap.inc
index 0b81dc0..dc08dd6 100644
--- a/includes/bootstrap.inc
+++ b/includes/bootstrap.inc
@@ -700,7 +700,14 @@ function drupal_environment_initialize() {
  *  TRUE if only containing valid characters, or FALSE otherwise.
  */
 function drupal_valid_http_host($host) {
-  return preg_match('/^\[?(?:[a-zA-Z0-9-:\]_]+\.?)+$/', $host);
+  // Limit the length of the host name to 1000 bytes to prevent DoS attacks with
+  // long host names.
+  return strlen($host) <= 1000
+    // Limit the number of subdomains and port separators to prevent DoS attacks
+    // in conf_path().
+    && substr_count($host, '.') <= 100
+    && substr_count($host, ':') <= 100
+    && preg_match('/^\[?(?:[a-zA-Z0-9-:\]_]+\.?)+$/', $host);
 }

Donc, c'était quoi le problème?

Les Hostnames falsifiables

En regardant simplement le patch on voit que le but est de prévenir :

  • les hostnames (noms d'hôte) longs de plus de 1000 caractères
  • les hostnames avec plus de 100 points (ou sous-domaines)
  • les hostnames avec plus de 100 : (utilisés pour les ports et quelquefois pour IPv6)

Le hostname est une partie très importante de la requête HTTP cliente. Quand vous interrogez un site, qui peut être un site Drupal, votre client HTTP va envoyez plusieurs entêtes et l'un d'eux est le 'hostname', c'est l'entête Host::

GET /page/foo?z=42 HTTP/1.1
Host: www.example.com
(other headers)

Le serveur HTTP recevant cette requête va décider quel VirtualHost utiliser pour gérer cette demande, généralement il va servir de cette entête Host pour choisir parmi plusieurs VirtualHost disponibles (parfois un même serveur HTTP est utilisé pour gérer plusieurs centaines de sites webs). Habituellement cette entête Host devrait contenir le DNS de votre site web. Cette entête est obligatoire en HTTP/1.1 et peut aussi être utilisée avec HTTP/1.0.

Mais cet entête est falsifiable par le client, elle peut donc contenir n'importe quoi. Heureusement (ou du moins c'est ce qu'on espère couramment) avoir un mauvais hostname devrait empêcher la réception de la requête sur votre installation Drupal valide à cause de plusieurs éléments:

  • Les injections vraiment mauvaises (comme des caractères nuls -- null-bytes--) sont détectées par le serveur HTTP et la connexion est fermée automatiquement
  • Les entêtes ne peuvent pas contenir plus de 8000 caractères (environ), ce qui limite la taille de l'entête modifié (mais, bon, 8000 c'est déjà énorme)
  • les hostnames non reconnus sont dirigés vers le virtualhost par défaut, qui peut être votre site Drupal, mais peut-être pas
  • Drupal s'assure aussi que le hostname reçu ne contient qu'un sous-ensemble de caractères valides
  • Les entêtes Host multiples sont concaténés avec "," (virgule et espace -- et drupal rejette les entêtes avec espaces)

En 2013 un excellent papier Practical Host headers attacks a été publié par James Kettle et nous pouvons noter plusieurs éléments très importants dans cet article:

  • La protection par VirtualHost par défaut ne marche pas, j'en reparle en fin de page. Ceci est valide pour Nginx et Apache.
  • une application qui se repose trop sur cet entête peut souffrir de problèmes importants.

Ce papier a été étudié par l'équipe de sécurité de Drupal, vous pouvez consulter les discussions ici, et l'un des faits à retenir de tout cela est que vous devriez forcer la valeur du paramètre $base_url quand drupal est installé en production pour éviter les attaques basées sur les mails de renouvellement de mots de passe.

Jusqu'ici pas vraiment de problèmes.

Recherche du fichier de paramètres avec conf_path()

Drupal possède déjà une fonction drupal_valid_http_host qui s'assure que le hostname ne contient pas de caractères comme /,\,%, ou &. Uniquement des lettres, des chiffres, des points et :. Drupal ne se repose pas sur la sécurité du serveur HTTP pour s'assurer qu'il dispose d'un Hostname propre, et c'est toujours bon d'ajouter quelques couches de sécurité au niveau applicatif.

C'est un bon point en terme de sécurité, parce que ce hostname est utilisé dans la fonction conf_path(), et c'est une fonction qui intervient très tôt dans le processus de démarrage de Drupal (bootrap).

Une des ces premières étapes pendant le bootstraping de Drupal est de charger le fichier de configuration. Ce fichier va vous donner, par exemple, accès à la base de données, ou le paramètre forcé $base_url.

/**
 * Sets the base URL, cookie domain, and session name from configuration.
 */
function drupal_settings_initialize() {
  global $base_url, $base_path, $base_root;

  // Export these settings.php variables to the global namespace.
  global $databases, $cookie_domain, $conf, $installed_profile,     $update_free_access, $db_url, $db_prefix, $drupal_hash_salt, $is_https, $base_secure_url, $base_insecure_url;
  $conf = array();

  if (file_exists(DRUPAL_ROOT . '/' . conf_path() . '/settings.php')) {
    include_once DRUPAL_ROOT . '/' . conf_path() . '/settings.php';
  }

Ici on voit que le chemin des 'settings' dépends de l'appel à conf_path(). Cette fonction est assez courte, il y a un appel drupal_static à l'intérieur qui est simplement une méthode pour éviter de recalculer des choses plusieurs fois par requête HTTP, c'est en fait un raccourci lazy-loading-run-only-once (ou en français, voyons voir... chargement-paresseux-ne-tournant-qu-une-seule-fois).

function conf_path($require_settings = TRUE, $reset = FALSE) {
  $conf = &drupal_static(__FUNCTION__, '');

  if ($conf && !$reset) {
    return $conf; //<-- here is the run-only-once thing I was talking about
  }

  $confdir = 'sites';

  $sites = array();
  if (file_exists(DRUPAL_ROOT . '/' . $confdir . '/sites.php')) {
    // This will overwrite $sites with the desired mappings.
    include(DRUPAL_ROOT . '/' . $confdir . '/sites.php');
  }

  $uri = explode('/', $_SERVER['SCRIPT_NAME'] ? $_SERVER['SCRIPT_NAME'] : $_SERVER['SCRIPT_FILENAME']);
  $server = explode('.', implode('.', array_reverse(explode(':', rtrim($_SERVER['HTTP_HOST'], '.')))));
  for ($i = count($uri) - 1; $i > 0; $i--) {
    for ($j = count($server); $j > 0; $j--) {
      $dir = implode('.', array_slice($server, -$j)) . implode('.', array_slice($uri, 0, $i));
      if (isset($sites[$dir]) && file_exists(DRUPAL_ROOT . '/' . $confdir . '/' . $sites[$dir])) {
        $dir = $sites[$dir];
      }
      if (file_exists(DRUPAL_ROOT . '/' . $confdir . '/' . $dir . '/settings.php') || (!$require_settings && file_exists(DRUPAL_ROOT . '/' . $confdir . '/' . $dir))) {
        $conf = "$confdir/$dir";
        return $conf;
      }
    }
  }
  $conf = "$confdir/default";
  return $conf;
}

Ce que fais ce code c'est tester le Hostname demandé dans la requête pour voir si un dossier contenant les settings (paramètres) et basé sur ce nom existe. Par défaut vous avez les réglages par défaut default dans le dossier <www>/sites/default, vous pouvez ensuite utiliser le fichier <www>/sites/sites.php pour faire une association entre des hostnames et d'autres fichiers de paramètres -- mais cela ne couvrira que les noms que vous avez réèllement, pas les mauvais hostnames --, et vous pouvez aussi avoir des dossiers basés sur le hostname, ou une partie de ce hostname, et contenant un fichier settings.php. Notez que vous ne pouvez pas suspendre cette fonctionnalité multisite, elle est toujours présente, cela changera uniquement avec Drupal 8.

Si le hostname est www.example.com:8080 et le fichier de départ utilisé pour boostraper Drupal est <www>/index.php, Drupal va faire une recherche sur ces fichiers (dans cet ordre):

  • www/sites/8080.www.example.com/settings.php
  • www/sites/www.example.com/settings.php
  • www/sites/example.com/settings.php
  • www/sites/com/settings.php
  • www/sites/www.example.com/settings.php
  • www/sites/default/settings.php

Si ce nom est www.example.com:8080 et le fichier bootstrapant drupal est www/modules/statistics/statistics.php, Drupal va rechercher ces fichiers:

  • www/sites/8080.www.example.com.modules.statistics/settings.php
  • www/sites/www.example.com.modules.statistics/settings.php
  • www/sites/example.com.modules.statistics/settings.php
  • www/sites/com.modules.statistics/settings.php
  • www/sites/8080.www.example.com.modules/settings.php
  • www/sites/www.example.com.modules/settings.php
  • www/sites/example.com.modules/settings.php
  • www/sites/com.modules/settings.php
  • www/sites/8080.www.example.com/settings.php
  • www/sites/www.example.com/settings.php
  • www/sites/example.com/settings.php
  • www/sites/com/settings.php
  • www/sites/default/settings.php

Ouaip, je ne suis pas certain que quelqu'un se serve vraiment de cette fonctionnalité de cette façon, mais c'est ce que fais le code.

Si l'un des fichiers est trouvé avant le fichier par défaut il est utilisé. Vous pouvez modifier les réglages dans ce fichier pour vous connecter sur une autre base de données, ou utiliser un autre base_url ou un autre database_prefix, ou modifier n'importe quelle variable, les trucs de la fonctionnalité multi-sites.

Comme vous pouvez le voir, utiliser le fichier sites/sites.php pour y stocker vos correspondances hostname-vers-dossier est certainement une bonne chose à faire en terme de performances (cette boucle de recherche est alors zappée). Rappelez vous que ces tests d'existence de fichiers sont faits pour chaque requête HTTP reçue par Drupal.

Donc vous pouvez maintenant relire le patch, avant le patch des hostnames très longs pouvaient être utilisés, et vous pouviez utiliser un très grand nombre de sous domaines... et pour chacun de ces sous-domaines vous ajoutez un certain nombre d'emplacement à vérifier pour la recherche d'un fichier de settings...

Arrivé à ce point je décidais de lancer quelques tests malfaisants pour voir comment le code multisite réagirait aux mauvais hostnames. En ne travaillant qu'avec les caractères autorisés par Drupal (lettres alphabétiques, nombres, points, :) nous pouvons au moins jouer avec cette recherche en profondeur de fichiers de settings. Et l'utilisation du raccourci sites.php n'est pas utilisé sur les mauvais hostnames.

Et là c'est le drame... un DOS

Donc, vous pensez peut-être que le bug se situe sur les appels à "file_exists", parce que nous allons en faire quelques milliers, en recherchant notre premier fichier correspondant si nous fabricons un hostname avec plusieurs milliers de sous-domaines. Mais bizarrement ces appels sont habituellement assez rapides.

Le vrai problème est sur "array_slice", effectuer plusieurs milliers d'appels array_slice inversés est très très lent. Les premiers sont assez rapides mais les dernières étapes sont plus longues, et nous allons effectuer plusieurs milliers d'aopérations array_slice.

function test_array_slice($server,$j) {
  print "ARRAY_SLICE array of " . count($server) . " elts => ";
  $time_start = microtime(true);
  array_slice($server,$j);
  $time_end = microtime(true);
  $time = $time_end - $time_start;
  print "time for array_slice to $j : $time (s)\n";
}
/*
ARRAY_SLICE array of 16000 elts => time for array_slice to -10    : 0.0004069805 (s)
ARRAY_SLICE array of 16000 elts => time for array_slice to -100   : 0.0004091262 (s)
ARRAY_SLICE array of 16000 elts => time for array_slice to -500   : 0.0004727840 (s)
ARRAY_SLICE array of 16000 elts => time for array_slice to -1000  : 0.0005030632 (s)
ARRAY_SLICE array of 16000 elts => time for array_slice to -4000  : 0.0010337829 (s)
ARRAY_SLICE array of 16000 elts => time for array_slice to -8000  : 0.0016450881 (s)
ARRAY_SLICE array of 16000 elts => time for array_slice to -12000 : 0.0018289089 (s)
ARRAY_SLICE array of 16000 elts => time for array_slice to -14000 : 0.0025160312 (s)
ARRAY_SLICE array of 16000 elts => time for array_slice to -16000 : 0.0032360553 (s)
*/

0,003s peut sembler rapide. Mais c'est déjà 7,5 fois plus long que les premiers appels à array_slice. J'ai fais un graphique très simple pour visualiser ce ralentissement du temps.

array_slice time graph for 16000 elements

Et chacun de ces temps correspond à uune seule extraction -- comme extraire du vecteur de 16000 éléments jusqu'à l'index -12 000 --, mais la boucle va extraire toutes les valeurs. Une à chaque tour de boucle.

La boucle empile chacun de ces temps, avec 16 000 éléments le dernier appel à array_slice dure bien 0.003s mais la somme de tous ces appels array_slice est de ... 52 secondes!

Mon premier correctif utilisait un appel "array_walk" pour préparer les noms de dossier à vérifier, au lieu de reconstruire ces noms à chaque tour de boucle, de cette façon:

<?php
/**
 * Apply this function with an array_walk to obtain an array with names that
 * need to be tested.
 */
function prepare_name(&$val, $key, &$current) {
  if (''!==$current) {
    $val = $val.".".$current;
  }
  $current = $val;
}
(...)
$rev_server = array_reverse($server);
$current='';
array_walk($rev_server, 'prepare_name', $current);
$work_server = array_reverse($rev_server);
// --end new
for ($i = count($uri) - 1; $i > 0; $i--) {
  // now the loop is a simple foreach name on first array
  foreach($work_server as $k => $name) {
    $dir = $name . implode('.', array_slice($uri, 0, $i));
  }
}

Et cela corrigeait les problèmes de performance (tant que "file_exists" est rapide sur votre serveur). Mais empêcher les mauvais hostnames comme nous l'avons fais dans le correctif final est une meilleure solution, plus radicale, il n'y a pas de bonnes raisons d'accepter de gérer des hostnames avec plusieurs milliers de sous-domaines.

Est-ce que cela était un vrai DOS? Et bien, sur des serveurs de production réèls, avec beaucoup de mémoire et des CPUs très rapides nous avons testé des exploits qui faisait passer plus de deux minutes dans cette boucle à chaque requête (unique). Donc avec un attaquant faisant tourner plusieurs de ces requêtes en parallèle ou touchant des serveurs un peu moins performants cela devient vraiment problématique.

Protégez votre site web

Les choses qui NE VOUS protègent PAS, ces choses ne protègeront pas un site web Drupal contre une attaque:

  • ne pas avoir de multi-site drupal, le bug se produit au check du mutlisite et vous ne pouvez pas le suspendre sur D5, D6 et D7 (un opt-in pour D8, enfin), vous avez toujours ce "conf_path()" qui tourne, sur chque requête, et vous ne pouvez rien faire avant le chargement des settings.
  • Ne pas avoir drupal sur le virtualhost par défaut, le **absolute-URI trick permet de passer à travers, voyez à la fin.
  • un drupal ancien, ce bug est présent depuis un temps très ancien, sur drupal 5 par exemple.
  • Varnish, Nginx, Apache (et peut-être d'autres): pas de protection par défaut.
  • utiliser le fichier de correspondance entre hostnames et répertoires www/sites.php, car les mauvais hostnames ne sont pas gérés dans ce fichier et vous n'avez pas moyen de rejeter les noms inconnus.

Les choses qui POURRAIENT marcher pour vous protéger (non testé):

  • Utiliser mod_security ou un autre outil de détection de requêtes mal-formées, mais vous devrez vérifier le comportement de ces outils avec les URL absolues (voir dernière partie).

Les choses QUI MARCHENT pour vous protéger (choisissez-en une):

  • Mettre à jour D6 ou D7 à la dernière version OU
  • Appliquer le patch anti-DOS OU
  • Utilisez un un VirtualHost par défaut en catch-all que ne lance pas Drupal et patcher Apache (voir à la fin) OU
  • Ajouter un check de HOST avec mod_rewrite dans Apache:

Le module Apache Mod_rewrite va recevoir le même hostname que PHP dans la variable HTTP_HOST. Si le "Absolute-URI trick" est utilisé mod_rewrite peut détecter les mauvais noms et rejeter la requête. Disons que vous avez un deux DNS valides pour votre site web (ici foo.example.com et bar.example.com), vérifiez alors qu'Apache a bien fais son travail et arrive sur votre site avec lun de ces noms:

# Reject with a 403 any hostname wich is not in our list of supported domains
RewriteCond %{HTTP_HOST} !^foo\.example\.com$
RewriteCond %{HTTP_HOST} !^bar\.example\.com$
RewriteRule .* - [F,L]

Le "absolute-URI trick"?

Comme je le disais plus haut la méthode du VirtualHost par défaut ne vous protège pas, c'est dommage parce que c'est une bonne pratique, vous ajoutez un VirualHost par défaut avec une simple page (comme la page "It Works") et tout individu qui s'amuserait à manipuler l'entête Host terminerait sur cette page.

Mais ceci peut être contourné pour Apache comme pour Nginx en utilisant les URL absolues, le "trick" est décrit dans le lien Practical Host Header attacks paper que j'avais déjà indiqué plus haut.

Le truc est d'utiliser une URL absolue dans la première partie de la requête HTTP. Au lieu d'un requête HTTP/1.1 classique:

GET /page/foo?z=42 HTTP/1.1
Host: www.example.com
(other headers)

Vous faites:

GET http://www.example.com/page/foo?z=42 HTTP/1.1
Host: something.else
(other headers)

et la RFC 2616 indique que cette requête devrait être gérée par le VirtualHost qui gère www.example.com, c'est normal. Et cette RFC dit aussi que l'entête Host devrait être ignorée. Cool. mais autant avec apache que Nginx ceci ne signifie pas que les entêtes Host ne seront pas transmis aux applications finales (ici PHP).

Donc en bout de course Drupal reçoit l'entête Host (ici "something.else"). Je pense que c'est un bug du côté du serveur HTTP. cet entête devrait être écrasé et ressembler à "www.example.com". Ce qui est pire c'est que les mauvais caractères habituels qui sont normalement détectés par Apache dans les entêtes Host ne sont pas vérifiés quand on utilise ce truc avec l'URL absolue. Du côté de Drupal nous avons le netooyage basé sur un regex sur l'entête Host, c'est une très bonne chose (défense en profondeur appliquée). Du côté de drupal cette utilisation d'une URL absolue va toujours générer une 404, l'URl ne correspond pas à des chemins connus du routeur, mais cette 404 n'est pas un problème pour l'attaque DOS qui se produit au chargement des settings et pas sur le routeur.

Si vous pensez qu'il s'agit d'un bug Apache, votez s'il vous plaît pour ce patch Apache sur le bugtracker de httpd, qui écrase le HTTp_HOST avec le domaine de l'URl absolue:

--- server/protocol.c   2014-03-10 14:04:03.000000000 +0100
+++ server/protocol.c.new   2014-06-05 23:41:38.233573966 +0200
@@ -1063,6 +1063,21 @@

     apr_brigade_destroy(tmp_bb);

+    /*
+    * rfc2616: If Request-URI is an absoluteURI, the host is part of the
+    * Request-URI. Any Host header field value in the request MUST be
+    * ignored.
+    * We are currently ignoring it, but the Host headers are still present
+    * and may get use by naive programs as the one used for vhost choice
+    * or like a valid hostname. So enforce the 'ignore' behavior by
+    * overwritting any present Host header.
+    * Note that this is made just before the fixHostname(r) call, so this
+    * Host header entry is still not as safe as the hostname.
+    */
+    if (r->hostname && apr_table_get(r->headers_in, "Host")) {
+        apr_table_set(r->headers_in, "Host", r->hostname);
+    }
+
     /* update what we think the virtual host is based on the headers we've
      * now read. may update status.
      */

En appliquant ce patch le seul moyen d'obtenir un hostname étrange ciblant votre drupal serait d'avoir drupal en VirtualHost par défaut, quelque chose qui peut se corriger facilement.

Il faudrait aussi faire un patch Nginx un jour...

ABONNEZ-VOUS À LA NEWSLETTER !
Voir aussi
Drupalcamp Lannion 2017 : le pouvoir de la communauté Drupalcamp Lannion 2017 : le pouvoir de la communauté 06/11/2017

Compte-rendu du Drupalcamp Lannion 2017, un événement qui a tenu toutes ses promesses.

Développer avec Twig dans Drupal 7 Développer avec Twig dans Drupal 7 25/06/2015

Utiliser Twig dans Drupal 7, c'est possible ! Découvrez comment prendre de l'avance sur Drupal 8, ...

Comment mettre en place un site Drupal "Headless" ? Comment mettre en place un site Drupal "Headless" ? 14/06/2017

Les différents modules et techniques pour mettre en place une couche de services web sur une base ...

Migrer de SPIP à Drupal Migrer de SPIP à Drupal 29/05/2017

Comment imiter le comportement de SPIP et découvrir Drupal pour les amateurs de ce CMS.

Bien débuter avec les transactions SQL Bien débuter avec les transactions SQL 09/12/2015

BEGIN et COMMIT vous connaissez, mais ACID ou LOCK ça vous dit quelque chose ?