
Уведомление о безопасности: неаутентифицированная хранимая межсайтовая XSS-уязвимость, приводящая к захвату учётной записи администратора (openclaw-dashboard)
Title: OpenClaw Dashboard v3.0.0 — сохранённый XSS через поле имени пользователя при неудачном входе Назначенный идентификатор CVE: CVE-2026-66418
Целевой репозиторий: https://github.com/tugcantopaloglu/openclaw-dashboard
Конечная точка входа записывает отправленное имя пользователя в журнал аудита без
какой-либо проверки. Панель уведомлений позже читает эти записи журнала и вставляет
их на страницу с помощью innerHTML без экранирования. Злоумышленник, который не
может войти в систему, всё равно может отправить запрос на неудачный вход, указав
в качестве имени пользователя полезную нагрузку-скрипт. При следующем открытии
администратором колокольчика уведомлений эта полезная нагрузка выполняется в его
браузере, в origin панели управления, и получает доступ к его токену сессии.
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-порту панели управления, но не имеет учётной записи и действительных учётных данных. Это соответствует тому, что страница входа доступна любому, кто может открыть панель управления. Единственное предусловие — уже существующая учётная запись администратора, что верно для любого развёртывания после первичной настройки.
Полезная нагрузка сохраняется, поэтому синхронизация по времени не требуется. Она выполняется, когда администратор открывает панель уведомлений — обычное действие, предлагаемое интерфейсом. После запуска она получает те же возможности, что и браузер администратора: может прочитать токен сессии и вызывать любые аутентифицированные конечные точки от имени жертвы.
Шаг 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.
Политика безопасности контента, заданная в 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, поэтому инъекцию и выполнение можно
подтвердить по отдельности.
Выполнение кода в origin панели управления с правами администратора. Скрипт может прочитать токен сессии и отправлять любые аутентифицированные запросы, включая изменение файлов инструкций и навыков агента и изменение конфигурации OpenClaw. Поскольку злоумышленнику не нужна учётная запись, этот неаутентифицированный сетевой запрос превращается в контроль над сессией администратора.
innerHTML или создавайте
узлы с помощью textContent вместо конкатенации строк. Это касается полей имени
пользователя, события и IP-адреса в уведомлениях.username на сервере перед записью в журнал: ограничьте его длину и
разрешите только ожидаемый набор символов.'unsafe-inline' из script-src. Если встроенные обработчики будут
заблокированы, эта проблема снижается с выполнения кода до безвредной разметки.