All research

Which HTTP libraries actually fetch a gopher:// URL? An empirically-tested SSRF matrix

SSRF gopher matrix: 6 libcurl-backed clients open a gopher socket, 0 native HTTP stacks reach gopher, redirect to gopher disabled by default since libcurl 7.65.2 in 2019.

Only HTTP clients built on libcurl will actually open a gopher:// socket, and that single fact decides whether a server-side request forgery can be escalated from "make the server fetch a URL" to "send arbitrary bytes to any internal TCP service." We tested the default HTTP client of ten languages against a live gopher listener. Six client libraries opened the socket, and every one of them is a thin wrapper around libcurl: the curl command line, PHP's ext-curl, Python's pycurl, Guzzle's curl handler, the Rust curl crate, and Faraday on the typhoeus adapter. Every native-language HTTP stack we tried refused gopher before a socket ever opened: Go net/http, Java HttpURLConnection and HttpClient, Python requests, httpx, aiohttp and urllib3, Node http and fetch, Ruby Net::HTTP, .NET HttpClient, Rust reqwest, and wget. The one-sentence law: gopher-over-SSRF is a libcurl story.

The blunt version If the vulnerable server fetches URLs with libcurl or any binding over it, treat a gopher SSRF as a raw socket to every internal service and plan for remote code execution through Redis or FastCGI. If it fetches with a native HTTP stack, gopher is simply off the table, because the scheme is rejected at the URL or adapter layer before any connection is attempted. Do not guess from the language. A PHP app on ext-curl is exposed and a PHP app on stream wrappers is not; a Ruby app on Net::HTTP is safe and the same app on a typhoeus adapter is not.

Why gopher is the scheme that matters for SSRF

Server-side request forgery is usually described as tricking an application into fetching an attacker-chosen URL. On its own that reaches internal HTTP services, which is bad but bounded. gopher is what removes the bound. libcurl treats a gopher URL as gopher://host:port/<type><selector>, where the first path character is the gopher item type and everything after it is sent verbatim as the selector, after URL-decoding, followed by CRLF. Because the bytes are URL-decoded first, a percent-encoded newline in the path becomes a real newline on the wire. That is the entire gadget. It turns "fetch a URL" into "write any byte sequence to any TCP port the server can reach," which is why gopher plus an internal Redis or php-fpm is the canonical path from a blind SSRF to code execution. It is one of the sharper edges of an external attack surface, and it hides entirely in the choice of HTTP client.

The test: a real socket, not a claim

Most references assert that "many libraries support gopher" and move straight to payloads. We wanted the pass or fail to be observed, not asserted, so the method is deliberately dumb. For each client we start a one-shot TCP listener on localhost, ask the client to fetch gopher://127.0.0.1:PORT/_<payload>, and record whether the listener ever accepts a connection and what bytes arrive. A client that refuses the scheme never connects, so the listener reports nothing. A client that opens the socket hands us the exact bytes on the wire. This is an active experiment against our own throwaway containers and a localhost listener, touching no third-party host. Here is the core of it against curl, pycurl, and PHP ext-curl, across three different libcurl versions:

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)

Two things are worth reading off that output directly. First, the libcurl clients open the socket on every version we tried, from an old 7.81.0 to a current 8.21.0, so this is not a quirk of one build. Second, the CRLF gadget is real and exact. The pycurl and PHP rows were fetched with the URL selector _PYCURL%0d%0aLINE2 and _PHPCURL%0d%0aLINE2, and the bytes that arrived contained a genuine carriage-return line-feed between the two tokens. We confirmed it in isolation as well: the URL gopher://host:port/_TESTPAYLOAD%0d%0aSECONDLINE lands on the socket as b'TESTPAYLOAD\r\nSECONDLINE\r\n'. That is a two-line protocol message written straight into a TCP service by nothing more than a URL.

The matrix

Rows marked live were re-tested against a socket for this post. The rest are verified from source, protocol registries, and each client's documented scheme handling. The pattern does not wander: the "opens a socket" column is exactly the set of libcurl bindings, plus one modular outlier in Perl.

