¿Qué librerías HTTP obtienen realmente una URL gopher://? Una matriz de SSRF probada empíricamente

Solo los clientes HTTP basados en libcurl abrirán realmente un socket gopher://, y ese único hecho decide si un server-side request forgery puede escalarse de "hacer que el servidor obtenga una URL" a "enviar bytes arbitrarios a cualquier servicio TCP interno". Probamos el cliente HTTP por defecto de diez lenguajes contra un listener gopher activo. Seis librerías cliente abrieron el socket, y cada una de ellas es un wrapper fino sobre libcurl: la línea de comandos curl, ext-curl de PHP, pycurl de Python, el handler curl de Guzzle, el crate curl de Rust y Faraday con el adaptador typhoeus. Todos los stacks HTTP nativos de lenguajes que probamos rechazaron gopher antes de abrir un socket: Go net/http, Java HttpURLConnection y HttpClient, Python requests, httpx, aiohttp y urllib3, Node http y fetch, Ruby Net::HTTP, .NET HttpClient, Rust reqwest y wget. La regla en una sola frase: gopher sobre SSRF es una historia de libcurl.
ext-curl está expuesta y una aplicación PHP sobre stream wrappers no lo está; una aplicación Ruby sobre Net::HTTP es segura y la misma aplicación sobre un adaptador typhoeus no lo es.
Por qué gopher es el esquema clave para SSRF
El server-side request forgery se describe habitualmente como engañar a una aplicación para que obtenga una URL elegida por el atacante. Por sí solo alcanza servicios HTTP internos, lo cual es grave pero limitado. gopher es lo que elimina ese límite. libcurl trata una URL gopher como gopher://host:port/<type><selector>, donde el primer carácter de la ruta es el tipo de elemento gopher y todo lo que le sigue se envía textualmente como el selector, tras decodificar la URL, seguido de CRLF. Debido a que los bytes se decodifican primero de la URL, un salto de línea con codificación porcentual en la ruta se convierte en un salto de línea real en el cable. Ese es todo el gadget. Convierte "obtener una URL" en "escribir cualquier secuencia de bytes en cualquier puerto TCP que el servidor pueda alcanzar", razón por la cual gopher junto con un Redis interno o php-fpm es la vía canónica desde un SSRF ciego hasta la ejecución de código. Es uno de los puntos más críticos de una superficie de ataque externa, y se oculta por completo en la elección del cliente HTTP.
La prueba: un socket real, no una suposición
La mayoría de las referencias afirman que "muchas librerías soportan gopher" y pasan directamente a los payloads. Queríamos observar si funcionaba o fallaba, no darlo por hecho, así que el método es deliberadamente simple. Para cada cliente iniciamos un listener TCP de un solo uso en localhost, le pedimos al cliente que obtenga gopher://127.0.0.1:PORT/_<payload>, y registramos si el listener llega a aceptar una conexión y qué bytes llegan. Un cliente que rechaza el esquema nunca se conecta, por lo que el listener no reporta nada. Un cliente que abre el socket nos entrega los bytes exactos transmitidos. Este es un experimento activo contra nuestros propios contenedores desechables y un listener en localhost, sin tocar ningún host de terceros. Aquí está la parte central contra curl, pycurl y PHP ext-curl, en tres versiones diferentes de 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)
Vale la pena extraer dos conclusiones directamente de esa salida. Primero, los clientes libcurl abren el socket en todas las versiones que probamos, desde una antigua 7.81.0 hasta una actual 8.21.0, por lo que no es una peculiaridad de una compilación concreta. Segundo, el gadget CRLF es real y exacto. Las filas de pycurl y PHP se obtuvieron con el selector de URL _PYCURL%0d%0aLINE2 y _PHPCURL%0d%0aLINE2, y los bytes recibidos contenían un retorno de carro y salto de línea genuinos entre los dos tokens. También lo confirmamos de forma aislada: la URL gopher://host:port/_TESTPAYLOAD%0d%0aSECONDLINE llega al socket como b'TESTPAYLOAD\r\nSECONDLINE\r\n'. Eso es un mensaje de protocolo de dos líneas escrito directamente en un servicio TCP mediante nada más que una URL.
La matriz
Las filas marcadas como live fueron reevaluadas contra un socket para esta publicación. El resto se verificaron a partir del código fuente, registros de protocolos y el manejo documentado de esquemas de cada cliente. El patrón no varía: la columna "abre un socket" coincide exactamente con el conjunto de bindings de libcurl, más un caso atípico modular en Perl.
| Cliente / librería | Lenguaje | Socket gopher:// | Por qué |
|---|---|---|---|
| curl CLI / libcurl (live) | C | SÍ | gopher, gophers, dict, ftp, file compilados por defecto; la allowlist de transferencia predeterminada los habilita |
| wget / wget2 (live) | C | no | solo http/https/ftp integrados; wget2 es solo https |
PHP ext-curl (curl_exec) (live) | PHP | SÍ | binding fino de libcurl; el sumidero clásico de SSRF a Redis/FastCGI |
PHP streams (file_get_contents) | PHP | no | solo wrappers registrados; sin wrapper para gopher/dict (file y ftp sí) |
| Guzzle (curl handler, por defecto) | PHP | SÍ | delega en ext-curl; el stream handler no |
| pycurl (live) | Python | SÍ | binding directo de libcurl; el mismo riesgo que PHP curl |
| requests (live) | Python | no | InvalidSchema: no connection adapters |
| httpx / aiohttp / urllib3 | Python | no | UnsupportedProtocol / NonHttpUrlClientError / URLSchemeUnknown |
| urllib (stdlib) (live) | Python | no | sin opener de gopher en Python 3 (file y ftp sí) |
| net/http | Go | no | unsupported protocol scheme "gopher"; solo round-trippers http/https |
| HttpURLConnection / HttpClient | Java | no | los handlers son http/https/ftp/file/jar/mailto; unknown protocol: gopher |
| Apache HttpClient 4/5 / OkHttp | Java | no | el registro de esquemas es solo http/https por diseño |
| Net::HTTP | Ruby | no | específico de http/https; OpenURI envía gopher a File.open (lectura local, no un pivot TCP) |
| Faraday | Ruby | no* | sobre Net::HTTP; el adaptador typhoeus (libcurl) es la excepción que alcanza gopher |
| http / https / undici / fetch (live) | Node.js | no | específico por protocolo; WHATWG fetch es solo http/https/file/data/blob |
| HttpClient / HttpWebRequest | .NET | no | solo http/https; el GopherWebRequest heredado fue eliminado en .NET Core |
| reqwest / ureq | Rust | no | stacks hyper y en puro Rust, solo http/https |
| curl crate | Rust | SÍ | binding directo de libcurl, misma advertencia que cualquier otro binding |
| LWP::UserAgent | Perl | SÍ* | solo si LWP::Protocol::gopher está instalado; LWP es modular por esquema |
Cuente las filas con SÍ y la conclusión es ineludible. Seis librerías cliente abren un socket gopher, y las seis son libcurl: curl, PHP ext-curl, pycurl, Guzzle-curl, el crate curl de Rust y Faraday sobre typhoeus. El séptimo y único positivo que no es curl es LWP::UserAgent de Perl, e incluso ese requiere que un operador haya instalado el plugin opcional LWP::Protocol::gopher, por lo que está desactivado por defecto. Todo lo demás, cada stack HTTP nativo convencional en Go, Java, Python, Node, Ruby, .NET y Rust, rechaza el esquema antes de abrir una conexión. No hay payload ingenioso que cambie eso, porque el rechazo ocurre en el parser de URL o en el registro del adaptador, antes de cualquier socket.
Matiz uno: la redirección hacia gopher está desactivada por defecto
"La aplicación usa curl, así que es explotable" es solo una verdad a medias en una compilación moderna, y la mitad que falta es donde se pierde tiempo en un engagement. Hay dos allowlists separadas en libcurl. La allowlist de transferencia rige una URL que se le pasa directamente y todavía habilita gopher. La allowlist de redirección rige a qué esquemas puede cambiar una cabecera Location:, y desde libcurl 7.65.2 en mayo de 2019 se limita únicamente a http, https, ftp y ftps. Por lo tanto, un open-redirect que apunte a gopher:// se rechaza por defecto. Lo comprobamos en 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'
La redirección solo llega a gopher si la propia aplicación volvió a ampliar los protocolos de redirección, configurando CURLOPT_REDIR_PROTOCOLS_STR para incluir gopher o pasando a la CLI --proto-redir all. Esa mala configuración no es hipotética. CVE-2026-33752 es exactamente esta clase de fallo: la librería curl_cffi seguía redirecciones hacia protocolos arbitrarios porque no limitaba la allowlist de redirección, convirtiendo un simple SSRF sobre http de nuevo en un pivot completo mediante gopher. La conclusión práctica para un atacante es que en un objetivo con libcurl actual necesita que el SSRF le permita colocar el esquema completo, una URL gopher:// directa, en lugar de depender de un open-redirect. La conclusión práctica para un defensor es que ampliar los protocolos de redirección es un arma de doble filo, y el valor por defecto que lo desarma ha sido el correcto desde 2019.
Matiz dos: el payload es CRLF, y es trivial
La razón por la que gopher importa más que dict:// o file:// es el control a nivel de bytes. libcurl envía la porción del selector de la URL como bytes en bruto tras la decodificación de la URL, por lo que el atacante compone el tráfico que irá al cable en la ruta de la URL. Un %0d%0a con codificación porcentual se convierte en un retorno de carro y salto de línea real, y cualquier protocolo orientado a líneas procesará el resultado como comandos independientes:
- Redis. Una sola URL gopher puede enviar
SET, luegoCONFIG SET diryCONFIG SET dbfilename, luegoSAVE, escribiendo una webshell, una entrada de cron o un archivo authorized_keys en el disco. Esta es la cadena que generan los generadores públicos de payloads. - FastCGI (php-fpm en el puerto 9000). Un registro FastCGI manipulado con
PHP_VALUEoSCRIPT_FILENAMElogra la ejecución de código contra un php-fpm vinculado únicamente a localhost. - memcached, SMTP, Postgres, Zabbix. Cualquier protocolo orientado a líneas o con prefijo de longitud accesible desde el origen del SSRF es un candidato, porque se controlan los bytes por completo.
Nada de esto requiere un fallo de memoria o una race condition. Es una URL que se decodifica en una conversación de protocolo, por lo que un cliente con soporte para gopher convierte un SSRF modesto en uno grave. El mismo instinto, un agente o un servicio que obtiene una URL sobre la que usted influye, es lo que hace que los clientes automatizados que se conectan a endpoints arbitrarios merezcan ser auditados para la misma clase de pivot.
Qué hacer al respecto
La defensa no es "dejar de usar curl". libcurl funciona bien cuando su alcance de protocolos está fijado. El fallo consiste en confiar en los valores por defecto y permitir que la entrada del usuario elija el esquema.
- Fije la allowlist de esquemas explícitamente. Para libcurl, ext-curl y pycurl establezca
CURLOPT_PROTOCOLS_STR = "http,https"y nunca amplíeCURLOPT_REDIR_PROTOCOLS_STRmás allá dehttp,https. En la CLI:--proto -all,http,https --proto-redir -all,http,https. - Nunca permita que la entrada del usuario controle el esquema de la URL. Acepte un host y una ruta, establezca de forma fija
https://y rechace cualquier otra cosa. La mayoría de las cadenas de SSRF a gopher mueren aquí. - Trate una redirección hacia un esquema que no sea http como un fallo crítico, y vuelva a validar en el lado del servidor el esquema resuelto tras cada salto de redirección, no solo en la URL de entrada.
- Prefiera un cliente exclusivo para http para peticiones que no requieran múltiples protocolos. Go
net/http, JavaHttpClienty Pythonrequestsohttpxle brindan inmunidad frente a gopher, dict y file de forma gratuita. - Elimine CRLF en bruto y codificado (
%0d,%0a) de cualquier ruta o query de URL saliente influenciada por el usuario. - Aplique filtrado de salida (egress filtering) en la capa de red para que incluso un pivot exitoso no pueda alcanzar un servicio interno: bloquee 6379, 11211, 9000, 25 y 5432 en la salida de la aplicación, y vincule los servicios internos a localhost con autenticación.
- Audite los usuarios indirectos de libcurl que oculta su stack: el handler curl de Guzzle, Faraday en typhoeus, el crate curl de Rust y Perl LWP con el plugin gopher tienen la misma exposición que PHP curl, y son fáciles de pasar por alto en un grafo de dependencias.
Método y límites, para que las cifras sean fiables
El conjunto de datos es nuestro, construido observando sockets, y es reproducible.
- Cómo probamos. Las filas live se ejecutaron en contenedores desechables (php:8.3-cli, python:3.12-slim) y en un host Linux estándar, contra un listener TCP en localhost
python3que reporta la conexión y los primeros bytes recibidos. Se evaluaron tres versiones de libcurl: 7.81.0, 8.14.1 y 8.21.0. - Qué significa "no". Un "no" es un cliente que nunca abre el socket, porque rechaza el esquema en el parser de URL, en el registro del adaptador o en el manejador del protocolo. Esto es más seguro que una conexión que se abre y luego se cierra, porque los bytes del atacante nunca salen del proceso.
- Las filas con asterisco. Faraday y Perl LWP dependen del adaptador configurado o del plugin instalado, por lo que llevan un asterisco: la configuración por defecto es segura, una configuración específica no.
- Alcance. Esto trata sobre si se abre un socket para gopher, lo que determina el pivot de SSRF. No dice nada sobre el SSRF basado en http, que todos los clientes aquí ejecutan, ni sobre la exposición a
file://, queURLde Java y algunos otros todavía conservan.
En ningún momento se contactó con un host de terceros. Cada conexión en esta investigación fue a un listener que iniciamos en loopback, lo que marca la diferencia entre medir un comportamiento y sondear el sistema de otra persona.
Preguntas frecuentes
¿Soporta la librería requests de Python el protocolo gopher?
No. En una prueba en vivo, requests lanza InvalidSchema: No connection adapters were found para una URL gopher y nunca abre un socket. Lo mismo ocurre con httpx, aiohttp, urllib3, y la librería estándar urllib. La única excepción en Python es pycurl, un binding fino sobre libcurl, que sí abre un socket gopher. Si su código Python usa requests o httpx, un payload de SSRF con gopher no puede alcanzar un servicio interno a través de él.
¿Por qué curl soporta gopher pero wget no?
Conjuntos de protocolos diferentes. libcurl compila gopher, gophers, dict, ftp y file por defecto, y su allowlist de transferencia los habilita. wget solo se construyó para http, https y ftp, y wget2 es solo https. Es una diferencia de diseño y de tiempo de compilación, no un ajuste en tiempo de ejecución, por lo que curl es el clásico pivot de SSRF y wget no.
¿Qué protocolos soporta libcurl para SSRF?
En una compilación estándar, libcurl puede abrir gopher, gophers, dict, ftp, ftps, file, tftp, ldap y los protocolos de correo, además de http y https. Para SSRF los importantes son gopher y dict, porque ambos colocan bytes controlados por el atacante en un socket directo. gopher es el más potente, porque el selector tras el carácter de tipo de elemento se envía textualmente, por lo que un CRLF codificado se convierte en un salto de línea real.
¿Cómo escala el protocolo gopher un SSRF a RCE?
Un SSRF ordinario hace que el servidor obtenga una URL http. gopher convierte eso en enviar bytes arbitrarios a cualquier puerto TCP accesible, porque libcurl decodifica la URL del selector y la escribe en bruto. Una sola URL puede enviar un comando SET de Redis junto con CONFIG SET dir y SAVE para depositar una webshell, o un registro FastCGI a php-fpm. El SSRF es la vía de entrega, gopher es el payload y un servicio interno orientado a líneas es el objetivo.
¿Soporta HttpURLConnection de Java el protocolo gopher?
No. java.net.URL y HttpURLConnection registran handlers únicamente para http, https, ftp, file, jar y mailto, y una URL gopher lanza unknown protocol: gopher. El moderno HttpClient, Apache HttpClient y OkHttp rechazan cualquier esquema que no sea http por diseño. Java es efectivamente inmune a SSRF con gopher, aunque java.net.URL todavía soporta file://, lo cual es una preocupación independiente.
¿Por qué libcurl no sigue una redirección a una URL gopher://?
Desde libcurl 7.65.2 en mayo de 2019, la allowlist de redirección por defecto incluye únicamente http, https, ftp y ftps, por lo que un 302 hacia gopher:// es rechazado y no se abre ningún socket. El envío directo de una URL gopher completa sigue funcionando, porque la allowlist de transferencia es independiente. Una aplicación puede reabrir la vía de redirección ampliando CURLOPT_REDIR_PROTOCOLS_STR, que es la clase de fallo detrás de CVE-2026-33752 en curl_cffi. En un objetivo moderno, incluya el esquema gopher completo en lugar de confiar en un open-redirect.
¿Cómo se envía CRLF a Redis a través de un payload SSRF con gopher?
Codifique los saltos de línea. libcurl decodifica la URL del selector, por lo que gopher://127.0.0.1:6379/_SET%20k%20v%0d%0aSAVE%0d%0aQUIT envía SET k v, un CRLF real, SAVE, un CRLF real, y luego QUIT. En nuestra prueba, _TESTPAYLOAD%0d%0aSECONDLINE llegó como los bytes TESTPAYLOAD\r\nSECONDLINE\r\n. Redis utiliza un protocolo delimitado por saltos de línea, por lo que es una secuencia de comandos válida, razón por la cual gopher más Redis es la cadena clásica de manual de SSRF a RCE.
Lecturas relacionadas
- ¿Qué es una superficie de ataque externa? Dónde se ubica un SSRF en el mapa que un atacante construye sobre lo que expone su organización.
- Seguridad en servidores MCP remotos: la mitad del registro es el servidor de otra persona. El mismo patrón de "un cliente obtiene una URL en la que usted influye", en la capa de agentes automatizados.
- Revisión frente a escaneo frente a pentest. Por qué un hallazgo encadenado como SSRF a gopher y a RCE es lo que una prueba real saca a la luz y un escáner no.
¿Funcionaría un pivot con gopher contra usted?
Nuestra revisión de $100 mapea su superficie de ataque externa real tal como lo hace un atacante, sobre un alcance cuya propiedad ha verificado y autorizado por escrito, con un operador sénior en la sesión de resultados. Si un Redis interno o php-fpm está a un SSRF de distancia de la ejecución de código es exactamente el tipo de cadena que buscamos.
Contratar una revisión de $100