padi · pwnz vulnerability researcher ⚔️

DOM-based XSS via window.location.href sink - $550

XSS alert

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 S vem 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 = param ao 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!