Quelles bibliothèques HTTP récupèrent réellement une URL gopher:// ? Une matrice SSRF testée empiriquement

Seuls les clients HTTP basés sur libcurl ouvrent réellement un socket gopher:// socket, et ce simple fait détermine si un server-side request forgery peut être escaladé de « faire récupérer une URL par le serveur » à « envoyer des octets arbitraires à n'importe quel service TCP interne ». Nous avons testé le client HTTP par défaut de dix langages face à un listener gopher actif. Six bibliothèques clientes ont ouvert le socket, et chacune d'entre elles est un simple wrapper autour de libcurl : la ligne de commande curl, le ext-curl, le pycurl de Python, le handler curl de Guzzle, la crate Rust curl, et Faraday avec l'adaptateur typhoeus. Chaque pile HTTP native testée a refusé gopher avant même d'ouvrir un socket : Go net/http, Java HttpURLConnection et HttpClient, Python requests, httpx, aiohttp et urllib3, Node http et fetch, Ruby Net::HTTP, .NET HttpClient, Rust reqwest, et wget. La règle en une phrase : gopher via SSRF est une affaire de libcurl.
ext-curl est exposée, tandis qu'une application PHP utilisant les stream wrappers ne l'est pas ; une application Ruby utilisant Net::HTTP est protégée, mais la même application avec un adaptateur typhoeus ne l'est pas.
Pourquoi gopher est le schéma déterminant pour les SSRF
Le server-side request forgery est généralement décrit comme le fait de tromper une application pour lui faire récupérer une URL choisie par l'attaquant. En soi, cela permet d'atteindre des services HTTP internes, ce qui est fâcheux mais limité. gopher fait sauter cette limite. libcurl traite une URL gopher comme gopher://host:port/<type><selector>, où le premier caractère du chemin correspond au type d'élément gopher et tout ce qui suit est envoyé textuellement comme sélecteur, après décodage de l'URL, suivi de CRLF. Comme les octets sont d'abord décodés, un saut de ligne encodé en pourcentages dans le chemin devient un véritable saut de ligne sur le réseau. C'est là tout le gadget. Cela transforme « récupérer une URL » en « écrire n'importe quelle séquence d'octets sur n'importe quel port TCP accessible par le serveur », ce qui fait de gopher combiné à un Redis ou php-fpm interne la voie classique menant d'une SSRF aveugle à l'exécution de code. C'est l'un des aspects les plus critiques d'une surface d'attaque externe, et cela dépend entièrement du choix du client HTTP.
Le test : un vrai socket, pas une simple affirmation
La plupart des références affirment que « de nombreuses bibliothèques supportent gopher » et passent directement aux payloads. Nous voulions observer le succès ou l'échec plutôt que de nous contenter d'affirmations, la méthode est donc volontairement basique. Pour chaque client, nous démarrons un listener TCP à usage unique sur localhost, demandons au client de récupérer gopher://127.0.0.1:PORT/_<payload>, et enregistrons si le listener reçoit une connexion ainsi que les octets reçus. Un client qui refuse le schéma ne se connecte jamais, le listener ne rapporte donc rien. Un client qui ouvre le socket nous transmet exactement les octets envoyés sur le réseau. Il s'agit d'une expérience active menée sur nos propres conteneurs jetables et un listener localhost, sans solliciter aucun hôte tiers. Voici l'essentiel des résultats face à curl, pycurl et PHP ext-curl, sur trois versions différentes de libcurl :
curl gopher:// (libcurl 7.81.0) -> CONNECTED b'AAAA\r\n'
curl gophers:// (libcurl 7.81.0) -> CONNECTED (TLS ClientHello on the wire)
curl dict:// (libcurl 7.81.0) -> CONNECTED b'CLIENT libcurl 7.81.0\r\nAAAA\r\nQUIT\r\n'
pycurl gopher:// (libcurl 8.21.0) -> CONNECTED b'PYCURL\r\nLINE2\r\n'
php ext-curl gopher:// (libcurl 8.14.1) -> CONNECTED b'PHPCURL\r\nLINE2\r\n'
wget gopher:// (wget 1.21.2) -> NO-CONNECT
python requests -> NO-CONNECT (InvalidSchema: no connection adapters)
python urllib -> NO-CONNECT (unknown url type: gopher)
node fetch / http.get -> NO-CONNECT (scheme rejected before connect)
Deux constats ressortent directement de cette sortie. Premièrement, les clients basés sur libcurl ouvrent le socket sur toutes les versions testées, d'une ancienne 7.81.0 à l'actuelle 8.21.0, ce n'est donc pas une particularité d'un build spécifique. Deuxièmement, le gadget CRLF est bien réel et exact. Les lignes pycurl et PHP ont été exécutées avec le sélecteur d'URL _PYCURL%0d%0aLINE2 et _PHPCURL%0d%0aLINE2, et les octets reçus contenaient un véritable retour chariot suivi d'un saut de ligne entre les deux tokens. Nous l'avons également confirmé de manière isolée : l'URL gopher://host:port/_TESTPAYLOAD%0d%0aSECONDLINE arrive sur le socket sous la forme b'TESTPAYLOAD\r\nSECONDLINE\r\n'. C'est un message protocolaire de deux lignes injecté directement dans un service TCP au moyen d'une simple URL.
La matrice
Les lignes marquées live ont été retestées face à un socket pour cet article. Les autres ont été vérifiées à partir du code source, des registres de protocoles et de la documentation de gestion des schémas de chaque client. La tendance est constante : la colonne « ouvre un socket » correspond exactement à l'ensemble des bindings libcurl, plus une exception modulaire en Perl.
| Client / bibliothèque | Langage | Socket gopher:// | Raison |
|---|---|---|---|
| CLI curl / libcurl (live) | C | OUI | gopher, gophers, dict, ftp, file tous compilés ; la allowlist de transfert par défaut les active |
| wget / wget2 (live) | C | non | seuls http/https/ftp intégrés ; wget2 est https-only |
PHP ext-curl (curl_exec) (live) | PHP | OUI | simple binding libcurl ; le sink classique de SSRF vers Redis/FastCGI |
PHP streams (file_get_contents) | PHP | non | uniquement les wrappers enregistrés ; aucun wrapper gopher/dict (file et ftp oui) |
| Guzzle (handler curl, par défaut) | PHP | OUI | délègue à ext-curl ; le handler stream ne le fait pas |
| pycurl (live) | Python | OUI | binding direct libcurl ; même piège que PHP curl |
| requests (live) | Python | non | InvalidSchema: no connection adapters |
| httpx / aiohttp / urllib3 | Python | non | UnsupportedProtocol / NonHttpUrlClientError / URLSchemeUnknown |
| urllib (stdlib) (live) | Python | non | aucun opener gopher dans Python 3 (file et ftp oui) |
| net/http | Go | non | unsupported protocol scheme "gopher" ; uniquement des round-trippers http/https |
| HttpURLConnection / HttpClient | Java | non | les handlers sont http/https/ftp/file/jar/mailto ; unknown protocol: gopher |
| Apache HttpClient 4/5 / OkHttp | Java | non | le registre de schémas est limité à http/https par conception |
| Net::HTTP | Ruby | non | spécifique à http/https ; OpenURI transmet gopher à File.open (lecture locale, pas un pivot TCP) |
| Faraday | Ruby | non* | sur Net::HTTP ; l'adaptateur typhoeus (libcurl) est l'exception qui atteint gopher |
| http / https / undici / fetch (live) | Node.js | non | spécifique au protocole ; WHATWG fetch gère uniquement http/https/file/data/blob |
| HttpClient / HttpWebRequest | .NET | non | http/https uniquement ; l'ancien GopherWebRequest a été supprimé dans .NET Core |
| reqwest / ureq | Rust | non | piles hyper et pure-Rust, http/https uniquement |
| crate curl | Rust | OUI | binding direct libcurl, même mise en garde que pour tous les autres bindings |
| LWP::UserAgent | Perl | OUI* | seulement si LWP::Protocol::gopher est installé ; LWP est modulaire par schéma |
Comptez les lignes OUI et la conclusion s'impose d'elle-même. Six bibliothèques clientes ouvrent un socket gopher, et toutes les six reposent sur libcurl : curl, PHP ext-curl, pycurl, Guzzle-curl, la crate curl en Rust et Faraday avec typhoeus. Le septième et unique résultat positif hors libcurl est LWP::UserAgent, et cela nécessite encore qu'un opérateur ait installé le plugin optionnel LWP::Protocol::gopher plugin, il est donc désactivé par défaut. Tout le reste, chaque pile HTTP native majeure en Go, Java, Python, Node, Ruby, .NET et Rust, rejette le schéma avant même d'ouvrir une connexion. Aucun payload ingénieux ne peut changer cela, car le refus se produit au niveau du parseur d'URL ou du registre d'adaptateurs, en amont de tout socket.
Première nuance : la redirection vers gopher est désactivée par défaut
« L'application utilise curl, elle est donc exploitable » n'est qu'à moitié vrai sur un build moderne, et l'autre moitié est précisément là où l'on perd du temps en mission. Il existe deux allowlists distinctes dans libcurl. La allowlist de transfert régit les URL transmises directement, et elle active toujours gopher. La allowlist de redirection régit les schémas vers lesquels un en-tête Location: header may switch to, et depuis libcurl 7.65.2 en mai 2019, elle est restreinte à http, https, ftp et ftps uniquement. Ainsi, une open-redirect pointant vers gopher:// est refusée par défaut. Nous l'avons constaté sur libcurl 7.81.0 :
# default: an HTTP 302 to gopher:// is blocked
$ curl -sL http://listener/ -> Location: gopher://127.0.0.1:PORT/_REDIRPWN
* Protocol "gopher" not supported or disabled in libcurl
listener: (never reached)
# widened: the same redirect now opens the gopher socket
$ curl -sL --proto-redir all http://listener/
listener: GOPHER SOCKET OPENED b'REDIRPWN\r\n'
La redirection n'atteint gopher que si l'application a elle-même ré-élargi les protocoles de redirection, en configurant CURLOPT_REDIR_PROTOCOLS_STR pour inclure gopher ou en passant à la CLI --proto-redir all. Cette mauvaise configuration n'est pas hypothétique. CVE-2026-33752 relève exactement de cette classe de vulnérabilité : la bibliothèque curl_cffi suivait les redirections vers des protocoles arbitraires faute de restreindre la allowlist de redirection, transformant une simple SSRF http en un pivot gopher complet. La conclusion pratique pour un attaquant est que, sur une cible libcurl moderne, la SSRF doit vous permettre d'injecter le schéma complet, une URL directe gopher://, plutôt que de compter sur une open-redirect. La conclusion pratique pour un défenseur est qu'élargir les protocoles de redirection revient à jouer avec une arme chargée, et que le comportement par défaut qui la neutralise est en place depuis 2019.
Deuxième nuance : le payload repose sur CRLF, et c'est trivial
La raison pour laquelle gopher importe plus que dict:// ou file:// réside dans le contrôle au niveau de l'octet. libcurl transmet la partie sélecteur de l'URL sous forme d'octets bruts après décodage de l'URL, ce qui permet à l'attaquant de construire le trafic réseau directement dans le chemin de l'URL. Un %0d%0a encodé en pourcentages devient un véritable retour chariot suivi d'un saut de ligne, et tout protocole orienté ligne interprétera le résultat comme des commandes distinctes :
- Redis. Une seule URL gopher peut envoyer
SET, puisCONFIG SET diretCONFIG SET dbfilename, puisSAVE, écrivant ainsi un webshell, une tâche cron ou un fichier authorized_keys sur le disque. C'est la chaîne que produisent les générateurs de payloads publics. - FastCGI (php-fpm sur le port 9000). Un enregistrement FastCGI forgé avec
PHP_VALUEouSCRIPT_FILENAMEpermet d'obtenir l'exécution de code face à un php-fpm écoutant uniquement sur localhost. - memcached, SMTP, Postgres, Zabbix. Tout protocole orienté ligne ou préfixé par la longueur accessible depuis l'origine de la SSRF est une cible potentielle, car vous contrôlez totalement les octets.
Rien de tout cela ne nécessite de bogue mémoire ou de condition de course. Il s'agit simplement d'une URL qui se décode en un échange protocolaire, ce qui explique pourquoi un client compatible gopher transforme une SSRF modeste en une faille critique. Cette même logique, un agent ou un service qui récupère une URL que vous contrôlez, est ce qui rend les clients automatisés se connectant à des endpoints arbitraires particulièrement intéressants à auditer pour ce même type de pivot.
Comment y remédier
La défense ne consiste pas à « cesser d'utiliser curl ». libcurl convient parfaitement lorsque son périmètre de protocoles est verrouillé. L'erreur consiste à se reposer sur les valeurs par défaut et à laisser les entrées utilisateur choisir le schéma.
- Verrouillez explicitement la allowlist des schémas. Pour libcurl, ext-curl et pycurl, définissez
CURLOPT_PROTOCOLS_STR = "http,https"et n'élargissez jamaisCURLOPT_REDIR_PROTOCOLS_STRau-delà dehttp,https. En CLI :--proto -all,http,https --proto-redir -all,http,https. - Ne laissez jamais les entrées utilisateur contrôler le schéma de l'URL. Acceptez un hôte et un chemin, définissez en dur
https://, et rejetez tout le reste. La plupart des chaînes SSRF vers gopher s'arrêtent ici. - Traitez une redirection vers un schéma non-http comme une erreur bloquante, et revalidez le schéma résolu après chaque saut de redirection, côté serveur, pas uniquement sur l'URL initiale.
- Privilégiez un client strictement http pour les requêtes qui ne nécessitent pas plusieurs protocoles. Go
net/http, JavaHttpClientet Pythonrequestsouhttpxvous immunisent gratuitement contre gopher, dict et file. - Supprimez les CRLF bruts et encodés (
%0d,%0a) de tout chemin ou query d'URL sortante influencé par l'utilisateur. - Filtrez les flux sortants au niveau réseau afin que même un pivot réussi ne puisse atteindre un service interne : bloquez 6379, 11211, 9000, 25 et 5432 en sortie applicative, et liez les services internes à localhost avec authentification.
- Auditez les utilisateurs indirects de libcurl que votre pile dissimule : le handler curl de Guzzle, Faraday sur typhoeus, la crate Rust curl et Perl LWP avec le plugin gopher présentent la même exposition que PHP curl, et ils sont faciles à oublier dans un graphe de dépendances.
Méthode et limites, pour des résultats fiables
Le jeu de données est le nôtre, établi par observation directe des sockets, et il est reproductible.
- Notre méthode de test. Les lignes live ont été exécutées dans des conteneurs jetables (php:8.3-cli, python:3.12-slim) et sur un hôte Linux standard, face à un listener TCP localhost
python3qui rapporte la connexion et les premiers octets reçus. Trois versions de libcurl ont été testées : 7.81.0, 8.14.1 et 8.21.0. - Ce que signifie « non ». Un « non » désigne un client qui n'ouvre jamais le socket, parce qu'il rejette le schéma au niveau du parseur d'URL, du registre d'adaptateurs ou du gestionnaire de protocole. C'est plus strict qu'une connexion ouverte puis refermée, car les octets de l'attaquant ne quittent jamais le processus.
- Les lignes avec astérisque. Faraday et Perl LWP dépendent de l'adaptateur configuré ou du plugin installé, c'est pourquoi ils portent un astérisque : la configuration par défaut est sûre, mais une configuration spécifique ne l'est pas.
- Périmètre. Cela concerne l'ouverture ou non d'un socket pour gopher, ce qui conditionne le pivot SSRF. Cela ne préjuge en rien des SSRF basées sur http, que tous les clients ici réalisent, ni de l'exposition à
file://, que le composant JavaURLet quelques autres comportent encore.
Aucun hôte tiers n'a été contacté à aucun moment. Chaque connexion dans cette recherche visait un listener démarré sur loopback, ce qui fait toute la différence entre mesurer un comportement et sonder le système d'un tiers.
Foire aux questions
La bibliothèque Python requests prend-elle en charge le protocole gopher ?
Non. Lors d'un test live, requests lève InvalidSchema: No connection adapters were found pour une URL gopher et n'ouvre jamais de socket. Il en va de même pour httpx, aiohttp, urllib3 et le module standard urllib. La seule exception en Python est pycurl, un simple binding autour de libcurl, qui ouvre effectivement un socket gopher. Si votre code Python utilise requests ou httpx, un payload SSRF gopher ne peut pas atteindre un service interne par ce biais.
Pourquoi curl supporte-t-il gopher mais pas wget ?
Des ensembles de protocoles différents. libcurl compile par défaut gopher, gophers, dict, ftp et file, et sa allowlist de transfert les active. wget a été conçu uniquement pour http, https et ftp, et wget2 est https-only. Il s'agit d'une différence de conception et de compilation, pas d'un paramètre modifiable à l'exécution, c'est pourquoi curl est le pivot SSRF classique et wget ne l'est pas.
Quels protocoles libcurl supporte-t-il pour les SSRF ?
Sur un build standard, libcurl peut ouvrir gopher, gophers, dict, ftp, ftps, file, tftp, ldap et les protocoles de messagerie, en plus de http et https. Pour les SSRF, les plus importants sont gopher et dict, car tous deux envoient des octets contrôlés par l'attaquant sur un socket brut. gopher est le plus puissant, car le sélecteur après le caractère de type d'élément est transmis textuellement, de sorte qu'un CRLF encodé devient un véritable saut de ligne.
Comment le protocole gopher escalade-t-il une SSRF en RCE ?
Une SSRF classique amène le serveur à récupérer une URL http. gopher transforme cela en un envoi d'octets arbitraires vers n'importe quel port TCP accessible, car libcurl décode le sélecteur de l'URL et l'écrit brut. Une simple URL peut envoyer un SET Redis ainsi que CONFIG SET dir et SAVE pour déposer un webshell, ou un enregistrement FastCGI vers php-fpm. La SSRF est le vecteur de livraison, gopher est le payload, et un service interne orienté ligne est la cible.
HttpURLConnection en Java supporte-t-il gopher ?
Non. java.net.URL et HttpURLConnection enregistrent des handlers uniquement pour http, https, ftp, file, jar et mailto, et une URL gopher lève unknown protocol: gopher. Les bibliothèques modernes comme HttpClient, Apache HttpClient et OkHttp rejettent par conception tout schéma non-http. Java est effectivement immunisé contre les SSRF gopher, bien que java.net.URL supporte toujours file://, ce qui constitue un problème distinct.
Pourquoi libcurl ne suit-il pas une redirection vers une URL gopher:// ?
Depuis libcurl 7.65.2 en mai 2019, la allowlist de redirection par défaut est restreinte à http, https, ftp et ftps uniquement, de sorte qu'une 302 vers gopher:// est refusée et aucun socket ne s'ouvre. La soumission directe d'une URL gopher complète fonctionne toujours, car la allowlist de transfert est distincte. Une application peut rouvrir la voie de redirection en élargissant CURLOPT_REDIR_PROTOCOLS_STR, ce qui constitue la classe de bogue derrière CVE-2026-33752 dans curl_cffi. Sur une cible moderne, injectez le schéma gopher complet plutôt que de compter sur une open-redirect.
Comment envoyer un CRLF à Redis via un payload SSRF gopher ?
Encodez les sauts de ligne. libcurl décode le sélecteur de l'URL, donc gopher://127.0.0.1:6379/_SET%20k%20v%0d%0aSAVE%0d%0aQUIT envoie SET k v, un vrai CRLF, SAVE, un vrai CRLF, puis QUIT. Lors de notre test, _TESTPAYLOAD%0d%0aSECONDLINE est arrivé sous la forme des octets TESTPAYLOAD\r\nSECONDLINE\r\n. Redis utilise un protocole délimité par des sauts de ligne, il s'agit donc d'une séquence de commandes valide, ce qui explique pourquoi gopher associé à Redis constitue la chaîne SSRF vers RCE d'école.
Lectures complémentaires
- Qu'est-ce qu'une surface d'attaque externe ? La place d'une SSRF dans la cartographie établie par un attaquant de ce que votre organisation expose.
- Sécurité des serveurs MCP distants : la moitié du registre appartient à un tiers. Le même schéma « un client récupère une URL que vous influencez », au niveau des agents machines.
- Check vs scan vs pentest. Pourquoi une vulnérabilité enchaînée comme SSRF vers gopher vers RCE est mise en évidence par un vrai test et ignorée par un scanner.
Un pivot gopher fonctionnerait-il contre vous ?
Notre check à 100 $ cartographie votre surface d'attaque externe réelle comme le ferait un attaquant, sur un périmètre dont vous avez vérifié la propriété et autorisé par écrit, avec un opérateur senior lors de la restitution. Déterminer si un Redis ou php-fpm interne est à une seule SSRF de l'exécution de code est exactement le type de chaîne que nous recherchons.
Réserver un check à 100 $