
安全通告:未认证的存储型跨站脚本攻击导致管理员账户接管 (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 端口的远程方,但没有账户,也没有有效凭据。这与登录页面可被任何能打开仪表板的人访问的情况相符。唯一前提是已存在一个管理员账户,任何完成首次运行设置的部署都满足此条件。
该载荷是存储型的,因此无需时序协调。当管理员打开通知面板时执行,而这是界面提供的正常操作。一旦运行,它就拥有与管理员浏览器相同的能力:可以读取会话令牌,并代表受害者调用任何需要认证的端点。
第 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 设置的 Content-Security-Policy 包含 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() 读取会话令牌,并通过 POST /api/key-file 覆盖代理指令文件 AGENTS.md。任何需要认证的端点都可以用同样的方式调用。
载荷文本保持为单行日志,并通过 /api/notifications 逐字节返回,因此注入与执行可以分别确认。
以管理员身份在仪表板源中执行代码。该脚本可以读取会话令牌并发出任何需要认证的请求,包括编辑代理的指令文件和技能文件,以及更改 OpenClaw 配置。由于攻击者不需要账户,这会把一个未认证的网络请求转变为对管理员会话的控制。
innerHTML 之前对每个值进行 HTML 编码,或者使用 textContent 构建节点,而不是字符串拼接。通知中的用户名、事件和 IP 字段都需要这样处理。username 之前,在服务器端对其验证:限制其长度,并将其限定在预期的字符集内。script-src 中移除 'unsafe-inline'。在内联处理程序被阻止后,该问题的影响从代码执行降为无害的标记。