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.
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.
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.
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:
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.
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:
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 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.
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.
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"
);
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:
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.
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
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.
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:
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');
}
21. A mentalidade que tiro desse relatório
A principal lição é não procurar apenas nomes de vulnerabilidades. É mais produtivo acompanhar capacidades.
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
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