Preorder drugiego tomu książki sekuraka: Wprowadzenie do bezpieczeństwa IT. -15% z kodem: sekurak-book
Bezpieczeństwo protokołu WebSocket w praktyce
Dynamiczny rozwój aplikacji WWW doprowadza do sytuacji, w której już od jakiegoś czasu pojawia się zapotrzebowanie na wprowadzenie możliwości asynchronicznej wymiany danych pomiędzy klientem, a serwerem aplikacji. Wykorzystywany powszechnie protokół HTTP jest bezstanowy, opiera się na zapytaniu wysyłanym do serwera i udzielanej odpowiedzi – brak tutaj stanów pośrednich. Jednym z zaproponowanych rozwiązań rozszerzających dotychczasowe możliwości, jest technika long polling.
W przypadku serwerów HTTP, klient musi założyć, że serwer może nie odpowiedzieć na żądanie od razu. Z kolei strona serwerowa takiej komunikacji zakładała, że w przypadku braku danych do wysłania, nie wyśle pustej odpowiedzi, ale zaczeka do momentu, w którym się te dane pojawią. Inną możliwością jest wykorzystanie zapytań asynchronicznych (XHR). W tym przypadku jednak, uzyskanie efektu komunikacji dwukierunkowej z jak najmniejszym opóźnieniem, osiągane jest kosztem zwiększenia ilości zapytań do serwera. I tak, w związku z zapotrzebowaniem na implementację prawdziwej dwukierunkowej komunikacji w aplikacjach WWW, zaproponowano wdrożenie protokołu WebSocket.
Co to jest i jak działa protokół WebSocket
WebSocket jest protokołem opartym o TCP, zapewniającym dwukierunkową (ang. full-duplex) komunikację pomiędzy klientem a serwerem. Po zestawieniu połączenia, obie strony mogą wymieniać się danymi w dowolnym momencie, wysyłając pakiet danych. Strona zainteresowana nawiązaniem połączenia, wysyła do serwera żądanie inicjalizujące połączenie (ang. handshake). Żądanie to, ze względów na kompatybilność z serwerami WWW, jest niemal identyczne jak standardowe zapytanie HTTP:
GET /chat HTTP/1.1 Host: server.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Origin: http://example.com Sec-WebSocket-Protocol: chat Sec-WebSocket-Version: 13
Takie zapytanie informuje serwer WWW, że aplikacja chce nawiązać połączenie, wykorzystując protokół WebSocket (nagłówek Upgrade). W pierwszej chwili, uwagę przykuwa również nagłówek Sec-WebSocket-Key, zawierający ciąg zakodowany z wykorzystaniem algorytmu base64. Na myśl przychodzi fakt, że może znajdować się tam klucz, który zostanie później wykorzystany do szyfrowania komunikacji. Jego faktycznie zastosowanie ma na celu jedynie ominięcie problemów z pamięcią podręczną (ang. cache), a w praktyce zawiera ciąg losowo wygenerowanych danych.
W odpowiedzi na tak przygotowane i wysłane żądanie, serwer aplikacji reaguje w następujący sposób:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk= Sec-WebSocket-Protocol: chat
Kod odpowiedzi 101 oznacza, że serwer wspiera protokół WebSocket i wyraża zgodę na nawiązanie połączenia. Podobnie, jak w przypadku żądania, odpowiedź również zawiera ciąg znaków zakodowanych w base64. W tym przypadku jest to wynik funkcji skrótu SHA-1, na wysłanym wcześniej ciągu znaków z nagłówka Sec-WebSocket-Key, połączonym ze stałym GUID-em „258EAFA5-E914-47DA-95CA-C5AB0DC85B11”. Po pomyślnym zakończeniu nawiązywania połączenia, dalsza komunikacja odbywa się poprzez socket TCP już z pominięciem protokołu HTTP. Ramka WebSocket wygląda następująco:
Na tym etapie, interesują nas głównie pola opcode oraz payload data. Opcode definiuje w jaki sposób powinny być interpretowane dane przesłane w payload data. Najważniejsze wartości jakie może przyjąć pole opcode przedstawiono w tabeli.
Pozostałe, niewymienione tutaj wartości, omówione są m.in. w dokumencie RFC 6455.
Osobny akapit należy poświęcić bitowi mask oraz polu masking-key. Zgodnie z standardem, każdy z wysyłanych pakietów od klienta do serwera, musi posiadać ustawiony bit mask. W przypadku gdy zostanie on ustawiony, w polu payload nie zostają umieszczone przesyłane dane w postaci jawnej, ale ich zamaskowana postać. Przez zamaskowanie mamy na myśli wynik działania funkcji XOR, na ciągach znaków z pola masking-key oraz wysyłanych danych. Powstaje tutaj pytanie, jaką wartość do całego procesu wnosi wykonanie takiej operacji. Jest ono zasadne, ponieważ z punktu widzenia poufności przesyłanych danych, nie występuje wartość dodana. Klucz szyfrujący znajduje się tuż przed „zamaskowanymi” danymi, przez co odczytanie tak przesyłanego szyfrogramu, należy traktować jako trywialne zadanie. W dokumencie RFC możemy znaleźć jednak informacje o tym, że wykorzystanie takiego mechanizmu, wprowadza ochronę przed cache poisoning – atakami mającymi na celu wpłynięcie na pamięć podręczną różnego typu serwerów proxy.
Jak wygląda przykładowa ramka w praktyce? Wysyłając do serwera ciąg znaków “Sekurak” możemy przechwycić następujący pakiet (np. przy pomocy narzędzia Wireshark):
Widzimy tutaj, że opcode przyjął wartość 1 co oznacza, ze wysyłamy tekst. Wysyłany ciąg ma 7 znaków (111 binarnie) co zgadza się z długością payloadu (Sekurak). Dodatkowo, pakiet ma również ustawiony bit mask oraz 32 bitowy masking-key. Ostatnie 7 bajtów, to zamaskowane dane. Co ważne, Wireshark potrafi rozpoznać pakiet WebSocket i zaprezentować w czytelny sposób poszczególne jego części:
Wykorzystując prosty skrypt, możemy skonfrontować teorię z praktyką. Nasz zamaskowany payload, ma następującą postać heksadecymalną: 9d5376f1bc5776, zgodnie z tym co widać w polu masking-key, a wykorzystany klucz to: ce361d84.
>>> def xor_strings (payload, key): ... from itertools import cycle, izip ... key = cycle(key) ... return ''.join(chr(ord(x) ^ ord(y)) for (x,y) in izip(payload, key)) ... >>> key = "ce361d84" >>> payload = "9d5376f1bc5776" >>> xor_strings(payload.decode("hex"),key.decode("hex")) 'Sekurak' >>>
Wygląda na to, że wszystko działa zgodnie z założeniem.
Prosty klient
W kolejnym kroku, warto poznać działanie WebSocket w praktyce. W tym celu można wykorzystać prostego klienta w JavaScript oraz serwer echo udostępniany przez społeczność websocket.org.
W stosunku do oryginału, kod został minimalnie przystosowany do naszych potrzeb:
<!DOCTYPE html> <meta charset="utf-8" /> <title>WebSocket Test</title> <script language="javascript" type="text/javascript"> var wsUri = "ws://echo.websocket.org/"; var output; function init() { output = document.getElementById("output"); testWebSocket(); document.getElementById("data").focus(); document.getElementById("data").addEventListener('keypress', function(e) { var key = e.which || e.keyCode; if (key === 13) { doSend(document.getElementById("data").value); document.getElementById("data").value = ""; } }); } /* inicjalizacja polaczenia z serwerem oraz przypisanie funkcji do najważniejszych zdazen */ function testWebSocket() { websocket = new WebSocket(wsUri); websocket.onopen = function(evt) { onOpen(evt) }; websocket.onclose = function(evt) { onClose(evt) }; websocket.onmessage = function(evt) { onMessage(evt) }; websocket.onerror = function(evt) { onError(evt) }; } /* funkcja wywolywana przy zestawieniu polaczenia */ function onOpen(evt) { writeToScreen("CONNECTED"); doSend("WebSocket rocks"); } /* funkcja wywolywana przy zamknieciu polaczenia */ function onClose(evt) { writeToScreen("DISCONNECTED"); } /* funkcja wywolywana przy nadejściu nowej wiadomosci */ function onMessage(evt) { writeToScreen('<span style="color: blue;">RESPONSE: ' + evt.data + '</span>'); } /* funkcja wywolywana przy wystąpieniu bledu */ function onError(evt) { writeToScreen('<span style="color: red;">ERROR:</span> ' + evt.data); } /* funkcja wywolywana przy probie wyslania wiadomosci */ function doSend(message) { writeToScreen("SENT: " + message); websocket.send(message); } /* funkcja pomocnicza wypisująca tekst */ function writeToScreen(message) { var pre = document.createElement("p"); pre.style.wordWrap = "break-word"; pre.innerHTML = message; output.appendChild(pre); } window.addEventListener("load", init, false); </script> <h2>WebSocket Test</h2> <div id="output"></div> <input id="data"></div>
źródło: https://www.websocket.org/echo.html
Zapisując skrypt pod dowolną nazwą z rozszerzeniem .html i otwierając plik w przeglądarce wspierającej protokół WebSocket, nawiążemy automatycznie połączenie z serwerem echo. Zaleca się wykorzystanie przeglądarek opartych o Chromium (np. Google Chrome), ze względu na rozbudowane funkcje związane z WebSocket dostępne w konsoli deweloperskiej.
Wpisując dowolny ciąg znaków w pole tekstowe, możemy go wysłać do serwera poprzez wciśnięcie przycisku Enter. Aby podejrzeć ramki wygenerowane przez przeglądarkę oraz odebrane z serwera można wykorzystać Google Developer Tools (Konsola deweloperska -> Zakładka Network -> pozycja echo.websocket.org w kolumnie Name -> zakładka Frames):
Zagrożenia
Poniżej opisujemy najważniejsze zagrożenia związane z wykorzystaniem protokołu WebSocket. Zostały one zmapowane na najczęstsze podatności występujące w aplikacjach internetowych, które wymienione są na liście OWASP Top 10. Intencją nie jest zachowanie kolejności, czy dokładnego podziału, ale potraktowanie wspomnianej listy jako szablonu do omówienia najważniejszych podatności, związanych z opisywanym protokołem. Przystępując do dalszej lektury, należy zdać sobie sprawę z faktu, że WebSocket nie jest niczym innym, jak kolejnym sposobem na przesyłanie danych poprzez sieć. Decyzja o tym, co dzieje się z danymi przesyłanymi w ten sposób, zależy już w zupełności od aplikacji wykorzystującej ten protokół.
Same-origin policy
Praktyczna próba wykorzystania WebSocket, celowo została umieszczona zaraz za wstępem teoretycznym. Jeżeli wymieniliśmy kilka wiadomości z serwerem echo, powinno nas zastanowić to, że bez problemu nawiązaliśmy połączenia – a co ważniejsze – otrzymaliśmy odpowiedź od zewnętrznego serwera. Dlaczego nie zaprotestował mechanizm Same-origin Policy (SOP)?
Jednym z głównym zagrożeń, jakie należy rozważyć w przypadku wykorzystania WebSocket, jest kwestia Same-origin policy, a dokładniej – w tym przypadku – braku jej zastosowania. Mówiąc inaczej, połączenia WebSocket nawiązywane z przeglądarek internetowych, nie są obarczone żadnymi ograniczeniami, co do miejsca do którego chcemy nawiązać połączenie. W przypadku zapytań HTTP, zastosowanie ma SOP oraz ewentualnie rozluźnienia tej polityki, w postaci odpowiednich zasad CORS. Tutaj nie mamy takich ograniczeń. Na chwile obecną, jedynym sposobem na to, by okiełznać połączenie WebSocket, jest zastosowanie Content Security Policy (CSP) poprzez dyrektywę connect-src.
Niepoprawne zarządzanie uwierzytelnieniem oraz sesją
WebSocket w żaden sposób nie implementuje bezpośrednio mechanizmu uwierzytelnienia (ang. authentication). Tak samo – jak w HTTP – ciężar weryfikacji tożsamości klienta, leży po stronie aplikacji opartej o ten protokół.
Ominięcie autoryzacji
Podobnie jak w przypadku uwierzytelnienia, również kwestie związane z przydzielaniem praw do zasobów leżą po stronie aplikacji wykorzystującej WebSocket. WebSocket definiuje podobny do HTTP zestaw schematów URL. Trzeba pamiętać, że jeżeli aplikacja nie wprowadzi odpowiedniego poziomu autoryzacji, to – podobnie jak w przypadku zasobów HTTP pozostawionych bez uwierzytelnienia – również tutaj będzie można przeprowadzić ich enumerację. Projektując aplikację, wygodnie jest robić pewne założenia, które znacząco upraszczają kwestie związane z implementacją zabezpieczeń. Przykładem sytuacji, kiedy może pojawić się pokusa pójścia na skróty, jest obdarzenie nadmiernym zaufaniem nagłówków wysyłanych przez klienta, w tym przypadku szczególnie mowa o nagłówku Origin. Nagłówek ten zawiera informacje o domenie, z której wysłane zostało dane żądanie i powinno się go walidować po stronie serwera. Jego wartość jest automatycznie ustawiona poprzez przeglądarki internetowe i nie może zostać zmieniona, np. poprzez kod JavaScript. Należy jednak pamiętać, że klientem nawiązującym połączenie może być dowolna aplikacja, której już to obostrzenie nie obowiązuje.
Wstrzyknięcia i niepoprawna obsługa danych
W tym miejscu należy jeszcze raz przypomnieć, że WebSocket jest jedynie protokołem wymiany danych. Od programisty zależy, jakie dane i w jakiej formie będą przesyłane. Na aplikacji natomiast, spoczywa ciężar walidacji danych. Informacje przesłane tym protokołem, nie powinny być traktowane jako zaufane i obsługiwane tak samo, jak dane przesyłane innymi protokołami. Jeżeli dane odbierane przez WebSocket mają trafić do bazy danych, powinien zostać wykorzystany mechanizm prepared statements. W momencie, kiedy chcemy dołączyć odebrane dane do drzewa DOM, należy wcześniej zamienić znaki kontrolne HTML na ich encje.
Wyczerpanie zasobów serwera
Uruchomienie serwera WebSocket może wiązać się koniecznością przemyślenia kwestii wyczerpywania zasobów. Domyślnie, klient nie posiada właściwie żadnych ograniczeń, co do ilości nawiązanych połączeń. Otworzenie kilku kart w przeglądarce z tą samą aplikacją wykorzystującą WebSocket, będzie skutkowało nawiązaniem takiej samej ilości nowych połączeń. Logika chroniąca przed nadmiernym wyczerpywaniem zasobów, musi zostać zaimplementowana po stronie serwera lub infrastruktury.
Tunelowanie ruchu
Liczne źródła traktujące o WebSocket zawierają informację o tym, że protokół ten pozwala na tunelowanie dowolnego ruchu TCP. Jako przykład takiego zastosowania, można zaprezentować projekt wsshd. Dzięki niemu, instalując kilka bibliotek i uruchamiając skrypt na serwerze, możemy wystawić nasz serwer SSH w świat pozwalając na łączenie się do niego właśnie poprzez WebSocket.
git clone https://github.com/aluzzardi/wssh.git cd wssh/ pip install -r requirements_server.txt python setup.py install wsshd
Wsshd udostępnia klienta konsolowego oraz interfejs WWW, dzięki któremu możemy połączyć się z serwerem SSH przez WebSocket:
Zastosowanie takich rozwiązań otwiera nowe perspektywy na omijanie filtrowania ruchu sieciowego przez zapory ogniowe.
Szyfrowany kanał komunikacji
Podobnie jak w przypadku HTTP, wykorzystując WebSocket możemy zadecydować, czy dane mają być wysyłane szyfrowanym kanałem komunikacji (TLS), czy nie. Dla zastosowań wykorzystujących szyfrowanie przygotowany został protokół wss (np. wss://sekurak.pl).
Gotowe rozwiązania
W praktyce, mało kto decyduje się na wykorzystanie natywnej implementacji WebSocket, poprzez podstawowy interfejs JavaScript dostarczany w przeglądarkach. Popularniejszym podejściem jest wykorzystanie gotowych bibliotek i frameworków. Najciekawsze z nich to:
●Socket.io – jedno z popularniejszych rozwiązań tego typu, rozwijane od 2010 roku; część serwerowa napisana jest w Node.JS,
●Ratchet – coś dla osób chcących pozostać przy rozwiązaniach opartych o PHP,
●WebSocketHandler – klasa dostępna w środowisku .NET od wersji 4.5,
●Autobahn – jeżeli operujemy w środowisku Python, na pewno warto zainteresować się tą biblioteką; posiada ona również swoje implementacje dla innych technologii (Node.JS, Java, C++).
W przypadku własnych implementacji należy pamiętać m.in. o takich kwestiach, jak zarządzanie pamięcią.
Testowanie
Do przechwytywania ruchu i modyfikacji zapytań wysyłanych poprzez WebSocket, zdecydowanie zaleca się wykorzystać OWASP Zaproxy. Wsparcie dla WebSocket w Burp Suite, jest w powijakach – dostępne możliwości ograniczają się właściwie tylko do podstawowego przechwytywania zapytań oraz wyświetlania listy wykonanych żądań i otrzymanych odpowiedzi. Aby wykorzystać Zapa do testów, należy pobrać plik JAR i upewnić się, że po uruchomieniu proxy, nasłuchuje na porcie 8080 (Tools -> Options -> zakładka Local Proxy -> pole Port). Następnie, należy skonfigurować naszą przeglądarkę tak, by ruch sieciowy wysyłała do proxy localhost:8080 (wskazówki, jak skonfigurować ustawienia proxy w popularnych przeglądarkach możemy znaleźć m.in. tutaj).
Po skonfigurowaniu przeglądarki i odświeżeniu pliku z naszym testowym klientem w proxy powinna pojawić się nowa zakładka WebSockets:
Będzie to miejsce, w którym znajdziemy informację o każdej ramce wysłanej z aplikacji, jak i otrzymanej z serwera. Klikając na wybranej pozycji z listy prawym przyciskiem myszy, pojawi się menu, z którego będziemy mogli wybrać opcję Resend:
W nowym oknie będziemy mieli do wyboru kilka przydatnych opcji:
● Opcode – z listy rozwijanej możemy wybrać takie opcje jak TEXT, BINARY, CLOSE, PING oraz PONG. W ten sposób jesteśmy w stanie zasymulować każdy z etapów komunikacji jaki może wystąpić w przypadku protokołu WebSocket,
● Direction – kierunek, w którym ma zostać wysłana ramka (do serwera – Outgoing, Incoming – do aplikacji),
● Channel – lista z której możemy wybrać, którego połączenia dotyczą modyfikacje (jeżeli w danym momencie mamy nawiązane więcej niż jedno).
Wprowadzone przez nas zmiany zatwierdzamy przyciskiem “Send”.
Pokazane tutaj opcje są namiastką narzędzia “Repeater” Burp Suite, jakie często wykorzystywane jest do modyfikacji żądań HTTP.
Modelowanie zagrożeń
W ramach podsumowania artykułu, poniżej przedstawiam przykładową listę pytań, na które należy odpowiedzieć podczas modelowania zagrożeń aplikacji wykorzystującej WebSocket:
●Czy wykorzystywany jest szyfrowany kanał komunikacji (wss)?
●Czy dane odbierane od klienta poprzez protokół WebSocket są odpowiednio walidowane?
●Czy wykorzystany serwer WebSocket ogranicza ilość możliwych równoległych połączeń od jednego klienta?
●W jaki sposób realizowane jest uwierzytelnienie oraz autoryzacja do zasobów udostępnianych poprzez WebSocket?
●Czy wykorzystany jest znany serwer WebSocket, czy autorskie rozwiązanie? Czy autorski serwer przeszedł etap weryfikacji bezpieczeństwa?
●Czy posiadamy wdrożoną politykę CSP limitującą źródła z jakimi możemy nawiązać połączenie?
●Czy po stronie serwera walidowany jest nagłówek Origin dodatkowo uwzględniając fakt, że może zostać on zmanipulowany w przypadku zastosowania klienta, nie będącego przeglądarką internetową?
●Czy wykorzystana jest gotowana biblioteka obsługująca część kliencką oraz serwerową odpowiedzialną za protokół WebSocket?
●Czy zapora ogniowa dopuszcza ruch sieciowy do portu, na którym nasłuchuje serwer WebSocket, tylko z określonych źródeł?
Podsumowanie
WebSocket jest ciekawym rozwiązaniem, które w dobie “bogatych” aplikacji WWW, może znaleźć wiele zastosowań chociażby w przypadku aplikacji, gdzie użytkownicy jednocześnie pracują nad tym samym zestawem danych. Niemniej, należy pamiętać, że z perspektywy bezpieczeństwa, jest to tylko nośnik danych, a ciężar odpowiedniego obchodzenia się z nimi – podobnie jak w przypadku HTTP – leży po stronie aplikacji.
Odniesienia
1. https://tools.ietf.org/html/rfc6455
2. http://websocket.org/echo.html
~ Marcin Piosek, pentester w Securitum
już oczytane w sekurakzine ;)
Socket.io tak naprawdę używa wielu transportów, w tym WS i XHR. Ja za to dorzucę jeszcze bibliotekę „ws” do Node.js, która pozwala explicite ustawić limit wielkości jednej wiadomości od klienta (klient wysyła więcej → połączenie jest zamykane). Co do klienta, jakby ktoś chciał czyste websockety z auto-reconnectem, to na moim GitHubie jest biblioteka „esdf-ws-client”.
Ogólnie fajny napisany artykuł. Właściwie zdaje się, że zagrożenia są takie, jak gdybyśmy pisali dowolną inną aplikację sieciową używającą socketów TCP.
Szkoda, że nie ma wersji opartej o UDP do zastosowań w przeglądarkowych grach sieciowych.