
Avis de sécurité : Cross-Site Scripting stocké via les messages d'agent conduisant au vol de jeton de session (openclaw-dashboard)
Titre : XSS stocké sur OpenClaw Dashboard via le champ de session lastMessage ID CVE : CVE-2026-66421
https://github.com/tugcantopaloglu/openclaw-dashboard
La table des sessions de la page d'accueil du tableau de bord affiche le dernier message de chaque session
d'agent. Ce texte est extrait de la transcription de la conversation de l'agent puis écrit
dans la page avec innerHTML, sans échappement. OpenClaw étant une passerelle multi-canaux
pour agents, le texte du message peut provenir de toute personne capable de parler à l'agent,
comme un groupe de discussion ou un webhook. Un message contenant une charge utile HTML
s'exécute comme script dans le navigateur de l'administrateur dès qu'il ouvre le tableau de bord.
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:H/VI:H/VA:N/SC:H/SI:H/SA:NPrésente depuis la première publication publique. Le rendu vulnérable existe dans le
tout premier tag (v1.1.0) et dans chaque commit ultérieur jusqu'au main actuel
inclus (d6198d0). Non corrigé au moment de la rédaction.
L'attaquant est une partie capable d'envoyer un message qui atterrit dans la transcription de session de l'agent. Il n'a pas besoin de compte sur le tableau de bord. Dans un déploiement OpenClaw typique, cela inclut les membres des canaux de discussion connectés et toute source qui atteint l'agent via un webhook. L'attaquant se trouve donc souvent en dehors de la frontière de confiance de l'opérateur.
La charge utile est stockée dans la transcription et rendue à chaque fois que
l'administrateur consulte la page des sessions. Cette page est la vue d'accueil par
défaut et se rafraîchit sur une minuterie, donc la seule interaction requise est que
l'administrateur charge le tableau de bord qu'il utilise normalement. AT:P reflète
l'unique condition sur laquelle s'appuie l'attaquant : que le déploiement route ses
messages vers une session affichée par le tableau de bord.
Étape 1. Le texte du message est extrait de la transcription. Le serveur lit le message le plus récent et renvoie son texte, tronqué à 80 caractères, sans assainissement :
// server.js:474-481
if (typeof msg.content === 'string') {
text = msg.content;
} else if (Array.isArray(msg.content)) {
for (const b of msg.content) {
if (b.type === 'text' && b.text) { text = b.text; break; }
}
}
if (text) return text.replace(/\n/g, ' ').substring(0, 80);
Étape 2. Il est exposé via l'API des sessions. getSessionsJson place le texte
dans le champ lastMessage de chaque session :
// server.js:538
lastMessage: getLastMessage(s.sessionId || key),
GET /api/sessions renvoie ce tableau au navigateur.
Étape 3. Il est écrit dans le DOM sans échappement. La table des sessions construit
chaque ligne par concaténation de chaînes et l'affecte via innerHTML :
// index.html:3758
const lastMsg = s.lastMessage ? s.lastMessage.substring(0, 60) + (s.lastMessage.length > 60 ? '…' : '') : '';
// index.html:3773
<div class="table-cell" style="..." onclick="toggleSessionExpand('${escapedKey}', event)">${lastMsg}</div>
lastMsg est placé directement dans la chaîne HTML. Aucune étape d'encodage n'existe
sur ce chemin.
La même table interpole s.label (index.html:3770) et s.key (index.html:3767) dans
le balisage de la ligne de la même manière. Ces champs n'ont pas de limite de 60
caractères, ce qui laisse à un attaquant capable de les influencer plus d'espace pour
une charge utile.
La Content-Security-Policy à server.js:298 autorise les gestionnaires d'événements
en ligne (script-src 'self' 'unsafe-inline'), donc un gestionnaire onerror
s'exécute.
En tant que partie pouvant envoyer un message à l'agent, envoyez un message dont le texte est une charge utile courte qui tient dans le budget de 60 caractères, par exemple :
Cela fait 56 caractères et atteint la transcription de session de l'agent comme n'importe quel message normal.
Confirmez que le serveur renvoie la charge utile sans échappement :
curl -H "Authorization: Bearer ADMIN_TOKEN" http://TARGET:7000/api/sessions
Le champ lastMessage de la session concernée contient la chaîne brute ``.
L'administrateur ouvre le tableau de bord. La page des sessions est la vue par défaut, donc aucune
navigation supplémentaire n'est nécessaire. La ligne est rendue, le navigateur analyse la balise ``,
le gestionnaire onerror s'exécute et le jeton de session de l'administrateur est lu par la charge utile.
Remplacer le corps du console.log par un fetch vers un point de terminaison authentifié
de même origine permet à la charge utile d'agir avec les privilèges de l'utilisateur connecté.
Exécution de script dans l'origine du tableau de bord en tant qu'utilisateur connecté, accessible à toute personne qui n'a qu'à envoyer un message à l'agent. La charge utile peut lire le jeton de session et appeler des points de terminaison authentifiés, y compris ceux qui modifient les fichiers d'instructions de l'agent lui-même et la configuration OpenClaw. Comme elle se déclenche sur la page par défaut, l'administrateur n'a aucune action inhabituelle à effectuer.
lastMessage, label et key avant de les insérer dans le balisage de la ligne, ou construire les cellules avec textContent plutôt qu'avec une chaîne HTML.innerHTML.'unsafe-inline' de script-src afin que le balisage injecté ne puisse pas s'exécuter même si une étape d'encodage est manquée.