Wszystkie publikacje

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

Matryca SSRF gopher: 6 klientów opartych na libcurl otwiera socket gopher, 0 natywnych stosów HTTP obsługuje gopher, przekierowanie do gopher domyślnie wyłączone od wersji libcurl 7.65.2 w 2019 roku.

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.

Krótko i wprost Jeśli podatny serwer pobiera adresy URL za pomocą libcurl lub jakichkolwiek powiązanych bindingów, traktuj gopher SSRF jak surowy socket do każdej usługi wewnętrznej i przygotuj się na zdalne wykonanie kodu przez Redis lub FastCGI. Jeśli pobiera je natywnym stosem HTTP, gopher po prostu odpada, ponieważ schemat jest odrzucany na poziomie parsera URL lub adaptera przed podjęciem jakiejkolwiek próby połączenia. Nie zgaduj po samym języku. Aplikacja PHP używająca 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.

Czy klient HTTP otwiera socket gopher://? Testowano 2026-08
Klient / bibliotekaJęzykSocket gopher://Powód
curl CLI / libcurl (live)CTAKgopher, gophers, dict, ftp, file są wkompilowane; domyślna allowlista transferu je włącza
wget / wget2 (live)Cniewbudowane tylko http/https/ftp; wget2 obsługuje wyłącznie https
PHP ext-curl (curl_exec) (live)PHPTAKcienki binding libcurl; klasyczny sink SSRF-do-Redis/FastCGI
PHP streams (file_get_contents)PHPnietylko zarejestrowane wrappery; brak wrappera gopher/dict (file i ftp tak)
Guzzle (curl handler, domyślny)PHPTAKdeleguje do ext-curl; handler stream tego nie robi
pycurl (live)PythonTAKbezpośredni binding libcurl; to samo ryzyko co w PHP curl
requests (live)PythonnieInvalidSchema: no connection adapters
httpx / aiohttp / urllib3PythonnieUnsupportedProtocol / NonHttpUrlClientError / URLSchemeUnknown
urllib (stdlib) (live)Pythonniebrak obsługi gopher w Python 3 (file i ftp tak)
net/httpGonieunsupported protocol scheme "gopher"; tylko implementacje round-tripper dla http/https
HttpURLConnection / HttpClientJavaniehandlery obsługują http/https/ftp/file/jar/mailto; unknown protocol: gopher
Apache HttpClient 4/5 / OkHttpJavanierejestr schematów z założenia obejmuje wyłącznie http/https
Net::HTTPRubyniespecyficzny dla http/https; OpenURI przekazuje gopher do File.open (odczyt lokalny, nie pivot TCP)
FaradayRubynie*na Net::HTTP; adapter typhoeus (libcurl) jest wyjątkiem obsługującym gopher
http / https / undici / fetch (live)Node.jsniezależne od protokołu; WHATWG fetch obsługuje tylko http/https/file/data/blob
HttpClient / HttpWebRequest.NETnietylko http/https; przestarzały GopherWebRequest został usunięty w .NET Core
reqwest / ureqRustniestosy hyper i czysty Rust, tylko http/https
curl crateRustTAKbezpośredni binding libcurl, te same konsekwencje co w innych bindingach
LWP::UserAgentPerlTAK*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:

Ż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.

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.

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

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