DOM-based XSS via window.location.href sink - $550
![]()
Introduction
Nesse post venho contar como identifiquei, reportei e fui pago por uma vulnerabilidade que encontrei apenas utilizando o devtools do navegador e lendo os arquivos javascript da aplicação.
Recon
Aplicações web modernas são ricas em código client-side. Bibliotecas/frameworks tipo React, Vue, Angular e etc proporcionam que várias interações sejam feitas puramente ou majoritariamente no lado do cliente fazendo com que exista uma superfície gigantesca de código para ser analisada.
Ao acessar a aplicação percebi que ela utilizava React, utilizando o devtools encontrei o seguinte trecho de código:
// #region Partner-Route "LineMan"
<Route exact path='/lineman' component={ZPartner02} />
<Route exact path='/lineman/error' component={ZPartner02Error} />
<Route exact path='/lineman/home' component={ZPartner02Home} />
<Route exact path='/lineman/xtest' component={ZPartner02Xtest} />
<Route exact path='/lineman/membersignin' component={ZPartner02MemberSignin} />
<Route exact path='/lineman/membersigninsuccess' component={ZPartner02MemberSigninSuccess} />
<Route exact path='/lineman/membernew' component={ZPartner02MemberNew} />
<Route exact path='/lineman/membernewthx' component={ZPartner02MemberNewStep01Thx} />
<Route exact path='/lineman/membenew_doactionreg' component={ZPartner02MemberNewStep02} />
<Route exact path='/lineman/membernewsuccess' component={ZPartner02MemberNewStep02Thx} />
<Route exact path='/lineman/membenew_doactionreg_20' component={ZPartner02MemberNewStep02SR20} />
{/* Default Route */}
<Redirect to="/"
Esse trecho já revela informações úteis:
Primeiro, é possível identificar que a aplicação utiliza React Router para controlar a navegação. Além disso, a própria estrutura das rotas indica a existência de uma integração com o LINE MAN - uma plataforma tailandesa - contendo páginas para autenticação, criação de conta, mensagens de sucesso e tratamento de erros. Cada componente continha um arquivo JavaScript responsável por lidar com a lógica do endpoint.
Dentro da aba sources no devtools ficou bem fácil de identificar cada um desses arquivos .js dos componentes:
/static/js/components/Zpartner02.js
/static/js/components/ZPartner02Error.js
/static/js/components/ZPartner02Home.js
/static/js/components/ZPartner02Xtest.js
/static/js/components/ZPartner02MemberSignin.js
/static/js/components/ZPartner02MemberSigninSuccess.js
/static/js/components/ZPartner02MemberNew.js
/static/js/components/ZPartner02MemberNewStep01Thx.js
/static/js/components/ZPartner02MemberNewStep02.js
/static/js/components/ZPartner02MemberNewStep02Thx.js
/static/js/components/ZPartner02MemberNewStep02SR20.js
Depois de abrir um por um — eu gosto de ler código '-' — cheguei ao arquivo com o código vulnerável.
Code Review
Ao acessar o arquivo /static/js/components/ZPartner02MemberSigninSuccess.js era possível encontrar o seguinte código vulnerável - deixei so a parte importante do código:
render() {
const query = new URLSearchParams(this.props.location.search);
const param = decodeURIComponent(query.get("S") || "");
return (
{<a}
{ href={param}}
{ className="btn btn-primary rounded-pill px-5"}
{ onClick={(e) => {}
{ e.preventDefault();}
{ if (!param) return;}
{ // force open app scheme}
{ window.location.href = param;}
{ }}}
{>}
{ กลับไปยัง LINE MAN<br />}
{ <small style={{ fontSize: '90%' }}>Back to LINE MAN</small>}
{</a>}
Fazendo uma análise bem simples do trecho acima seguindo a ideia de source -> sink, podemos chegar na seguinte conclusão:
Source: O parâmetro
Svem da query string da URL — ou seja, podemos controlar o valor:
const query = new URLSearchParams(this.props.location.search);
const param = decodeURIComponent(query.get("S") || "");
- O `decodeURIComponent` decodifica, mas **não** faz a validação de nada.
Sink: O valor que controlamos é usado diretamente pelo (sink)
window.location.href = paramao clicar no botão presente no código:
{<a}
{ href={param}}
{ className="btn btn-primary rounded-pill px-5"}
{ onClick={(e) => {}
{ e.preventDefault();}
{ if (!param) return;}
{ // force open app scheme}
{ window.location.href = param;}
{ }}}
{>}
{ กลับไปยัง LINE MAN<br />}
{ <small style={{ fontSize: '90%' }}>Back to LINE MAN</small>}
{</a>}
A falha tá aqui: window.location.href aceita o pseudo-protocolo javascript: como valor. Se o valor passado pelo parâmetro S for, por exemplo, javascript:alert(1) o navegador executa o código quando a vítima clicar no botão presente na página. Sem sanitização, apenas um DOM-xss dando sopa.
Ou seja, o clássico -> input controlado pelo usuário é utilizado sem sanitização de forma direta em um sink perigoso.
XSS em aplicações React?
É comum ouvir que "React protege contra XSS". Isso é verdade... até certo ponto.
O React faz escaping automático dos dados renderizados pelo JSX. Por exemplo:
const userInput = "<img src=x onerror=alert(1)>";
return <div>{userInput}</div>;
Em vez de interpretar esse conteúdo como HTML o React renderiza a string como texto. Dessa forma, o navegador nunca chega a interpretar a tag <img>, impedindo que o código JavaScript seja executado. Essa proteção, entretanto, existe apenas durante a renderização dos componentes.
No código vulnerável, o problema não estava no JSX mas na seguinte chamada:
window.location.href = param;
Nesse ponto a navegação passa a ser responsabilidade da API window.location implementada pelo próprio navegador.
Se param vier da URL e contiver um protocolo como javascript:, é o navegador quem decidirá como tratar esse valor. O React não faz qualquer validação sobre o argumento passado para window.location.href.
O framework não impede o uso inseguro das APIs nativas do navegador.
Payload final
A payload final foi bem simples. Depois de identificar o endpoint e o parâmetro vulnerável e também identificar que não ocorre nenhum tipo de sanitização basta criar o seguinte exploit:
https://padipw.nz/lineman/membersigninsuccess?S=javascript:alert(document.domain)
A vítima acessa o link, a página carrega normalmente. Quando ela clica em "กลับไปยัง LINE MAN / Back to LINE MAN":
| Etapa | O que acontece |
|---|---|
URLSearchParams |
Extrai S=javascript:... da URL |
decodeURIComponent |
Decodifica |
window.location.href = param |
O navegador reconhece javascript: e executa |
E aparece a famosa janelinha de alerta. Infelizmente não consegui escalar essa vulnerabilidade porque precisava de KYC de outro país :(
FIM / Timeline
Ao todo demorou cerca de 15 dias para o programa corrigir, eu fazer o retest e ser pago com $550 doláres.
Obrigado por ler!