Quali librerie HTTP effettuano davvero il fetch di un URL gopher://? Una matrice SSRF testata empiricamente

Solo i client HTTP basati su libcurl apriranno effettivamente un gopher:// socket, e questo singolo fatto determina se una server-side request forgery può essere scalata da "far richiedere un URL al server" a "inviare byte arbitrari a qualsiasi servizio TCP interno." Abbiamo testato il client HTTP predefinito di dieci linguaggi contro un listener gopher attivo. Sei librerie client hanno aperto il socket, e ognuna di esse è un sottile wrapper attorno a libcurl: la riga di comando di curl, ext-curl, di Python pycurl, l'handler curl di Guzzle, il crate curl di Rust, e Faraday sull'adapter typhoeus. Ogni stack HTTP nativo che abbiamo provato ha rifiutato gopher prima ancora che un socket venisse aperto: Go net/http, Java HttpURLConnection e HttpClient, Python requests, httpx, aiohttp e urllib3, Node http e fetch, Ruby Net::HTTP, .NET HttpClient, Rust reqwest, e wget. La regola in una frase: gopher-over-SSRF è una questione di libcurl.
ext-curl è esposta mentre un'app PHP su stream wrapper non lo è; un'app Ruby su Net::HTTP è sicura mentre la stessa app su un adapter typhoeus non lo è.
Perché gopher è lo schema che conta per le SSRF
La server-side request forgery viene solitamente descritta come l'indurre un'applicazione a recuperare un URL scelto dall'attaccante. Di per sé questo consente di raggiungere servizi HTTP interni, il che è grave ma limitato. gopher è ciò che rimuove questo limite. libcurl tratta un URL gopher come gopher://host:port/<type><selector>, dove il primo carattere del path è il tipo di elemento gopher e tutto ciò che segue viene inviato testualmente come selettore, dopo la decodifica dell'URL, seguito da CRLF. Poiché i byte vengono prima decodificati dall'URL, un newline percent-encoded nel path diventa un vero a capo sulla rete. Questo è l'intero gadget. Trasforma il "recuperare un URL" in "scrivere qualsiasi sequenza di byte su qualsiasi porta TCP raggiungibile dal server", motivo per cui gopher abbinato a un Redis interno o a php-fpm rappresenta il percorso canonico da una blind SSRF alla code execution. È uno degli aspetti più insidiosi di una superficie di attacco esterna, e si nasconde interamente nella scelta del client HTTP.
Il test: un socket reale, non un'ipotesi
La maggior parte delle fonti afferma che "molte librerie supportano gopher" passando direttamente ai payload. Volevamo che il superamento o il fallimento del test fosse osservato empiricamente, non presunto, quindi il metodo è volutamente semplice. Per ciascun client avviamo un listener TCP one-shot su localhost, chiediamo al client di effettuare il fetch di gopher://127.0.0.1:PORT/_<payload>, e registriamo se il listener accetta una connessione e quali byte arrivano. Un client che rifiuta lo schema non si connette mai, quindi il listener non segnala nulla. Un client che apre il socket ci fornisce gli esatti byte trasmessi sulla rete. Si tratta di un esperimento attivo condotto su nostri container usa-e-getta e su un listener su localhost, senza toccare alcun host di terze parti. Ecco il punto centrale testato su curl, pycurl e PHP ext-curl, su tre diverse versioni di 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)
Ci sono due aspetti degni di nota direttamente da quell'output. Primo, i client basati su libcurl aprono il socket su ogni versione testata, da una vecchia 7.81.0 all'attuale 8.21.0, quindi non si tratta di un comportamento anomalo di una singola build. Secondo, il gadget CRLF è reale ed esatto. Le righe di pycurl e PHP sono state interrogate con il selettore URL _PYCURL%0d%0aLINE2 e _PHPCURL%0d%0aLINE2, e i byte arrivati contenevano un vero carriage-return line-feed tra i due token. Lo abbiamo confermato anche in isolamento: l'URL gopher://host:port/_TESTPAYLOAD%0d%0aSECONDLINE arriva sul socket come b'TESTPAYLOAD\r\nSECONDLINE\r\n'. Si tratta di un messaggio di protocollo su due righe scritto direttamente in un servizio TCP tramite nient'altro che un URL.
La matrice
Le righe contrassegnate come live sono state ritestate su un socket per questo post. Le restanti sono state verificate dal codice sorgente, dai registri di protocollo e dalla gestione documentata degli schemi di ciascun client. Il pattern è costante: la colonna "apre un socket" coincide esattamente con l'insieme dei binding di libcurl, più un caso isolato modulare in Perl.
| Client / libreria | Linguaggio | Socket gopher:// | Motivazione |
|---|---|---|---|
| curl CLI / libcurl (live) | C | SÌ | gopher, gophers, dict, ftp, file tutti compilati all'interno; l'allowlist di trasferimento predefinita li abilita |
| wget / wget2 (live) | C | no | solo http/https/ftp integrati; wget2 supporta solo https |
PHP ext-curl (curl_exec) (live) | PHP | SÌ | sottile binding di libcurl; il classico sink da SSRF a Redis/FastCGI |
Stream PHP (file_get_contents) | PHP | no | solo wrapper registrati; nessun wrapper gopher/dict (file e ftp sì) |
| Guzzle (handler curl, default) | PHP | SÌ | delega a ext-curl; l'handler stream no |
| pycurl (live) | Python | SÌ | binding diretto di libcurl; stessa insidia di PHP curl |
| requests (live) | Python | no | InvalidSchema: no connection adapters |
| httpx / aiohttp / urllib3 | Python | no | UnsupportedProtocol / NonHttpUrlClientError / URLSchemeUnknown |
| urllib (stdlib) (live) | Python | no | nessun opener gopher in Python 3 (file e ftp sì) |
| net/http | Go | no | unsupported protocol scheme "gopher"; solo round-tripper http/https |
| HttpURLConnection / HttpClient | Java | no | gli handler sono solo http/https/ftp/file/jar/mailto; unknown protocol: gopher |
| Apache HttpClient 4/5 / OkHttp | Java | no | il registro degli schemi è solo http/https per design |
| Net::HTTP | Ruby | no | specifico per http/https; OpenURI invia gopher a File.open (lettura locale, non un pivot TCP) |
| Faraday | Ruby | no* | su Net::HTTP; l'adapter typhoeus (libcurl) è l'eccezione che raggiunge gopher |
| http / https / undici / fetch (live) | Node.js | no | specifico per protocollo; fetch di WHATWG supporta solo http/https/file/data/blob |
| HttpClient / HttpWebRequest | .NET | no | solo http/https; il legacy GopherWebRequest è stato rimosso in .NET Core |
| reqwest / ureq | Rust | no | stack hyper e pure-Rust, solo http/https |
| crate curl | Rust | SÌ | binding diretto di libcurl, stesso caveat di ogni altro binding |
| LWP::UserAgent | Perl | SÌ* | solo se LWP::Protocol::gopher è installato; LWP è modulare per schema |
Contando le righe con SÌ il quadro è inequivocabile. Sei librerie client aprono un socket gopher, e tutte e sei usano libcurl: curl, PHP ext-curl, pycurl, Guzzle-curl, il crate curl di Rust e Faraday su typhoeus. Il settimo e unico riscontro positivo non-curl è quello di Perl LWP::UserAgent, e anche in quel caso richiede che l'operatore abbia installato il plugin opzionale LWP::Protocol::gopher, quindi è disattivato per default. Tutto il resto, ogni stack HTTP nativo diffuso in Go, Java, Python, Node, Ruby, .NET e Rust, rifiuta lo schema prima di aprire una connessione. Non esiste alcun payload ingegnoso in grado di cambiare questo comportamento, perché il rifiuto avviene nel parser degli URL o nel registro degli adapter, a monte di qualsiasi socket.
Prima sfumatura: il redirect verso gopher è disabilitato per default
"L'applicazione usa curl, quindi è sfruttabile" è vero solo a metà su una build moderna, e la metà mancante è dove si perde tempo durante un engagement. Esistono due allowlist distinte in libcurl. L'allowlist di trasferimento governa gli URL passati direttamente, e include ancora gopher. L'allowlist di redirect governa verso quali schemi può reindirizzare un header Location:, e a partire da libcurl 7.65.2 di maggio 2019 è ristretta ai soli http, https, ftp e ftps. Di conseguenza, un open redirect che punta a gopher:// viene rifiutato per default. Lo abbiamo osservato direttamente su 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'
Il redirect raggiunge gopher solo se l'applicazione ha riallargato i protocolli di redirect autonomamente, impostando CURLOPT_REDIR_PROTOCOLS_STR per includere gopher o passando alla CLI --proto-redir all. Tale configurazione errata non è ipotetica. CVE-2026-33752 appartiene esattamente a questa classe di bug: la libreria curl_cffi seguiva redirect verso protocolli arbitrari perché non vincolava l'allowlist di redirect, trasformando una semplice SSRF http in un pivot gopher completo. La deduzione pratica per un attaccante è che su un target libcurl moderno è necessario che la SSRF permetta di inserire l'intero schema, un URL diretto gopher://, anziché fare affidamento su un open redirect. La deduzione pratica per chi difende è che ampliare i protocolli di redirect è estremamente pericoloso, e l'impostazione predefinita che lo neutralizza è corretta dal 2019.
Seconda sfumatura: il payload è CRLF, ed è banale
La ragione per cui gopher è più rilevante di dict:// o file:// è il controllo a livello di byte. libcurl invia la porzione del selettore dell'URL come raw byte dopo la decodifica dell'URL, consentendo all'attaccante di comporre il traffico di rete direttamente nel path dell'URL. Un percent-encoded %0d%0a diventa un vero carriage-return line-feed, e qualsiasi protocollo basato su righe analizzerà il risultato come comandi separati:
- Redis. Un singolo URL gopher può inviare
SET, poiCONFIG SET direCONFIG SET dbfilename, quindiSAVE, scrivendo su disco una webshell, una voce di cron o un file authorized_keys. Questa è la catena generata dai payload generator pubblici. - FastCGI (php-fpm sulla 9000). Un record FastCGI creato ad hoc con
PHP_VALUEoSCRIPT_FILENAMEottiene l'esecuzione di codice contro un php-fpm in ascolto solo su localhost. - memcached, SMTP, Postgres, Zabbix. Qualsiasi protocollo basato su righe o length-prefixed raggiungibile dall'origine della SSRF è un candidato, perché si controllano completamente i byte.
Nulla di tutto questo richiede un bug di memoria o una race condition. Si tratta di un URL che viene decodificato in una conversazione di protocollo, motivo per cui un client che supporta gopher trasforma una modesta SSRF in una minaccia critica. Lo stesso meccanismo, un agente o un servizio che effettua il fetch di un URL influenzato dall'utente, è ciò che rende i client automatizzati che si connettono a endpoint arbitrari meritevoli di verifica per la stessa classe di pivot.
Come rimediare
La contromisura non è "smettere di usare curl". libcurl funziona perfettamente quando il suo perimetro di protocolli è vincolato. L'errore sta nell'affidarsi alle impostazioni predefinite e permettere all'input dell'utente di scegliere lo schema.
- Vincola esplicitamente l'allowlist degli schemi. Per libcurl, ext-curl e pycurl imposta
CURLOPT_PROTOCOLS_STR = "http,https"e non estendere maiCURLOPT_REDIR_PROTOCOLS_STRoltrehttp,https. Da CLI:--proto -all,http,https --proto-redir -all,http,https. - Non permettere mai all'input dell'utente di controllare lo schema dell'URL. Accetta host e path, imposta come costante
https://, e rifiuta qualsiasi altra cosa. La maggior parte delle catene da SSRF a gopher viene interrotta qui. - Tratta un redirect verso uno schema non-http come un errore bloccante, e rivalida lo schema risolto dopo ogni hop di redirect, lato server, non solo sull'URL iniziale.
- Preferisci un client solo-http per richieste che non necessitano di più protocolli. Go
net/http, JavaHttpCliente Pythonrequestsohttpxgarantiscono un'immunità nativa da gopher, dict e file. - Rimuovi i CRLF non codificati e codificati (
%0d,%0a) da qualsiasi path o query di un URL in uscita influenzato dall'utente. - Filtra il traffico in uscita a livello di rete in modo che anche un pivot riuscito non possa raggiungere un servizio interno: blocca 6379, 11211, 9000, 25 e 5432 dall'egress dell'applicazione, e associa i servizi interni a localhost con autenticazione.
- Verifica gli utilizzi indiretti di libcurl presenti nel tuo stack: l'handler curl di Guzzle, Faraday su typhoeus, il crate curl di Rust e Perl LWP con il plugin gopher hanno la stessa esposizione di PHP curl, e possono sfuggire facilmente in un dependency graph.
Metodo e limitazioni, per dati affidabili
Il dataset è nostro, generato osservando i socket, ed è riproducibile.
- Come abbiamo effettuato i test. Le righe live sono state eseguite in container temporanei (php:8.3-cli, python:3.12-slim) e su un host Linux standard, contro un listener TCP su localhost
python3che registra la connessione e i primi byte ricevuti. Sono state testate tre versioni di libcurl: 7.81.0, 8.14.1 e 8.21.0. - Cosa significa "no". Un "no" indica un client che non apre mai il socket, poiché rifiuta lo schema a livello di parser URL, di registro degli adapter o di gestore del protocollo. Questo offre una garanzia più forte rispetto a una connessione aperta e poi chiusa, poiché i byte dell'attaccante non lasciano mai il processo.
- Le righe con asterisco. Faraday e Perl LWP dipendono dall'adapter configurato o dal plugin installato, motivo per cui presentano un asterisco: la configurazione predefinita è sicura, una specifica configurazione non lo è.
- Ambito. L'analisi riguarda l'apertura o meno di un socket per gopher, che determina la possibilità di pivot SSRF. Non riguarda le SSRF basate su http, supportate da ogni client analizzato, né l'esposizione a
file://, che JavaURLe pochi altri presentano ancora.
Nessun host di terze parti è stato contattato in alcuna fase. Ogni connessione in questa ricerca è avvenuta verso un listener avviato su loopback, che è la differenza tra misurare un comportamento e scansionare il sistema di qualcun altro.
Domande frequenti
La libreria requests di Python supporta il protocollo gopher?
No. In un test reale, requests solleva InvalidSchema: No connection adapters were found per un URL gopher e non apre mai un socket. Lo stesso vale per httpx, aiohttp, urllib3, e per il modulo della standard library urllib. L'unica eccezione in Python è pycurl, un sottile binding su libcurl, che apre effettivamente un socket gopher. Se il tuo codice Python usa requests o httpx, un payload SSRF gopher non può raggiungere un servizio interno tramite esso.
Perché curl supporta gopher mentre wget no?
Insiemi di protocolli differenti. libcurl compila al suo interno gopher, gophers, dict, ftp e file per default, e la sua allowlist di trasferimento li abilita. wget è stato creato solo per http, https e ftp, mentre wget2 supporta solo https. Si tratta di una differenza di progettazione e in fase di compilazione, non di uno switch a runtime, motivo per cui curl è il classico vettore di pivot SSRF e wget non lo è.
Quali protocolli supporta libcurl per le SSRF?
Su una build standard, libcurl può aprire gopher, gophers, dict, ftp, ftps, file, tftp, ldap e i protocolli di posta, oltre a http e https. Per le SSRF quelli rilevanti sono gopher e dict, poiché entrambi inviano byte controllati dall'attaccante su un raw socket. gopher è il più potente, perché il selettore dopo il carattere del tipo di elemento viene inviato testualmente, trasformando un CRLF codificato in un vero a capo.
In che modo il protocollo gopher scala una SSRF a RCE?
Una comune SSRF fa richiedere al server un URL http. gopher trasforma questa richiesta nell'invio di byte arbitrari a qualsiasi porta TCP raggiungibile, poiché libcurl decodifica l'URL del selettore e lo scrive in formato raw. Un singolo URL può inviare a Redis SET oltre a CONFIG SET dir e SAVE per depositare una webshell, oppure un record FastCGI a php-fpm. La SSRF è il vettore di consegna, gopher è il payload e un servizio interno basato su righe è il target.
HttpURLConnection di Java supporta gopher?
No. java.net.URL e HttpURLConnection registrano gestori solo per http, https, ftp, file, jar e mailto, e un URL gopher solleva unknown protocol: gopher. I moderni HttpClient, Apache HttpClient e OkHttp rifiutano qualsiasi schema non-http per design. Java è di fatto immune a SSRF gopher, anche se java.net.URL supporta ancora file://, che costituisce una problematica distinta.
Perché libcurl non segue un redirect verso un URL gopher://?
A partire da libcurl 7.65.2 di maggio 2019, l'allowlist di redirect predefinita include solo http, https, ftp e ftps, quindi un 302 verso gopher:// viene rifiutato e non viene aperto alcun socket. L'invio diretto di un URL gopher completo funziona ancora, poiché l'allowlist di trasferimento è separata. Un'applicazione può riaprire il percorso di redirect ampliando CURLOPT_REDIR_PROTOCOLS_STR, che è la classe di bug alla base di CVE-2026-33752 in curl_cffi. Su un target moderno, inserisci l'intero schema gopher anziché affidarti a un open redirect.
Come si invia un CRLF a Redis tramite un payload SSRF gopher?
Codificando i newline. libcurl decodifica l'URL del selettore, quindi gopher://127.0.0.1:6379/_SET%20k%20v%0d%0aSAVE%0d%0aQUIT invia SET k v, un vero CRLF, SAVE, un vero CRLF, quindi QUIT. Nel nostro test, _TESTPAYLOAD%0d%0aSECONDLINE è arrivato come sequenza di byte TESTPAYLOAD\r\nSECONDLINE\r\n. Redis adotta un protocollo delimitato da newline, quindi questa è una sequenza di comandi valida, motivo per cui gopher abbinato a Redis è la classica catena da SSRF a RCE.
Letture correlate
- Cos'è una superficie di attacco esterna? Dove si colloca una SSRF nella mappatura che un attaccante costruisce su ciò che la tua organizzazione espone.
- Sicurezza dei server MCP remoti: metà del registro è il server di qualcun altro. Lo stesso pattern "un client effettua il fetch di un URL controllato dall'utente", a livello di agent automatici.
- Check contro scan contro pentest. Perché un riscontro concatenato come SSRF-gopher-RCE è ciò che emerge da un vero test e non da uno scanner.
Un pivot gopher funzionerebbe contro la tua infrastruttura?
Il nostro check da $100 mappa la tua reale superficie di attacco esterna esattamente come farebbe un attaccante, su un perimetro verificato di tua proprietà e autorizzato per iscritto, con un operatore senior per l'analisi dei risultati. Verificare se un Redis o un php-fpm interno sia a una sola SSRF di distanza dall'esecuzione di codice è esattamente il tipo di concatenazione che cerchiamo.
Prenota un check da $100