
Avis de sécurité : Cross-Site Scripting stocké non authentifié menant à la prise de contrôle du compte administrateur (openclaw-dashboard)
Titre : XSS stocké dans OpenClaw Dashboard v3.0.0 via le champ de nom d'utilisateur d'une connexion échouée Identifiant CVE attribué : CVE-2026-66418
Dépôt cible : https://github.com/tugcantopaloglu/openclaw-dashboard
Le endpoint de connexion enregistre le nom d'utilisateur soumis dans le journal d'audit sans aucune validation. Le panneau de notifications lit ensuite ces entrées de journal et les écrit dans la page avec innerHTML et sans échappement. Un attaquant qui ne peut pas se connecter peut néanmoins envoyer une requête de connexion échouée dont le nom d'utilisateur est une charge utile de script. La prochaine fois que l'administrateur connecté ouvre la cloche de notifications, cette charge utile s'exécute dans son navigateur, dans l'origine du tableau de bord, avec accès à son jeton de session.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:N/SC:H/SI:H/SA:NIntroduit dans la v3.0.0, qui a ajouté le centre de notifications qui affiche les entrées du journal d'audit. Présent dans la v3.0.0 et dans chaque commit ultérieur jusqu'au main actuel inclus (d6198d0). Non corrigé au moment de la rédaction.
L'attaquant est un tiers distant qui peut atteindre le port HTTP du tableau de bord mais ne possède ni compte ni identifiants valides. Cela correspond à un écran de connexion accessible à quiconque peut ouvrir le tableau de bord. La seule condition préalable est qu'un compte administrateur existe déjà, ce qui est vrai pour tout déploiement après la configuration initiale.
La charge utile est stockée, donc aucune coordination temporelle n'est nécessaire. Elle s'exécute lorsque l'administrateur ouvre le panneau de notifications, une action normale proposée par l'interface. Une fois exécutée, elle dispose des mêmes capacités que le navigateur de l'administrateur : elle peut lire le jeton de session et appeler tout endpoint authentifié au nom de la victime.
Étape 1. Le nom d'utilisateur est journalisé tel quel. Une connexion avec un nom d'utilisateur qui ne correspond pas au compte enregistré atteint cette branche :
// 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 provient directement du corps de la requête JSON. Il n'y a aucune limite de longueur, aucune liste blanche de caractères ni aucun contrôle de type. auditLog l'écrit sur le disque sous forme de ligne 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 échappe les guillemets et les sauts de ligne, de sorte que la charge utile reste sur une seule ligne et se réanalyse proprement. Il n'échappe pas <, > ni /, donc le balisage HTML survit intact.
Étape 2. Le journal est relu. Le endpoint de notifications renvoie les lignes de journal récentes au navigateur :
// 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 }));
Étape 3. Le nom d'utilisateur est écrit dans le DOM sans échappement. Le frontend construit chaque ligne de notification par concaténation de chaînes et l'affecte avec 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 est la chaîne de l'attaquant. Elle atteint innerHTML sans encodage, de sorte que le navigateur l'analyse comme du HTML.
La Content-Security-Policy définie dans server.js:298 inclut script-src 'self' 'unsafe-inline', donc les gestionnaires d'événements en ligne tels que onerror sont autorisés à s'exécuter.
Assurez-vous qu'un compte administrateur existe (toute installation normale). L'attaquant n'a pas besoin de ses identifiants.
En tant qu'attaquant non authentifié, envoyez une connexion échouée dont le nom d'utilisateur est la charge utile :
curl -X POST http://TARGET:7000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"","password":"x"}'
Le serveur répond 401 Invalid username or password et stocke la charge utile.
L'administrateur se connecte normalement et clique sur la cloche de notifications.
La charge utile s'exécute dans la session de l'administrateur. Dans cet exemple, elle lit le jeton de session avec le getStoredToken() de la page et l'utilise pour écraser le fichier d'instructions de l'agent AGENTS.md via POST /api/key-file. Tout endpoint authentifié peut être appelé de la même manière.
Le texte de la charge utile reste sur une seule ligne de journal et revient octet pour octet via /api/notifications, de sorte que l'injection et l'exécution peuvent être confirmées séparément.
Exécution de code dans l'origine du tableau de bord en tant qu'administrateur. Le script peut lire le jeton de session et émettre toute requête authentifiée, y compris modifier les fichiers d'instructions et de compétences de l'agent et changer la configuration d'OpenClaw. Comme l'attaquant n'a besoin d'aucun compte, cela transforme une requête réseau non authentifiée en contrôle de la session de l'administrateur.
innerHTML, ou construisez les nœuds avec textContent au lieu d'une concaténation de chaînes. Les champs nom d'utilisateur, événement et IP des notifications en ont tous besoin.username côté serveur avant de le journaliser : limitez sa longueur et restreignez-le à un jeu de caractères attendu.'unsafe-inline' de script-src. Avec les gestionnaires en ligne bloqués, ce problème passe d'une exécution de code à un balisage inoffensif.