Does the HTTP client open a gopher:// socket? Tested 2026-08
Client / libraryLanguagegopher:// socketWhy
curl CLI / libcurl (live)CYESgopher, gophers, dict, ftp, file all compiled in; default transfer allowlist enables them
wget / wget2 (live)Cnoonly http/https/ftp built in; wget2 is https-only
PHP ext-curl (curl_exec) (live)PHPYESthin libcurl binding; the classic SSRF-to-Redis/FastCGI sink
PHP streams (file_get_contents)PHPnoonly registered wrappers; no gopher/dict wrapper (file and ftp yes)
Guzzle (curl handler, default)PHPYESdelegates to ext-curl; stream handler does not
pycurl (live)PythonYESdirect libcurl binding; same footgun as PHP curl
requests (live)PythonnoInvalidSchema: no connection adapters
httpx / aiohttp / urllib3PythonnoUnsupportedProtocol / NonHttpUrlClientError / URLSchemeUnknown
urllib (stdlib) (live)Pythonnono gopher opener in Python 3 (file and ftp yes)
net/httpGonounsupported protocol scheme "gopher"; only http/https round-trippers
HttpURLConnection / HttpClientJavanohandlers are http/https/ftp/file/jar/mailto; unknown protocol: gopher
Apache HttpClient 4/5 / OkHttpJavanoscheme registry is http/https only by design
Net::HTTPRubynohttp/https specific; OpenURI sends gopher to File.open (local-read, not a TCP pivot)
FaradayRubyno*on Net::HTTP; the typhoeus (libcurl) adapter is the exception that reaches gopher
http / https / undici / fetch (live)Node.jsnoprotocol-specific; WHATWG fetch is http/https/file/data/blob only
HttpClient / HttpWebRequest.NETnohttp/https only; the legacy GopherWebRequest was removed in .NET Core
reqwest / ureqRustnohyper and pure-Rust stacks, http/https only
curl crateRustYESdirect libcurl binding, same caveat as every other binding
LWP::UserAgentPerlYES*only if LWP::Protocol::gopher is installed; LWP is modular per scheme

Count the YES rows and the story is unmissable. Six client libraries open a gopher socket, and all six are libcurl: curl, PHP ext-curl, pycurl, Guzzle-curl, the Rust curl crate, and Faraday-on-typhoeus. The seventh and only non-curl positive is Perl's LWP::UserAgent, and even that needs an operator to have installed the optional LWP::Protocol::gopher plugin, so it is off by default. Everything else, every mainstream native HTTP stack in Go, Java, Python, Node, Ruby, .NET, and Rust, rejects the scheme before it opens a connection. There is no clever payload that changes that, because the refusal happens at the URL parser or the adapter registry, upstream of any socket.

Nuance one: redirect into gopher is off by default

"The app uses curl, so it is exploitable" is only half true on a modern build, and the missing half is where people waste an engagement. There are two separate allowlists in libcurl. The transfer allowlist governs a URL you hand it directly, and it still enables gopher. The redirect allowlist governs what schemes a Location: header may switch to, and since libcurl 7.65.2 in May 2019 it is narrowed to http, https, ftp, and ftps only. So an open-redirect that points at gopher:// is refused by default. We watched it happen on 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'

The redirect only reaches gopher if the application re-widened the redirect protocols itself, by setting CURLOPT_REDIR_PROTOCOLS_STR to include gopher or by handing the CLI --proto-redir all. That misconfiguration is not hypothetical. CVE-2026-33752 is exactly this class of bug: the curl_cffi library followed redirects into arbitrary protocols because it did not constrain the redirect allowlist, turning a plain http SSRF back into a full gopher pivot. The practical read for an attacker is that on a current libcurl target you need the SSRF to let you place the whole scheme, a direct gopher:// URL, rather than relying on an open-redirect. The practical read for a defender is that widening redirect protocols is a loaded gun, and the default that disarms it has been correct since 2019.

Nuance two: the payload is CRLF, and it is trivial

The reason gopher matters more than dict:// or file:// is the byte-level control. libcurl sends the selector portion of the URL as raw bytes after URL-decoding, so the attacker composes the wire traffic in the URL path. A percent-encoded %0d%0a becomes a real carriage-return line-feed, and any line-oriented protocol will parse the result as separate commands:

