Welche HTTP-Bibliotheken rufen tatsächlich eine gopher://-URL ab? Eine empirisch getestete SSRF-Matrix

Nur HTTP-Clients, die auf libcurl basieren, öffnen tatsächlich einen gopher://-Socket, und genau dieser Umstand entscheidet darüber, ob eine Server-Side Request Forgery von "der Server ruft eine URL ab" zu "beliebige Bytes an jeden internen TCP-Dienst senden" eskaliert werden kann. Wir haben die Standard-HTTP-Clients von zehn Programmiersprachen gegen einen aktiven gopher-Listener getestet. Sechs Client-Bibliotheken öffneten den Socket, und jede einzelne davon ist ein schlanker Wrapper um libcurl: die curl-Kommandozeile, PHPs ext-curl, Pythons pycurl, Guzzles curl-Handler, das Rust-Crate curl und Faraday mit dem typhoeus-Adapter. Jeder native HTTP-Stack, den wir ausprobiert haben, verweigerte gopher, noch bevor ein Socket geöffnet wurde: Go net/http, Java HttpURLConnection und HttpClient, Python requests, httpx, aiohttp und urllib3, Node http und fetch, Ruby Net::HTTP, .NET HttpClient, Rust reqwest sowie wget. Die Faustregel in einem Satz: gopher-over-SSRF ist reine libcurl-Sache.
ext-curl ist verwundbar, eine PHP-App mit Stream-Wrappern nicht. Eine Ruby-App mit Net::HTTP ist sicher, dieselbe App mit einem typhoeus-Adapter nicht.
Warum gopher das entscheidende Schema für SSRF ist
Server-Side Request Forgery wird üblicherweise so beschrieben, dass eine Anwendung dazu verleitet wird, eine vom Angreifer gewählte URL abzurufen. Für sich genommen erreicht das interne HTTP-Dienste, was problematisch, aber begrenzt ist. gopher hebt diese Begrenzung auf. libcurl behandelt eine gopher-URL als gopher://host:port/<type><selector>, wobei das erste Pfadzeichen der gopher-Item-Typ ist und alles dahinter nach dem URL-Decoding unverändert als Selector gesendet wird, gefolgt von CRLF. Da die Bytes zuerst URL-dekodiert werden, wird ein prozentkodierter Zeilenumbruch im Pfad zu einem echten Zeilenumbruch auf der Leitung. Das ist das gesamte Gadget. Es verwandelt "eine URL abrufen" in "eine beliebige Bytefolge an jeden TCP-Port senden, den der Server erreichen kann". Genau deshalb ist gopher in Kombination mit einem internen Redis oder php-fpm der klassische Pfad von einer Blind-SSRF zur Codeausführung. Es ist eine der schärfsten Kanten einer externen Angriffsfläche, und sie verbirgt sich vollständig in der Wahl des HTTP-Clients.
Der Test: ein echter Socket, keine bloße Behauptung
Die meisten Quellen behaupten pauschal, dass "viele Bibliotheken gopher unterstützen", und gehen direkt zu Payloads über. Wir wollten das Bestehen oder Scheitern beobachten und nicht nur annehmen. Die Methode ist daher bewusst simpel gehalten. Für jeden Client starten wir einen One-Shot-TCP-Listener auf localhost, weisen den Client an, gopher://127.0.0.1:PORT/_<payload> abzurufen, und protokollieren, ob der Listener jemals eine Verbindung akzeptiert und welche Bytes ankommen. Ein Client, der das Schema verweigert, baut nie eine Verbindung auf, sodass der Listener nichts meldet. Ein Client, der den Socket öffnet, liefert uns exakt die übertragenen Bytes. Dies ist ein aktives Experiment in unseren eigenen Wegwerf-Containern und einem Localhost-Listener, ohne externe Hosts zu berühren. Hier ist der Kern des Tests gegen curl, pycurl und PHP ext-curl, über drei verschiedene libcurl-Versionen hinweg:
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)
Aus dieser Ausgabe lassen sich zwei Dinge direkt ablesen. Erstens öffnen die libcurl-Clients den Socket in jeder getesteten Version, von einem älteren 7.81.0 bis zum aktuellen 8.21.0. Es handelt sich also nicht um eine Eigenheit eines einzelnen Builds. Zweitens ist das CRLF-Gadget real und präzise. Die pycurl- und PHP-Zeilen wurden mit dem URL-Selector _PYCURL%0d%0aLINE2 und _PHPCURL%0d%0aLINE2 abgerufen, und die empfangenen Bytes enthielten ein echtes Carriage Return Line Feed zwischen den beiden Tokens. Wir haben dies auch isoliert bestätigt: Die URL gopher://host:port/_TESTPAYLOAD%0d%0aSECONDLINE landet auf dem Socket als b'TESTPAYLOAD\r\nSECONDLINE\r\n'. Das ist eine zweizeilige Protokollnachricht, die allein über eine URL direkt in einen TCP-Dienst geschrieben wird.
Die Matrix
Zeilen mit der Kennzeichnung live wurden für diesen Beitrag erneut an einem Socket getestet. Der Rest ist anhand von Quellcode, Protokoll-Registries und dem dokumentierten Schema-Handling des jeweiligen Clients verifiziert. Das Muster ist eindeutig: Die Spalte "öffnet einen Socket" umfasst exakt die Menge der libcurl-Bindings plus einen modularen Ausreißer in Perl.
| Client / Bibliothek | Sprache | gopher://-Socket | Grund |
|---|---|---|---|
| curl CLI / libcurl (live) | C | JA | gopher, gophers, dict, ftp, file standardmäßig einkompiliert. Standard-Transfer-Allowlist aktiviert sie |
| wget / wget2 (live) | C | nein | nur http/https/ftp integriert. wget2 unterstützt ausschließlich https |
PHP ext-curl (curl_exec) (live) | PHP | JA | schlankes libcurl-Binding. die klassische SSRF-zu-Redis/FastCGI-Senke |
PHP-Streams (file_get_contents) | PHP | nein | nur registrierte Wrapper. kein gopher/dict-Wrapper (file und ftp vorhanden) |
| Guzzle (curl-Handler, Standard) | PHP | JA | delegiert an ext-curl. Stream-Handler tut dies nicht |
| pycurl (live) | Python | JA | direktes libcurl-Binding. dieselbe Falle wie PHP curl |
| requests (live) | Python | nein | InvalidSchema: no connection adapters |
| httpx / aiohttp / urllib3 | Python | nein | UnsupportedProtocol / NonHttpUrlClientError / URLSchemeUnknown |
| urllib (Standardbibliothek) (live) | Python | nein | kein gopher-Opener in Python 3 (file und ftp vorhanden) |
| net/http | Go | nein | unsupported protocol scheme "gopher", nur http/https-Round-Tripper |
| HttpURLConnection / HttpClient | Java | nein | Handler nur für http/https/ftp/file/jar/mailto. unknown protocol: gopher |
| Apache HttpClient 4/5 / OkHttp | Java | nein | Schema-Registry ist konstruktionsbedingt ausschließlich für http/https ausgelegt |
| Net::HTTP | Ruby | nein | spezifisch für http/https. OpenURI leitet gopher an File.open weiter (lokales Lesen, kein TCP-Pivot) |
| Faraday | Ruby | nein* | auf Net::HTTP. Der typhoeus-(libcurl)-Adapter ist die Ausnahme, die gopher erreicht |
| http / https / undici / fetch (live) | Node.js | nein | protokollspezifisch. WHATWG-fetch unterstützt nur http/https/file/data/blob |
| HttpClient / HttpWebRequest | .NET | nein | nur http/https. Der veraltete GopherWebRequest wurde in .NET Core entfernt |
| reqwest / ureq | Rust | nein | hyper- und Pure-Rust-Stacks, nur http/https |
| curl-Crate | Rust | JA | direktes libcurl-Binding, dieselbe Einschränkung wie bei jedem anderen Binding |
| LWP::UserAgent | Perl | JA* | nur wenn LWP::Protocol::gopher installiert ist. LWP ist pro Schema modular aufgebaut |
Zählt man die JA-Zeilen, ist das Ergebnis unübersehbar. Sechs Client-Bibliotheken öffnen einen gopher-Socket, und alle sechs basieren auf libcurl: curl, PHP ext-curl, pycurl, Guzzle-curl, das Rust-curl-Crate und Faraday-auf-typhoeus. Das siebte und einzige Nicht-curl-Positivergebnis ist Perls LWP::UserAgent, und selbst dafür muss ein Administrator das optionale Plugin LWP::Protocol::gopher installiert haben, sodass es standardmäßig deaktiviert ist. Alles andere, jeder gängige native HTTP-Stack in Go, Java, Python, Node, Ruby, .NET und Rust, weist das Schema ab, bevor eine Verbindung geöffnet wird. Es gibt kein cleveres Payload, das daran etwas ändert, da die Verweigerung im URL-Parser oder in der Adapter-Registry vor jedem Socket-Aufbau stattfindet.
Nuance eins: Redirects zu gopher sind standardmäßig deaktiviert
"Die Anwendung nutzt curl, also ist sie ausnutzbar" stimmt auf modernen Systemen nur zur Hälfte, und an der fehlenden Hälfte wird oft wertvolle Zeit im Assessment verschwendet. In libcurl gibt es zwei getrennte Allowlists. Die Transfer-Allowlist regelt URLs, die direkt übergeben werden, und sie lässt gopher weiterhin zu. Die Redirect-Allowlist regelt, zu welchen Schemas ein Location:-Header weiterleiten darf, und seit libcurl 7.65.2 im Mai 2019 ist sie auf http, https, ftp und ftps beschränkt. Ein Open-Redirect auf gopher:// wird standardmäßig abgewiesen. Wir haben das unter libcurl 7.81.0 überprüft:
# 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'
Der Redirect erreicht gopher nur dann, wenn die Anwendung die erlaubten Redirect-Protokolle selbst wieder erweitert hat, indem sie CURLOPT_REDIR_PROTOCOLS_STR so setzt, dass gopher enthalten ist, oder der CLI --proto-redir all übergibt. Diese Fehlkonfiguration ist keineswegs hypothetisch. CVE-2026-33752 ist genau diese Art von Schwachstelle: Die curl_cffi-Bibliothek folgte Redirects in beliebige Protokolle, weil sie die Redirect-Allowlist nicht einschränkte, wodurch aus einer einfachen http-SSRF wieder ein vollständiger gopher-Pivot wurde. Für Angreifer bedeutet das in der Praxis, dass bei einem aktuellen libcurl-Ziel die SSRF die Platzierung des gesamten Schemas erlauben muss, also eine direkte gopher://-URL, anstatt sich auf einen Open-Redirect zu verlassen. Für Verteidiger bedeutet das, dass das Erweitern von Redirect-Protokollen ein enormes Risiko birgt und die Standardeinstellung, die dies verhindert, seit 2019 korrekt greift.
Nuance zwei: Das Payload ist CRLF und denkbar einfach
Der Grund, warum gopher relevanter ist als dict:// oder file://, liegt in der Kontrolle auf Byte-Ebene. libcurl sendet den Selector-Teil der URL nach dem URL-Decoding als Raw-Bytes, sodass der Angreifer den Netzwerkverkehr direkt im URL-Pfad zusammenstellt. Ein prozentkodiertes %0d%0a wird zu einem echten Carriage Return Line Feed, und jedes zeilenbasierte Protokoll parst das Ergebnis als separate Befehle:
- Redis. Eine einzelne gopher-URL kann
SET, dannCONFIG SET dirundCONFIG SET dbfilename, gefolgt vonSAVEsenden und so eine Webshell, einen Cron-Eintrag oder eine authorized_keys-Datei auf die Festplatte schreiben. Das ist genau die Kette, die gängige Payload-Generatoren erzeugen. - FastCGI (php-fpm auf Port 9000). Ein präparierter FastCGI-Record mit
PHP_VALUEoderSCRIPT_FILENAMEführt zur Codeausführung auf einem php-fpm, der nur an localhost gebunden ist. - memcached, SMTP, Postgres, Zabbix. Jedes zeilenbasierte oder über Längenangaben gesteuerte Protokoll, das vom Ursprung der SSRF aus erreichbar ist, kommt infrage, da Sie die Bytes vollständig kontrollieren.
Nichts davon erfordert einen Speicherfehler oder eine Race Condition. Es handelt sich um eine URL, die sich in eine Protokollkommunikation dekodiert. Deshalb verwandelt ein gopher-fähiger Client eine einfache SSRF in ein kritisches Problem. Dasselbe Prinzip, dass ein Agent oder Dienst eine von Ihnen beeinflusste URL abruft, macht auch maschinelle Clients, die sich mit beliebigen Endpunkten verbinden, zu lohnenden Prüfzielen für dieselbe Pivot-Klasse.
Minderungsmaßnahmen
Die Lösung lautet nicht "curl verbieten". libcurl ist unbedenklich, wenn sein Protokollumfang fest eingeschränkt wird. Das eigentliche Problem besteht darin, sich auf Standardwerte zu verlassen und Benutzereingaben das Schema wählen zu lassen.
- Schema-Allowlist explizit festlegen. Setzen Sie für libcurl, ext-curl und pycurl
CURLOPT_PROTOCOLS_STR = "http,https"und erweitern SieCURLOPT_REDIR_PROTOCOLS_STRniemals überhttp,httpshinaus. Auf der CLI:--proto -all,http,https --proto-redir -all,http,https. - Niemals Benutzereingaben das URL-Schema steuern lassen. Akzeptieren Sie Host und Pfad, setzen Sie
https://fest im Code und weisen Sie alles andere ab. Die meisten SSRF-zu-gopher-Ketten scheitern bereits hier. - Einen Redirect in ein Nicht-http-Schema als harten Fehler behandeln, und das aufgelöste Schema nach jedem Redirect-Hop serverseitig neu validieren, nicht nur bei der ursprünglichen Eingabe-URL.
- Einen reinen http-Client bevorzugen für Abrufe, die keine weiteren Protokolle benötigen. Go
net/http, JavaHttpClientsowie Pythonrequestsoderhttpxbieten standardmäßig Immunität gegen gopher, dict und file. - Rohe und kodierte CRLF-Zeichen entfernen (
%0d,%0a) aus allen benutzerbeeinflussten ausgehenden URL-Pfaden oder Query-Strings. - Egress-Filterung auf Netzwerkebene durchsetzen, damit selbst ein erfolgreicher Pivot keinen internen Dienst erreichen kann: Blockieren Sie 6379, 11211, 9000, 25 und 5432 für ausgehenden Anwendungsverkehr und binden Sie interne Dienste mit Authentifizierung an localhost.
- Indirekte libcurl-Nutzer im Stack auditieren: Guzzles curl-Handler, Faraday auf typhoeus, das Rust-curl-Crate und Perl LWP mit dem gopher-Plugin weisen dasselbe Risiko auf wie PHP curl und werden im Abhängigkeitsbaum leicht übersehen.
Methodik und Grenzen zur Nachvollziehbarkeit der Daten
Der Datensatz stammt aus unseren eigenen Messungen an realen Sockets und ist reproduzierbar.
- So haben wir getestet. Die Live-Zeilen liefen in Wegwerf-Containern (php:8.3-cli, python:3.12-slim) und auf einem Standard-Linux-Host gegen einen
python3-TCP-Listener auf localhost, der den Verbindungsaufbau und die ersten empfangenen Bytes protokolliert. Drei libcurl-Versionen wurden getestet: 7.81.0, 8.14.1 und 8.21.0. - Was "nein" bedeutet. Ein "nein" bedeutet, dass der Client den Socket niemals öffnet, weil er das Schema im URL-Parser, in der Adapter-Registry oder im Protokoll-Handler abweist. Das ist robuster als eine Verbindung, die erst geöffnet und dann geschlossen wird, da die Bytes des Angreifers den Prozess zu keinem Zeitpunkt verlassen.
- Die Zeilen mit Sternchen. Faraday und Perl LWP hängen vom konfigurierten Adapter oder installierten Plugin ab und sind daher mit einem Sternchen versehen: Die Standardkonfiguration ist sicher, eine spezifische Anpassung nicht.
- Umfang. Hier geht es ausschließlich darum, ob ein Socket für gopher geöffnet wird, was über den SSRF-Pivot entscheidet. Dies trifft keine Aussage über http-basierte SSRF, die jeder hier gelistete Client ausführen kann, noch über die Gefährdung durch
file://, die JavasURLund einige andere weiterhin aufweisen.
Zu keinem Zeitpunkt wurde ein externer Host kontaktiert. Jede Verbindung in dieser Untersuchung erfolgte zu einem Listener, den wir auf dem Loopback-Interface gestartet haben. Genau das unterscheidet eine Verhaltensmessung vom Scannen fremder Systeme.
Häufig gestellte Fragen
Unterstützt die Python-Bibliothek requests das gopher-Protokoll?
Nein. Im Live-Test wirft requests bei einer gopher-URL InvalidSchema: No connection adapters were found und öffnet zu keinem Zeitpunkt einen Socket. Dasselbe gilt für httpx, aiohttp, urllib3 sowie die Standardbibliothek urllib. Die einzige Ausnahme in Python ist pycurl, ein schlankes Binding um libcurl, das tatsächlich einen gopher-Socket öffnet. Wenn Ihr Python-Code requests oder httpx verwendet, kann ein gopher-SSRF-Payload darüber keinen internen Dienst erreichen.
Warum unterstützt curl gopher, wget jedoch nicht?
Unterschiedlicher Protokollumfang. libcurl kompiliert standardmäßig gopher, gophers, dict, ftp und file ein, und seine Transfer-Allowlist aktiviert diese Protokolle. wget wurde ausschließlich für http, https und ftp entwickelt, und wget2 unterstützt nur https. Dies ist ein Entwurfs- und Build-Unterschied, kein Schalter zur Laufzeit. Deshalb ist curl der klassische SSRF-Pivot und wget nicht.
Welche Protokolle unterstützt libcurl für SSRF?
In einem Standard-Build kann libcurl neben http und https auch gopher, gophers, dict, ftp, ftps, file, tftp, ldap sowie die Mail-Protokolle öffnen. Für SSRF sind gopher und dict besonders relevant, da beide es ermöglichen, vom Angreifer kontrollierte Bytes auf einen Raw-Socket zu schreiben. gopher ist am wirkungsvollsten, da der Selector nach dem Item-Typ-Zeichen unverändert übertragen wird und ein kodiertes CRLF somit zu einem echten Zeilenumbruch wird.
Wie eskaliert das gopher-Protokoll eine SSRF zu einer RCE?
Eine gewöhnliche SSRF veranlasst den Server, eine http-URL abzurufen. gopher verwandelt dies in das Senden beliebiger Bytes an jeden erreichbaren TCP-Port, da libcurl den Selector URL-dekodiert und im Rohformat überträgt. Eine einzelne URL kann ein Redis-SET zusammen mit CONFIG SET dir und SAVE senden, um eine Webshell abzulegen, oder einen FastCGI-Record an php-fpm übertragen. Die SSRF dient als Transportweg, gopher als Payload und ein zeilenbasierter interner Dienst als Angriffsziel.
Unterstützt Javas HttpURLConnection gopher?
Nein. java.net.URL und HttpURLConnection registrieren Handler ausschließlich für http, https, ftp, file, jar und mailto. Eine gopher-URL wirft unknown protocol: gopher. Das moderne HttpClient, Apache HttpClient und OkHttp verwerfen Nicht-http-Schemas konstruktionsbedingt. Java ist gegen gopher-SSRF praktisch immun, wenngleich java.net.URL weiterhin file:// unterstützt, was ein separates Problem darstellt.
Warum folgt libcurl keinem Redirect auf eine gopher://-URL?
Seit libcurl 7.65.2 im Mai 2019 umfasst die standardmäßige Redirect-Allowlist nur noch http, https, ftp und ftps. Ein 302-Redirect auf gopher:// wird daher abgewiesen und es wird kein Socket geöffnet. Die direkte Übergabe einer vollständigen gopher-URL funktioniert weiterhin, da die Transfer-Allowlist separat verwaltet wird. Eine Anwendung kann den Redirect-Pfad wieder öffnen, indem sie CURLOPT_REDIR_PROTOCOLS_STR erweitert, was genau die Schwachstellenklasse hinter CVE-2026-33752 in curl_cffi war. Setzen Sie auf modernen Zielen das gesamte gopher-Schema ein, anstatt sich auf einen Open-Redirect zu verlassen.
Wie sendet man CRLF über ein gopher-SSRF-Payload an Redis?
Indem man die Zeilenumbrüche enkodiert. libcurl URL-dekodiert den Selector, sodass gopher://127.0.0.1:6379/_SET%20k%20v%0d%0aSAVE%0d%0aQUIT nacheinander SET k v, ein echtes CRLF, SAVE, ein weiteres CRLF und schließlich QUIT sendet. In unserem Test kam _TESTPAYLOAD%0d%0aSECONDLINE auf dem Socket als Bytefolge TESTPAYLOAD\r\nSECONDLINE\r\n an. Da Redis ein zeilenbasiertes Protokoll verwendet, stellt dies eine valide Befehlsfolge dar. Aus diesem Grund ist gopher in Kombination mit Redis die Paradekette für SSRF-zu-RCE.
Weiterführende Artikel
- Was ist eine externe Angriffsfläche? Wo sich eine SSRF in das Gesamtbild einfügt, das ein Angreifer von der Angriffsfläche Ihres Unternehmens erstellt.
- Sicherheit entfernter MCP-Server: Die Hälfte der Registry gehört Dritten. Dasselbe Muster, bei dem ein Client eine von Ihnen beeinflusste URL abruft, auf Ebene maschineller Agenten.
- Check vs. Scan vs. Pentest. Warum verkettete Schwachstellen wie SSRF-zu-gopher-zu-RCE bei echten Tests gefunden werden, von Scannern jedoch nicht.
Wäre ein gopher-Pivot bei Ihnen erfolgreich?
Unser $100-Check bildet Ihre reale externe Angriffsfläche aus Angreiferperspektive ab, bezogen auf einen schriftlich autorisierten und verifizierten Scope, inklusive Nachbesprechung durch einen Senior Operator. Ob ein interner Redis- oder php-fpm-Dienst nur eine SSRF von der Codeausführung entfernt ist, gehört genau zu den Angriffsketten, nach denen wir suchen.
$100-Check buchen