web / desktop / exploit chain

Do XSS ao RCE no Steam Chat

Uma leitura didática da cadeia envolvendo React, BBCode, OEmbed, CEF, custom URI, mudança de privilégio e execução nativa.

Nota: os trechos PHP deste artigo são exemplos didáticos criados para explicar a causa raiz. Eles não são código-fonte do Steam. A vulnerabilidade original não é atribuída a mim; aqui eu estudo, reconstruo conceitualmente e valido o entendimento técnico da cadeia.

Referência original: HackerOne Report #409850 ↗

O ponto mais interessante desse caso é que o pesquisador não encontrou uma linha simples do tipo entrada → system() → RCE. O impacto surgiu do encadeamento de várias capacidades menores: uma entrada controlável, transformação em link, protocolos especiais, um navegador embutido e uma ponte privilegiada capaz de alcançar o sistema operacional.

entrada controlada ↓ React / BBCode / OEmbed ↓ URL controlada ↓ CEF ↓ custom URI ↓ Steam Web Helper ↓ operação privilegiada ↓ Windows ↓ RCE

1. Primeiro: entender a arquitetura

O chat do Steam era uma aplicação web executada dentro do aplicativo desktop usando uma versão personalizada e restrita do Chrome Embedded Framework (CEF). A interface usava React, mas precisava conversar com partes nativas do Steam por mecanismos como postMessage, WebSocket local e outras interfaces internas.

Isso cria uma fronteira importante: JavaScript dentro do CEF não deveria possuir, por padrão, o mesmo privilégio do processo nativo. Para transformar uma falha web em RCE, o pesquisador precisava encontrar alguma ponte entre esses dois mundos.

React / JavaScript ↓ CEF ↓ [ trust boundary ] ↓ Steam Web Helper ↓ Windows

2. Por que React reduzia os XSS triviais?

React normalmente escapa valores renderizados como texto. Isso significa que uma entrada colocada diretamente entre chaves em JSX não vira automaticamente HTML executável.

function Profile({ username }) {
    return <div>{username}</div>;
}

Se username contiver uma string parecida com uma tag HTML, o comportamento esperado é tratá-la como texto, em vez de transformá-la automaticamente em um novo elemento DOM. Por isso o pesquisador não ficou procurando apenas o padrão clássico de “fechar a tag e inserir <script>”.

3. React não elimina XSS

A proteção automática do React não torna seguras todas as APIs do navegador. Existem sinks e contextos que continuam exigindo validação específica.

userInput ├── JSX como texto → normalmente escapado ├── href → validar o esquema da URI ├── document.location → validar destino ├── style → analisar contexto CSS ├── innerHTML → sink perigoso └── dangerouslySetInnerHTML → sink perigoso

href controlado

function Link({ url }) {
    return <a href={url}>Clique aqui</a>;
}

Aqui o React não precisa “quebrar” o HTML para existir um problema. Se o atacante controla o valor semântico da URL e a aplicação permite um esquema perigoso, o próprio href pode se tornar uma primitive interessante.

dangerouslySetInnerHTML

function Message({ html }) {
    return (
        <div dangerouslySetInnerHTML={{ __html: html }} />
    );
}

Nesse caso o conteúdo deixa de ser apenas texto e passa a ser interpretado como HTML. O mesmo raciocínio vale para código que obtém uma referência de DOM e escreve diretamente em innerHTML.

document.location

document.location = userInput;

React não transforma APIs como document.location em APIs seguras. Se o input controla uma URI, o esquema e o destino continuam precisando de validação.

4. BBCode: a camada intermediária

O chat utilizava BBCode, uma linguagem de marcação simplificada. Uma tag como:

