
تنبيه أمني: برمجة نصية عبر المواقع (XSS) مخزنة بدون مصادقة تؤدي إلى الاستيلاء على حساب المسؤول (openclaw-dashboard)
العنوان: ثغرة XSS مخزّنة في OpenClaw Dashboard v3.0.0 عبر حقل اسم المستخدم في تسجيل الدخول الفاشل معرّف CVE المعيّن: 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 وفي كل commit لاحق حتى main الحالي (d6198d0) ويشمله. لم يتم إصلاحها وقت كتابة هذا النص.
المهاجم طرف خارجي يمكنه الوصول إلى منفذ HTTP الخاص بلوحة التحكم لكنه لا يملك حساباً أو بيانات اعتماد صالحة. وهذا يتوافق مع إمكانية وصول أي شخص يفتح لوحة التحكم إلى شاشة تسجيل الدخول. الشرط المسبق الوحيد هو وجود حساب مسؤول مسبقاً، وهذا ينطبق على أي نشر بعد الإعداد الأولي.
الحمولة مخزّنة، فلا حاجة لتنسيق توقيت. يتم تنفيذها عندما يفتح المسؤول لوحة التنبيهات، وهو إجراء عادي يوفّره الواجهة. وبمجرد تشغيلها، تتمتع بنفس صلاحيات متصفح المسؤول: يمكنها قراءة رمز الجلسة واستدعاء أي نقطة نهاية مصادق عليها باسم الضحية.
الخطوة 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 بترميز علامات الاقتباس وأسطر الجديدة، لذلك يبقى الحمولة في سطر واحد ويُحلَّل مرة أخرى بشكل سليم. وهو لا يرمّز < أو > أو /، لذا تبقى علامات 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.
إن Content-Security-Policy المعيّنة في server.js:298 تتضمن script-src 'self' 'unsafe-inline'، لذا يُسمح بتشغيل معالجات الأحداث المضمّنة مثل onerror.
تأكد من وجود حساب مسؤول (أي تثبيت عادي). لا يحتاج المهاجم إلى بيانات اعتماده.
بوصفك المهاجم غير المصادق عليه، أرسل طلب تسجيل دخول فاشلاً يكون اسم المستخدم فيه هو الحمولة:
curl -X POST http://TARGET:7000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"","password":"x"}'
يردّ الخادم بـ401 Invalid username or password ويخزّن الحمولة.
يسجّل المسؤول الدخول بشكل طبيعي وينقر على جرس التنبيهات.
تعمل الحمولة في جلسة المسؤول. في هذا المثال، تقرأ رمز الجلسة عبر دالة الصفحة getStoredToken() وتستخدمه لاستبدال ملف تعليمات الوكيل AGENTS.md عبر POST /api/key-file. يمكن استدعاء أي نقطة نهاية مصادق عليها بالطريقة نفسها.
يظل نص الحمولة سطر سجل واحداً ويعود بايتاً ببايت عبر /api/notifications، لذا يمكن تأكيد الحقن والتنفيذ بشكل منفصل.
تنفيذ تعليمات برمجية في نطاق لوحة التحكم بصلاحيات المسؤول. يمكن للبرنامج قراءة رمز الجلسة وإرسال أي طلب مصادق عليه، بما في ذلك تعديل ملفات تعليمات الوكيل ومهاراته وتغيير إعدادات OpenClaw. ولأن المهاجم لا يحتاج إلى حساب، فإن هذا يحوّل طلب شبكة غير مصادق عليه إلى تحكم في جلسة المسؤول.
innerHTML، أو أنشئ العُقد باستخدام textContent بدلاً من دمج السلاسل النصية. جميع حقول اسم المستخدم والحدث وعنوان IP في التنبيهات تحتاج إلى ذلك.username على الخادم قبل تسجيله: حدد طوله واقصره على مجموعة أحرف متوقعة.'unsafe-inline' من script-src. مع حظر المعالجات المضمّنة، تنخفض هذه المشكلة من تنفيذ تعليمات برمجية إلى ترميز غير ضار.