
セキュリティアドバイザリ:未認証の格納型クロスサイトスクリプティングによる管理者アカウントの乗っ取り (openclaw-dashboard)
タイトル: OpenClaw Dashboard v3.0.0 におけるログイン失敗時のユーザー名フィールドを介した格納型XSS 割り当てCVE ID: CVE-2026-66418
対象リポジトリ: https://github.com/tugcantopaloglu/openclaw-dashboard
ログインエンドポイントは、送信されたユーザー名を一切の検証なしに監査ログへ記録します。通知パネルは後でそれらのログエントリを読み出し、エスケープ処理を行わずに innerHTML でページへ書き込みます。ログインできない攻撃者であっても、ユーザー名にスクリプトペイロードを仕込んだログイン失敗リクエストを送信できます。次にログイン済みの管理者が通知ベルを開くと、そのペイロードが管理者のブラウザ上、ダッシュボードのオリジン内で実行され、セッショントークンへアクセスできるようになります。
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:N/SC:H/SI:H/SA:N監査ログエントリを描画する通知センターを追加した v3.0.0 で導入されました。v3.0.0 および現在の main (d6198d0) までの後続のすべてのコミットに存在します。執筆時点では修正されていません。
攻撃者は、ダッシュボードのHTTPポートに到達できるが、アカウントも有効な認証情報も持たないリモートの第三者です。これは、ダッシュボードを開ける者なら誰でもログイン画面に到達できるという状況と一致します。唯一の前提条件は、管理者アカウントが既に存在していることですが、これは初期セットアップを完了したあらゆるデプロイメントで成り立ちます。
ペイロードは格納されるため、タイミングの調整は不要です。管理者がUIが提供する通常の操作である通知パネルを開いたときに実行されます。実行されると、管理者のブラウザと同じ権限を持ち、セッショントークンを読み取り、被害者に代わって任意の認証済みエンドポイントを呼び出すことができます。
ステップ 1: ユーザー名がそのままログに記録される。 登録済みアカウントと一致しないユーザー名でのログインは、次の分岐に到達します:
// 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 はJSONリクエストボディから直接取得されます。長さ制限、文字の許可リスト、型チェックはありません。auditLog はそれを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 は引用符と改行をエスケープするため、ペイロードは1行に収まり、きれいにパースし直せます。ただし <、>、/ はエスケープしないため、HTMLマークアップはそのまま残ります。
ステップ 2: ログが読み戻される。 通知エンドポイントは最近のログ行をブラウザへ返します:
// 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 }));
ステップ 3: ユーザー名がエスケープ処理なしでDOMに書き込まれる。 フロントエンドは各通知行を文字列連結で構築し、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 は攻撃者の文字列です。エンコード処理なしで innerHTML に到達するため、ブラウザはそれをHTMLとして解釈します。
server.js:298 で設定されるContent-Security-Policy には script-src 'self' 'unsafe-inline' が含まれているため、onerror のようなインラインイベントハンドラの実行が許可されます。
管理者アカウントが存在することを確認します (通常のインストールならば存在します)。攻撃者はその認証情報を必要としません。
未認証の攻撃者として、ユーザー名にペイロードを仕込んだログイン失敗を1回送信します:
curl -X POST http://TARGET:7000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"","password":"x"}'
サーバーは 401 Invalid username or password を返し、ペイロードを保存します。
管理者は通常どおりログインし、通知ベルをクリックします。
ペイロードは管理者のセッション内で実行されます。この例では、ページ自身の getStoredToken() を使ってセッショントークンを読み取り、POST /api/key-file を通じてエージェント指示ファイル AGENTS.md を上書きします。任意の認証済みエンドポイントも同様に呼び出せます。
ペイロードのテキストは単一のログ行のまま維持され、/api/notifications を通じてバイト単位でそのまま返されるため、インジェクションと実行を別々に確認できます。
ダッシュボードのオリジン内での管理者としてのコード実行。スクリプトはセッショントークンを読み取り、エージェントの指示ファイルやスキルファイルの編集、OpenClaw設定の変更を含む、任意の認証済みリクエストを発行できます。攻撃者はアカウントを必要としないため、これにより未認証のネットワークリクエストが管理者セッションの乗っ取りへと変わります。
innerHTML に値を挿入する前にすべての値をHTMLエンコードするか、文字列連結の代わりに textContent でノードを構築してください。通知のユーザー名、イベント、IPフィールドはすべてこれが必要です。username を検証し、長さを制限して想定される文字セットに制限してください。script-src から 'unsafe-inline' を削除してください。インラインハンドラがブロックされれば、この問題はコード実行から無害なマークアップ表示へと低下します。