Które biblioteki HTTP faktycznie pobierają adresy gopher://? Empirycznie przetestowana matryca SSRF

Tylko klienty HTTP oparte na libcurl faktycznie otworzą socket gopher://, a ten pojedynczy fakt decyduje o tym, czy server-side request forgery można eskalować z poziomu "zmuś serwer do pobrania URL" do "wyślij dowolne bajty do dowolnej wewnętrznej usługi TCP". Przetestowaliśmy domyślnego klienta HTTP w dziesięciu językach pod kątem aktywnego listenera gopher. Sześć bibliotek klienckich otworzyło socket i każda z nich jest cienkim wrapperem wokół libcurl: linia poleceń curl, w PHP ext-curl, w Pythonie pycurl, handler curl w Guzzle, crate w Rust curl, a także Faraday z adapterem typhoeus. Każdy sprawdzony przez nas natywny stos HTTP danego języka odrzucił gopher przed otwarciem socketu: Go net/http, Java HttpURLConnection oraz HttpClient, Python requests, httpx, aiohttp oraz urllib3, Node http oraz fetch, Ruby Net::HTTP, .NET HttpClient, Rust reqwest oraz wget. Reguła w jednym zdaniu: gopher-over-SSRF to wyłącznie kwestia libcurl.
ext-curl jest wystawiona na atak, a aplikacja PHP na stream wrappers nie; aplikacja Ruby na Net::HTTP jest bezpieczna, ale ta sama aplikacja na adapterze typhoeus już nie.
Dlaczego gopher jest kluczowym schematem dla SSRF
Server-side request forgery opisuje się zwykle jako nakłonienie aplikacji do pobrania wskazanego przez atakującego adresu URL. Samo w sobie pozwala to dotrzeć do wewnętrznych usług HTTP, co jest groźne, ale ograniczone. gopher usuwa to ograniczenie. libcurl traktuje URL gopher jako gopher://host:port/<type><selector>, gdzie pierwszy znak ścieżki określa typ elementu gopher, a wszystko po nim jest wysyłane dosłownie jako selektor, po zdekodowaniu URL, z doklejonym CRLF. Ponieważ bajty są najpierw dekodowane z formatu URL, zakodowany znak nowej linii w ścieżce staje się prawdziwym znakiem nowej linii w przesyłanym strumieniu. To cały mechanizm. Zamienia on "pobierz URL" w "zapisz dowolną sekwencję bajtów do dowolnego portu TCP dostępnego z serwera", dlatego gopher w połączeniu z wewnętrznym Redisem lub php-fpm to klasyczna ścieżka od blind SSRF do wykonania kodu. To jedna z ostrzejszych krawędzi zewnętrznej powierzchni ataku, a kryje się wyłącznie w wyborze klienta HTTP.
Test: prawdziwy socket, a nie założenia
Większość źródeł zakłada, że "wiele bibliotek obsługuje gopher" i przechodzi od razu do payloadów. Chcieliśmy zaobserwować sukces lub porażkę, a nie opierać się na deklaracjach, więc metoda jest celowo prosta. Dla każdego klienta uruchamiamy jednorazowy listener TCP na localhost, zlecamy klientowi pobranie gopher://127.0.0.1:PORT/_<payload> i rejestrujemy, czy listener w ogóle zaakceptował połączenie oraz jakie bajty dotarły. Klient, który odrzuca schemat, nigdy się nie łączy, więc listener nic nie zgłasza. Klient, który otwiera socket, przekazuje nam dokładne bajty z sieci. Jest to aktywny eksperyment na naszych własnych, tymczasowych kontenerach i listenerze localhost, bez dotykania jakichkolwiek zewnętrznych hostów. Oto kluczowe wyniki dla curl, pycurl i PHP ext-curl, na trzech różnych wersjach 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)
Z tych danych wyjściowych warto wyciągnąć bezpośrednio dwa wnioski. Po pierwsze, klienty oparte na libcurl otwierają socket w każdej testowanej wersji, od starej 7.81.0 do aktualnej 8.21.0, więc nie jest to kwestia pojedynczego buildu. Po drugie, gadżet CRLF działa realnie i precyzyjnie. Wiersze pycurl i PHP zostały pobrane z selektorami URL _PYCURL%0d%0aLINE2 oraz _PHPCURL%0d%0aLINE2, a odebrane bajty zawierały prawdziwy powrót karetki i znak nowej linii pomiędzy tymi dwoma tokenami. Potwierdziliśmy to również w izolacji: URL gopher://host:port/_TESTPAYLOAD%0d%0aSECONDLINE trafia do socketu jako b'TESTPAYLOAD\r\nSECONDLINE\r\n'. To dwuwierszowy komunikat protokołu zapisany bezpośrednio do usługi TCP za pomocą samego adresu URL.
Matryca
Wiersze oznaczone jako live zostały ponownie przetestowane na żywym sockecie na potrzeby tego artykułu. Reszta została zweryfikowana w kodzie źródłowym, rejestrach protokołów i dokumentacji obsługi schematów poszczególnych klientów. Schemat jest stały: kolumna "otwiera socket" pokrywa się dokładnie z listą bindingów libcurl, plus jeden modułowy wyjątek w Perlu.
| Klient / biblioteka | Język | Socket gopher:// | Powód |
|---|---|---|---|
| curl CLI / libcurl (live) | C | TAK | gopher, gophers, dict, ftp, file są wkompilowane; domyślna allowlista transferu je włącza |
| wget / wget2 (live) | C | nie | wbudowane tylko http/https/ftp; wget2 obsługuje wyłącznie https |
PHP ext-curl (curl_exec) (live) | PHP | TAK | cienki binding libcurl; klasyczny sink SSRF-do-Redis/FastCGI |
PHP streams (file_get_contents) | PHP | nie | tylko zarejestrowane wrappery; brak wrappera gopher/dict (file i ftp tak) |
| Guzzle (curl handler, domyślny) | PHP | TAK | deleguje do ext-curl; handler stream tego nie robi |
| pycurl (live) | Python | TAK | bezpośredni binding libcurl; to samo ryzyko co w PHP curl |
| requests (live) | Python | nie | InvalidSchema: no connection adapters |
| httpx / aiohttp / urllib3 | Python | nie | UnsupportedProtocol / NonHttpUrlClientError / URLSchemeUnknown |
| urllib (stdlib) (live) | Python | nie | brak obsługi gopher w Python 3 (file i ftp tak) |
| net/http | Go | nie | unsupported protocol scheme "gopher"; tylko implementacje round-tripper dla http/https |
| HttpURLConnection / HttpClient | Java | nie | handlery obsługują http/https/ftp/file/jar/mailto; unknown protocol: gopher |
| Apache HttpClient 4/5 / OkHttp | Java | nie | rejestr schematów z założenia obejmuje wyłącznie http/https |
| Net::HTTP | Ruby | nie | specyficzny dla http/https; OpenURI przekazuje gopher do File.open (odczyt lokalny, nie pivot TCP) |
| Faraday | Ruby | nie* | na Net::HTTP; adapter typhoeus (libcurl) jest wyjątkiem obsługującym gopher |
| http / https / undici / fetch (live) | Node.js | nie | zależne od protokołu; WHATWG fetch obsługuje tylko http/https/file/data/blob |
| HttpClient / HttpWebRequest | .NET | nie | tylko http/https; przestarzały GopherWebRequest został usunięty w .NET Core |
| reqwest / ureq | Rust | nie | stosy hyper i czysty Rust, tylko http/https |
| curl crate | Rust | TAK | bezpośredni binding libcurl, te same konsekwencje co w innych bindingach |
| LWP::UserAgent | Perl | TAK* | tylko jeśli zainstalowano LWP::Protocol::gopher; LWP ma architekturę modułową per schemat |
Wystarczy policzyć wiersze z wartością TAK, aby obraz sytuacji stał się jasny. Sześć bibliotek klienckich otwiera socket gopher i wszystkie sześć bazuje na libcurl: curl, PHP ext-curl, pycurl, Guzzle-curl, crate curl w Rust oraz Faraday na typhoeus. Siódmym i jedynym wynikiem pozytywnym spoza curl jest LWP::UserAgent w Perlu, a nawet on wymaga manualnej instalacji opcjonalnego pluginu LWP::Protocol::gopher, więc domyślnie jest wyłączony. Wszystko inne, każdy mainstreamowy natywny stos HTTP w Go, Java, Python, Node, Ruby, .NET i Rust, odrzuca ten schemat przed nawiązaniem połączenia. Żaden sprytny payload tego nie zmieni, ponieważ odmowa następuje na etapie parsera URL lub rejestru adapterów, przed utworzeniem socketu.
Niuanse: przekierowanie do gopher jest domyślnie wyłączone
Stwierdzenie "aplikacja używa curl, więc jest podatna" jest prawdziwe tylko w połowie w nowoczesnych kompilacjach, a pominięcie tej drugiej połowy prowadzi do straty czasu podczas audytu. W libcurl istnieją dwie niezależne allowlisty. Allowlista transferu zarządza adresem URL przekazanym bezpośrednio i nadal dopuszcza gopher. Allowlista przekierowań określa, na jakie schematy może przełączyć nagłówek Location:, a od wersji libcurl 7.65.2 z maja 2019 r. została ograniczona wyłącznie do http, https, ftp i ftps. Zatem open redirect wskazujący na gopher:// jest domyślnie odrzucany. Zaobserwowaliśmy to na 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'
Przekierowanie dotrze do gopher tylko wtedy, gdy aplikacja sama rozszerzyła listę dozwolonych protokołów przekierowań, ustawiając CURLOPT_REDIR_PROTOCOLS_STR tak, aby zawierało gopher, lub przekazując do CLI --proto-redir all. Taka błędna konfiguracja nie jest czysto teoretyczna. CVE-2026-33752 reprezentuje dokładnie tę klasę błędów: biblioteka curl_cffi podążała za przekierowaniami do dowolnych protokołów, ponieważ nie ograniczała allowlisty przekierowań, zamieniając zwykły http SSRF z powrotem w pełny pivot gopher. Praktyczny wniosek dla atakującego jest taki, że w przypadku nowoczesnego celu opartego na libcurl, podatność SSRF musi pozwalać na wskazanie całego schematu, bezpośredniego URL gopher://, zamiast polegać na open redirect. Praktyczny wniosek dla obrońcy: rozszerzanie dozwolonych protokołów przekierowań to proszenie się o kłopoty, a domyślna konfiguracja, która to blokuje, działa poprawnie od 2019 roku.
Niuanse: payload to CRLF i jest banalnie prosty
Powodem, dla którego gopher ma większe znaczenie niż dict:// czy file://, jest pełna kontrola na poziomie bajtów. libcurl przesyła część selektora adresu URL jako surowe bajty po zdekodowaniu URL, więc atakujący komponuje ruch sieciowy bezpośrednio w ścieżce URL. Zakodowane %0d%0a staje się prawdziwym powrotem karetki i znakiem nowej linii, a każdy protokół operujący na liniach tekstu zinterpretuje wynik jako oddzielne polecenia:
- Redis. Pojedynczy URL gopher może wysłać
SET, następnieCONFIG SET dirorazCONFIG SET dbfilename, a potemSAVE, zapisując webshella, wpis w cronie lub plik authorized_keys na dysku. To dokładnie taki łańcuch generują publiczne narzędzia do tworzenia payloadów. - FastCGI (php-fpm na porcie 9000). Odpowiednio spreparowany rekord FastCGI z
PHP_VALUElubSCRIPT_FILENAMEprowadzi do wykonania kodu na php-fpm nasłuchującym wyłącznie na localhost. - memcached, SMTP, Postgres, Zabbix. Każdy protokół tekstowy lub ze zdefiniowaną długością pakietu dostępny ze źródła SSRF jest potencjalnym celem, ponieważ w pełni kontrolujesz przesyłane bajty.
Żaden z tych scenariuszy nie wymaga błędu pamięci ani wyścigu. To po prostu URL dekodowany do sekwencji poleceń protokołu, dlatego klient obsługujący gopher zamienia niegroźny SSRF w krytyczną podatność. Ten sam schemat, w którym agent lub usługa pobiera kontrolowany przez Ciebie URL, sprawia, że klienci maszynowi łączący się z dowolnymi endpointami są warci audytu pod kątem tej samej klasy pivotu.
Jak się przed tym zabezpieczyć
Rozwiązaniem nie jest "przestań używać curl". libcurl jest bezpieczny, jeśli zakres obsługiwanych protokołów zostanie ściśle zdefiniowany. Błędem jest poleganie na domyślnych ustawieniach i zezwalanie, aby dane wejściowe użytkownika decydowały o schemacie.
- Sztywno zdefiniuj allowlistę schematów. Dla libcurl, ext-curl i pycurl ustaw
CURLOPT_PROTOCOLS_STR = "http,https"i nigdy nie rozszerzajCURLOPT_REDIR_PROTOCOLS_STRpozahttp,https. W CLI:--proto -all,http,https --proto-redir -all,http,https. - Nigdy nie pozwól użytkownikowi kontrolować schematu URL. Przyjmuj wyłącznie host i ścieżkę, wpisz na stałe
https://i odrzucaj wszystko inne. Większość łańcuchów SSRF-do-gopher kończy się na tym etapie. - Traktuj przekierowanie do schematu innego niż http jako krytyczny błąd, a po każdym przekierowaniu ponownie weryfikuj wynikowy schemat po stronie serwera, a nie tylko na wejściowym adresie URL.
- Wybieraj klientów obsługujących wyłącznie protokół http do pobierania zasobów, które nie wymagają wielu protokołów. Go
net/http, JavaHttpClientoraz Pythonrequestslubhttpxzapewniają odporność na gopher, dict i file bez dodatkowej konfiguracji. - Usuwaj surowe i zakodowane znaki CRLF (
%0d,%0a) ze wszystkich ścieżek i parametrów URL zależnych od danych wejściowych użytkownika. - Stosuj filtrowanie ruchu wychodzącego (egress) na poziomie sieci, aby nawet udany pivot nie dotarł do wewnętrznej usługi: zablokuj porty 6379, 11211, 9000, 25 i 5432 dla ruchu wychodzącego z aplikacji, a usługi wewnętrzne przypisuj do localhost z wymogiem uwierzytelniania.
- Zweryfikuj pośrednich użytkowników libcurl ukrytych w Twoim stosie technologicznym: handler curl w Guzzle, Faraday na typhoeus, crate curl w Rust i Perl LWP z wtyczką gopher niosą takie samo ryzyko jak PHP curl i łatwo je przeoczyć w grafie zależności.
Metodologia i ograniczenia, czyli dlaczego dane są wiarygodne
Zestaw danych jest nasz własny, stworzony na podstawie obserwacji socketów i w pełni powtarzalny.
- Jak testowaliśmy. Wiersze oznaczone jako live zostały uruchomione w jednorazowych kontenerach (php:8.3-cli, python:3.12-slim) oraz na standardowym hoście z systemem Linux, weryfikując zachowanie na listenerze TCP
python3na localhost, który rejestrował połączenie i pierwsze odebrane bajty. Przetestowano trzy wersje libcurl: 7.81.0, 8.14.1 oraz 8.21.0. - Co oznacza "nie". Wartość "nie" oznacza klienta, który w ogóle nie otwiera socketu, ponieważ odrzuca schemat na poziomie parsera URL, rejestru adapterów lub handlera protokołu. To bezpieczniejsza sytuacja niż nawiązanie i natychmiaste zamknięcie połączenia, ponieważ bajty atakującego nigdy nie opuszczają procesu.
- Wiersze z gwiazdką. Faraday i Perl LWP zależą od skonfigurowanego adaptera lub zainstalowanej wtyczki, stąd gwiazdka: konfiguracja domyślna jest bezpieczna, a specyficzna konfiguracja już nie.
- Zakres testu. Badanie dotyczy wyłącznie tego, czy gopher otwiera socket, co determinuje pivot SSRF. Nie odnosi się do podatności SSRF opartej na http, którą umożliwia każdy z tych klientów, ani do ryzyka związanego z
file://, które nadal występuje w javowymURLi kilku innych.
W żadnym momencie nie nawiązywano połączeń z zewnętrznymi hostami. Wszystkie połączenia w tym badaniu trafiały do listenera uruchomionego na loopbacku. To zasadnicza różnica między mierzeniem zachowania a skanowaniem cudzej infrastruktury.
Często zadawane pytania
Czy biblioteka requests w Pythonie obsługuje protokół gopher?
Nie. W testach na żywo requests wyrzuca wyjątek InvalidSchema: No connection adapters were found dla adresu URL gopher i nigdy nie otwiera socketu. To samo dotyczy httpx, aiohttp, urllib3 oraz standardowej biblioteki urllib. Jedynym wyjątkiem w Pythonie jest pycurl, cienki wrapper wokół libcurl, który rzeczywiście otwiera socket gopher. Jeśli Twój kod w Pythonie korzysta z requests lub httpx, payload SSRF z gopher nie dotrze przez niego do wewnętrznej usługi.
Dlaczego curl obsługuje gopher, a wget nie?
Wynika to z innego zestawu protokołów. libcurl domyślnie kompiluje obsługę gopher, gophers, dict, ftp oraz file, a jego allowlista transferu pozwala na ich użycie. wget został stworzony wyłącznie dla http, https i ftp, a wget2 obsługuje tylko https. Jest to różnica na poziomie architektury i kompilacji, a nie przełącznik konfiguracyjny runtime, dlatego curl stanowi klasyczny wektor pivotu SSRF, a wget nie.
Jakie protokoły obsługuje libcurl w kontekście SSRF?
W standardowej kompilacji dystrybucyjnej libcurl może obsługiwać gopher, gophers, dict, ftp, ftps, file, tftp, ldap oraz protokoły pocztowe, poza http i https. W kontekście SSRF najważniejsze są gopher i dict, ponieważ oba pozwalają wysłać kontrolowane przez atakującego bajty do surowego socketu. gopher daje największe możliwości, ponieważ selektor po znaku typu elementu jest wysyłany dosłownie, więc zakodowany CRLF staje się prawdziwym przejściem do nowej linii.
W jaki sposób protokół gopher pozwala eskalować SSRF do RCE?
Zwykły SSRF zmusza serwer do pobrania adresu URL http. gopher zamienia to w wysyłanie dowolnych bajtów do dowolnego osiągalnego portu TCP, ponieważ libcurl dekoduje selektor z formatu URL i przesyła go w postaci surowej. Pojedynczy URL może wysłać polecenie Redis SET wraz z CONFIG SET dir oraz SAVE, aby zapisać webshella, lub spreparowany rekord FastCGI do php-fpm. SSRF jest wektorem dostarczenia, gopher jest payloadem, a wewnętrzna usługa tekstowa jest celem.
Czy javowy HttpURLConnection obsługuje gopher?
Nie. java.net.URL oraz HttpURLConnection rejestrują handlery wyłącznie dla http, https, ftp, file, jar oraz mailto, a URL gopher powoduje rzucenie unknown protocol: gopher. Nowoczesne HttpClient, Apache HttpClient i OkHttp z założenia odrzucają każdy schemat inny niż http. Java jest w praktyce odporna na gopher SSRF, chociaż java.net.URL nadal obsługuje file://, co stanowi osobne zagrożenie.
Dlaczego libcurl nie podąża za przekierowaniem do adresu URL gopher://?
Od wersji libcurl 7.65.2 z maja 2019 roku domyślna allowlista przekierowań została ograniczona wyłącznie do http, https, ftp oraz ftps, więc odpowiedź 302 wskazująca na gopher:// jest odrzucana i socket nie zostaje otwarty. Bezpośrednie podanie pełnego adresu URL gopher nadal działa, ponieważ allowlista transferu jest osobnym mechanizmem. Aplikacja może odblokować ścieżkę przekierowań poprzez rozszerzenie CURLOPT_REDIR_PROTOCOLS_STR, co stanowiło istotę podatności CVE-2026-33752 w curl_cffi. W przypadku współczesnych celów skutecznym wektorem jest podanie pełnego schematu gopher zamiast polegania na open redirect.
Jak wysłać CRLF do bazy Redis za pomocą payloadu gopher SSRF?
Należy zakodować znaki nowej linii. libcurl dekoduje selektor z formatu URL przed zapisaniem go do socketu, więc URL taki jak gopher://127.0.0.1:6379/_SET%20k%20v%0d%0aSAVE%0d%0aQUIT wysyła SET k v, prawdziwy CRLF, SAVE, prawdziwy CRLF, a następnie QUIT. W naszym teście URL _TESTPAYLOAD%0d%0aSECONDLINE dotarł do nasłuchującego socketu jako bajty TESTPAYLOAD\r\nSECONDLINE\r\n. Redis wykorzystuje protokół oparty na liniach tekstu, więc jest to w pełni poprawna sekwencja poleceń, co czyni połączenie gopher i Redis podręcznikowym łańcuchem SSRF-do-RCE.
Polecane materiały
- Czym jest zewnętrzna powierzchnia ataku? Gdzie plasuje się SSRF na mapie zasobów Twojej organizacji widzianej oczami atakującego.
- Bezpieczeństwo zdalnych serwerów MCP: połowa rejestru to serwery innych podmiotów. Ten sam mechanizm, w którym klient pobiera kontrolowany przez Ciebie URL, na poziomie agentów maszynowych.
- Weryfikacja a skanowanie a pentest. Dlaczego złożone podatności łańcuchowe, takie jak SSRF-do-gopher-do-RCE, ujawnia tylko prawdziwy test, a nie automatyczny skaner.
Czy pivot przez gopher zadziałałby przeciwko Tobie?
Nasza weryfikacja za $100 mapuje Twoją rzeczywistą zewnętrzną powierzchnię ataku dokładnie tak, jak robi to atakujący, w zweryfikowanym i pisemnie autoryzowanym zakresie, z udziałem doświadczonego inżyniera podczas omówienia wyników. Sprawdzenie, czy wewnętrzny Redis lub php-fpm dzieli od wykonania kodu tylko jedna podatność SSRF, to dokładnie ten typ łańcucha, którego szukamy.
Zamów badanie za $100