[url=https://example.com]Visitar site[/url]

pode ser transformada em:

<a href="https://example.com">Visitar site</a>

O ponto de segurança é a transformação:

entrada do usuário ↓ BBCode ↓ parser ↓ React / HTML ↓ DOM

5. Como esse erro poderia aparecer em PHP

Um parser simplificado e vulnerável poderia ser:

<?php

function parseBBCode(string $message): string
{
    return preg_replace(
        '/\[url=(.*?)\](.*?)\[\/url\]/i',
        '<a href="$1">$2</a>',
        $message
    );
}

$message = $_POST['message'] ?? '';

echo parseBBCode($message);

O problema é que o valor capturado em $1 entra diretamente no atributo href.

SOURCE $_POST["message"] ↓ [url=VALOR] ↓ parser ↓ SINK href="VALOR"

6. O XSS baseado em URI

Em um parser que aceita qualquer esquema, uma entrada com uma URI executável poderia ser transformada em um link perigoso. O ponto não é necessariamente quebrar o atributo ou inserir uma tag <script>; é controlar o significado do próprio href.

[url=javascript:alert(document.domain)]Clique[/url]

Conceitualmente isso poderia produzir:

<a href="javascript:alert(document.domain)">Clique</a>

Essa era uma das classes de primitive investigadas no relatório: controlar o destino de um link.

7. A primitive: “eu controlo uma URL”

Essa é uma mudança importante de mentalidade. Em vez de pensar apenas “achei XSS”, o pesquisador pode pensar:

capacidade conquistada: "eu controlo o destino de uma URL" pergunta seguinte: "quem mais interpreta essa URL?"

Mesmo quando o caminho por javascript: não produziu o resultado desejado no cliente customizado do Steam, a primitive continuava valiosa.

8. Custom URI schemes

Uma URL não precisa começar com https://. Aplicativos podem registrar protocolos próprios, como:

steam://
spotify://
vscode://
myapp://

Quando um navegador embutido encontra uma dessas URIs, ela pode ser encaminhada para uma aplicação nativa.

<a href="myapp://settings"> ↓ CEF ↓ handler do protocolo ↓ MyApp.exe

A partir desse momento, o dado saiu do contexto puramente web e atravessou uma trust boundary.

9. O protocolo steam://

O pesquisador passou a investigar URIs do próprio Steam. O interesse aqui não era apenas “abrir alguma tela”, mas descobrir quais operações o cliente desktop processava e com qual nível de privilégio.

mensagem ↓ BBCode ↓ href controlável ↓ steam://... ↓ Steam

Isso mostra por que uma primitive de URL pode ser muito mais relevante dentro de CEF/Electron do que em uma página web isolada.

10. OEmbed e conteúdo incorporado

O chat também transformava links externos em conteúdo incorporado. Serviços de OEmbed podem fornecer metadados e, em alguns casos, HTML para montar players, cards ou iframes.

URL ↓ OEmbed ↓ HTML / iframe / componente ↓ DOM

Isso cria outra superfície importante, especialmente quando uma aplicação React precisa recorrer a dangerouslySetInnerHTML, innerHTML ou componentes especiais para renderizar conteúdo externo.

11. Tentativa com postMessage

Como conteúdos incorporados podiam rodar em contextos diferentes, uma hipótese era reproduzir mensagens que a aplicação legítima utilizava para conversar entre janelas.

window.top.postMessage(
    { action: "algumaAcao" },
    "https://destino-esperado.example"
);
iframe / embed ↓ postMessage ↓ janela principal ↓ possível operação privilegiada

Nesse caminho específico, o sandbox impedia o acesso necessário. A proteção funcionou. Mas isso não anulava as outras primitives já encontradas.

12. Diferenças de privilégio são pistas

O pesquisador observou também que algumas janelas abertas pelo ambiente possuíam funções que outras não possuíam. Isso revela uma pergunta extremamente útil em pesquisa:

Contexto A privilégio baixo ↓ consigo alcançar? ↓ Contexto B privilégio maior

Uma vulnerabilidade crítica pode surgir quando uma entrada de um contexto não confiável consegue atingir outro contexto com permissões maiores.

13. Protocol handlers do Windows

A investigação então desceu mais uma camada: como o Windows associa protocolos e tipos de arquivos a programas.

URI ↓ Windows identifica o handler ↓ programa associado ↓ argumentos

Mesmo provocar uma ação aparentemente simples no sistema operacional já prova algo importante: o input remoto conseguiu atravessar a fronteira do navegador.

14. Path traversal como primitive adicional

Durante os testes com handlers, a resolução de caminhos mostrou comportamento que podia ser influenciado com sequências de traversal como ../. Isso permitiu chegar a um cenário em que um arquivo local existente poderia ser acionado. Ainda havia uma limitação importante: o arquivo precisava existir previamente na máquina.

Novamente, a conclusão não era “acabou”. Era: agora tenho mais uma primitive; existe outra ponte que remove essa limitação?

15. A descoberta crítica nos logs

A grande virada aconteceu ao observar o console do Steam. O pesquisador percebeu que operações externas eram traduzidas internamente para uma estrutura semelhante a:

steam://openexternalforpid/<PID>/<URI>

Essa observação revelou a função interna que estava por trás do comportamento. Em vez de continuar adivinhando protocolos, agora ele sabia qual mecanismo privilegiado o Steam utilizava.

16. openexternalforpid como ponte privilegiada

target controlado ↓ steam://openexternalforpid/... ↓ Steam Web Helper ↓ operação nativa ↓ Windows

A importância está na mudança de privilégio: conteúdo originado em uma interface web conseguia chegar a uma função criada para realizar uma operação nativa.

17. Como representar a causa raiz em PHP

Imagine uma aplicação fictícia com um handler interno. O código abaixo é apenas uma representação didática:

<?php

function handleUri(string $uri): void
{
    $prefix = 'myapp://openexternal/';

    if (!str_starts_with($uri, $prefix)) {
        return;
    }

    $target = substr($uri, strlen($prefix));

    openExternal($target);
}

A pergunta importante passa a ser: o que openExternal() permite?

<?php

function openExternal(string $target): void
{
    /*
     * Anti-padrão didático.
     * Entrada não confiável nunca deveria controlar
     * diretamente uma primitive de execução.
     */

    // shell_exec($target);
}

A linha perigosa está propositalmente comentada. O objetivo é entender a causa raiz, não criar um executor funcional.

conteúdo remoto ↓ $target ↓ função privilegiada ↓ processo do sistema

18. Da falha web ao RCE

O RCE não veio simplesmente de “um XSS que chamou cmd.exe”. A cadeia foi formada por múltiplas interpretações e mudanças de contexto:

ATACANTE ↓ mensagem ↓ React / BBCode ↓ controle de URL ↓ custom URI ↓ CEF ↓ Steam Web Helper ↓ função privilegiada ↓ processo nativo ↓ Windows ↓ RCE

Esse é o padrão que vale memorizar: uma primitive web aparentemente limitada pode ganhar impacto ao atravessar uma fronteira para um componente com mais privilégios.

19. Corrigindo o problema na origem

Um parser de links deve validar o significado da URL, não apenas escapar caracteres HTML.

<?php

function createSafeLink(string $url, string $text): string
{
    $scheme = strtolower(
        (string) parse_url($url, PHP_URL_SCHEME)
    );

    if (!in_array($scheme, ['http', 'https'], true)) {
        return htmlspecialchars(
            $text,
            ENT_QUOTES | ENT_SUBSTITUTE,
            'UTF-8'
        );
    }

    $safeUrl = htmlspecialchars(
        $url,
        ENT_QUOTES | ENT_SUBSTITUTE,
        'UTF-8'
    );

    $safeText = htmlspecialchars(
        $text,
        ENT_QUOTES | ENT_SUBSTITUTE,
        'UTF-8'
    );

    return '<a href="' . $safeUrl . '">' .
           $safeText .
           '</a>';
}

Aqui existem duas proteções distintas: escaping para o contexto HTML e validação semântica do esquema permitido.

20. Protegendo a bridge privilegiada

Uma interface nativa não deveria aceitar uma operação genérica como “abra qualquer coisa que essa string mandar”. É mais seguro expor ações pequenas, estruturadas e explicitamente permitidas.

<?php

$allowedActions = [
    'open_settings',
    'show_profile',
    'open_help'
];

if (!in_array($action, $allowedActions, true)) {
    throw new RuntimeException('Invalid action');
}
ruim: "execute qualquer target" melhor: "execute uma das ações conhecidas e predefinidas"

21. A mentalidade que tiro desse relatório

A principal lição é não procurar apenas nomes de vulnerabilidades. É mais produtivo acompanhar capacidades.

O que eu controlo? ↓ Onde esse dado chega? ↓ Quem interpreta depois? ↓ Existe uma transformação? ↓ Estou atravessando uma trust boundary? ↓ O próximo componente tem mais privilégio? ↓ Que nova primitive eu conquistei? ↓ Posso encadear com outra?

O pesquisador poderia ter parado em “consigo controlar uma URL”. Em vez disso, perguntou quais consumidores existiam para aquela URL, quais protocolos eram aceitos, quem os processava e até onde esse processamento chegava.

22. Fórmula mental

Primitive + Trust Boundary + Privilege Difference + Chaining = Impacto elevado

Esse modelo é útil não só para CEF, mas também para Electron, WebViews, extensões de navegador e qualquer aplicação híbrida em que uma interface web converse com componentes nativos.

Conclusão

React reduzia os XSS triviais, mas não eliminava sinks perigosos. BBCode e OEmbed criavam transformações adicionais. O controle de URL virou uma primitive. Custom URI permitiu atravessar o contexto web. A descoberta de uma função privilegiada interna mostrou como alcançar o ambiente nativo. O RCE apareceu no final dessa cadeia, e não como um único erro isolado.

A pergunta que mais resume o relatório é: “Eu controlo alguma coisa. Quem interpreta isso depois, e esse próximo componente consegue fazer algo que eu não consigo?”

Referência

Relatório original no HackerOne: https://hackerone.com/reports/409850 ↗

← voltar