
Aviso de Segurança: Cross-Site Scripting Armazenado Não Autenticado Levando à Tomada de Conta de Administrador (openclaw-dashboard)
Título: OpenClaw Dashboard v3.0.0 XSS Armazenado via Campo de Nome de Usuário de Login Falho ID de CVE atribuído: CVE-2026-66418
Repositório alvo: https://github.com/tugcantopaloglu/openclaw-dashboard
O endpoint de login registra o nome de usuário submetido no log de auditoria sem qualquer validação. O painel de notificações depois lê essas entradas de log e as escreve na página com innerHTML e sem escape. Um atacante que não consegue fazer login ainda pode enviar uma solicitação de login falha cujo nome de usuário seja um payload de script. Na próxima vez que o administrador logado abrir o sino de notificações, esse payload será executado no navegador dele, na origem do dashboard, com acesso ao token de sessão.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:N/SC:H/SI:H/SA:NIntroduzido na v3.0.0, que adicionou a central de notificações que renderiza as entradas do log de auditoria. Presente na v3.0.0 e em todos os commits posteriores até e incluindo o main atual (d6198d0). Não corrigido no momento em que este texto foi escrito.
O atacante é uma parte remota que consegue alcançar a porta HTTP do dashboard, mas não tem conta nem credenciais válidas. Isso corresponde à tela de login ser acessível por qualquer pessoa que possa abrir o dashboard. A única pré-condição é que uma conta de administrador já exista, o que é verdade para qualquer implantação após a configuração inicial.
O payload é armazenado, portanto não é necessária coordenação de tempo. Ele é executado quando o administrador abre o painel de notificações, que é uma ação normal oferecida pela interface. Uma vez executado, ele tem as mesmas capacidades que o navegador do administrador: pode ler o token de sessão e chamar qualquer endpoint autenticado em nome da vítima.
Passo 1. O nome de usuário é registrado literalmente. Um login com um nome de usuário que não corresponde à conta registrada chega a este trecho:
// server.js:1577-1583
if (username !== creds.username) {
recordFailedAuth(ip);
auditLog('login_failed', ip, { username });
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid username or password' }));
return;
}
username vem diretamente do corpo da solicitação JSON. Não há limite de comprimento, lista de caracteres permitidos ou verificação de tipo. auditLog o escreve em disco como uma linha JSON:
// server.js:278-282
function auditLog(event, ip, details = {}) {
try {
const timestamp = new Date().toISOString();
const entry = JSON.stringify({ timestamp, event, ip, ...details }) + '\n';
fs.appendFileSync(auditLogPath, entry, 'utf8');
JSON.stringify escapa aspas e quebras de linha, então o payload permanece em uma única linha e é analisado de volta corretamente. Ele não escapa <, > ou /, portanto a marcação HTML sobrevive intacta.
Passo 2. O log é lido de volta. O endpoint de notificações retorna linhas de log recentes ao navegador:
// server.js:2042-2051
if (req.url.startsWith('/api/notifications')) {
if (!requireAuth(req, res)) return;
const limit = parseInt(new URL(req.url, 'http://localhost').searchParams.get('limit') || '50');
try {
const raw = fs.readFileSync(auditLogPath, 'utf8').trim();
const lines = raw.split('\n').filter(Boolean).slice(-Math.min(limit, 200));
const events = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean).reverse();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ events }));
Passo 3. O nome de usuário é escrito no DOM sem escape. O front-end constrói cada linha de notificação por concatenação de strings e a atribui com innerHTML:
// index.html:5647-5653
body.innerHTML = data.events.map(e => {
const icon = notifIcons[e.event] || '📋';
const time = e.timestamp ? new Date(e.timestamp).toLocaleString() : '';
const detail = e.username ? ' (' + e.username + ')' : '';
const ip = e.ip ? ' from ' + e.ip : '';
return '<div class="notif-item"><div class="notif-icon">' + icon + '</div><div class="notif-content"><div class="notif-event">' + (e.event||'').replace(/_/g, ' ') + detail + ip + '</div><div class="notif-time">' + time + '</div></div></div>';
}).join('');
e.username é a string do atacante. Ela chega ao innerHTML sem codificação, então o navegador a interpreta como HTML.
A Content-Security-Policy definida em server.js:298 inclui script-src 'self' 'unsafe-inline', portanto manipuladores de eventos inline como onerror podem ser executados.
Certifique-se de que existe uma conta de administrador (qualquer instalação normal). O atacante não precisa das credenciais dela.
Como atacante não autenticado, envie um login falho cujo nome de usuário seja o payload:
curl -X POST http://TARGET:7000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"","password":"x"}'
O servidor responde 401 Invalid username or password e armazena o payload.
O administrador faz login normalmente e clica no sino de notificações.
O payload é executado na sessão do administrador. Neste exemplo, ele lê o token de sessão com o getStoredToken() da própria página e o usa para sobrescrever o arquivo de instruções do agente AGENTS.md através de POST /api/key-file. Qualquer endpoint autenticado pode ser chamado da mesma forma.
O texto do payload permanece como uma única linha de log e retorna byte por byte através de /api/notifications, portanto a injeção e a execução podem ser confirmadas separadamente.
Execução de código na origem do dashboard como o administrador. O script pode ler o token de sessão e emitir qualquer solicitação autenticada, incluindo editar os arquivos de instrução e habilidades do agente e alterar a configuração do OpenClaw. Como o atacante não precisa de conta, isso transforma uma solicitação de rede não autenticada no controle da sessão do administrador.
innerHTML, ou construa nós com textContent em vez de concatenação de strings. Os campos de nome de usuário, evento e IP da notificação precisam disso.username no servidor antes de registrá-lo: limite seu comprimento e restrinja-o a um conjunto de caracteres esperado.'unsafe-inline' de script-src. Com os manipuladores inline bloqueados, este problema cai de execução de código para marcação inofensiva.