None of this needs a memory bug or a race. It is a URL that decodes into a protocol conversation, which is why a gopher-capable client turns a modest SSRF into a serious one. The same instinct, an agent or a service fetching a URL you influence, is what makes machine clients that connect to arbitrary endpoints worth auditing for the same class of pivot.

What to do about it

The defense is not "stop using curl." libcurl is fine when its protocol scope is pinned. The failure is relying on defaults and letting user input choose the scheme.

Method and limits, so the numbers are trustworthy

The dataset is our own, built by observing sockets, and it is reproducible.

No third-party host was contacted at any point. Every connection in this research was to a listener we started on loopback, which is the difference between measuring a behavior and probing someone else's system.

Frequently asked questions

Does Python's requests library support the gopher protocol?

No. In a live test, requests raises InvalidSchema: No connection adapters were found for a gopher URL and never opens a socket. The same holds for httpx, aiohttp, urllib3, and the standard-library urllib. The one Python exception is pycurl, a thin binding over libcurl, which does open a gopher socket. If your Python code uses requests or httpx, a gopher SSRF payload cannot reach an internal service through it.

Why does curl support gopher but wget does not?

Different protocol sets. libcurl compiles in gopher, gophers, dict, ftp, and file by default, and its transfer allowlist enables them. wget was only built for http, https, and ftp, and wget2 is https-only. It is a build-time and design difference, not a runtime switch, which is why curl is the classic SSRF pivot and wget is not.

Which protocols does libcurl support for SSRF?

On a standard build, libcurl can open gopher, gophers, dict, ftp, ftps, file, tftp, ldap, and the mail protocols, on top of http and https. For SSRF the important ones are gopher and dict, because both put attacker-controlled bytes on a raw socket. gopher is the strongest, because the selector after the item-type character is sent verbatim, so an encoded CRLF becomes a real line break.

How does the gopher protocol escalate an SSRF to RCE?

An ordinary SSRF makes the server fetch an http URL. gopher turns that into sending arbitrary bytes to any reachable TCP port, because libcurl URL-decodes the selector and writes it raw. A single URL can send a Redis SET plus CONFIG SET dir and SAVE to drop a webshell, or a FastCGI record to php-fpm. The SSRF is the delivery, gopher is the payload, and a line-oriented internal service is the target.

Does Java's HttpURLConnection support gopher?

No. java.net.URL and HttpURLConnection register handlers for http, https, ftp, file, jar, and mailto only, and a gopher URL throws unknown protocol: gopher. The modern HttpClient, Apache HttpClient, and OkHttp reject any non-http scheme by design. Java is effectively immune to gopher SSRF, though java.net.URL still supports file://, which is a separate concern.

Why does libcurl not follow a redirect to a gopher:// URL?

Since libcurl 7.65.2 in May 2019, the default redirect allowlist is http, https, ftp, and ftps only, so a 302 into gopher:// is refused and no socket opens. Direct submission of a full gopher URL still works, because the transfer allowlist is separate. An application can re-open the redirect path by widening CURLOPT_REDIR_PROTOCOLS_STR, which is the class of bug behind CVE-2026-33752 in curl_cffi. On a modern target, place the whole gopher scheme rather than relying on an open-redirect.

How do you send CRLF to Redis through a gopher SSRF payload?

Encode the newlines. libcurl URL-decodes the selector, so gopher://127.0.0.1:6379/_SET%20k%20v%0d%0aSAVE%0d%0aQUIT sends SET k v, a real CRLF, SAVE, a real CRLF, then QUIT. In our test, _TESTPAYLOAD%0d%0aSECONDLINE arrived as the bytes TESTPAYLOAD\r\nSECONDLINE\r\n. Redis speaks a newline-delimited protocol, so that is a valid command sequence, which is why gopher plus Redis is the textbook SSRF-to-RCE chain.

Related reading

Would a gopher pivot work against you?

Our $100 check maps your real external attack surface the way an attacker does, on scope you have verified you own and authorized in writing, with a senior operator on the readout. Whether an internal Redis or php-fpm is one SSRF away from code execution is exactly the kind of chain we look for.

Book a $100 check