CSRF, double submit cookies i good enough

Zacznijmy od końca. Jest coś takiego jak principle of good enough. W wielu przypadkach rzeczywiście wystarczające jest zastosowanie rozwiązania, które jest wystarczająco dobre. A skoro temat ten pojawia się u mnie, można przypuszczać, że będzie o aplikacjach internetowych i bezpieczeństwie. I tak rzeczywiście będzie.

Tak jak zaznaczyłem w tytule, sprawa dotyczyć będzie Cross-Site Request Forgery oraz jednego z możliwych zabezpieczeń, który nosi nazwę Double Submit(ted) Cookies. Ale zacznijmy od początku, czyli od tego o co w zasadzie chodzi w tym całym CSRF.

Jak działa aplikacja internetowa? W typowym przypadku GUI aplikacji prezentowane jest użytkownikowi w przeglądarce, cała logika jest realizowana po stronie serwera. W wyniku akcji użytkownika (np. kliknięcie linku, wysłanie formularza) generowane jest żądanie, które wysyłane jest do serwera. Serwer odbiera i obsługuje żądanie, a następnie odsyła odpowiedź do użytkownika. Całość odbywa się w z wykorzystaniem protokołu HTTP.

Protokół HTTP jest już dość wiekowy, a w czasie, gdy był projektowany, jego autorom nawet nie śniło się, że Internet będzie wyglądał tak, jak wygląda. Nikt nie myślał również o złożonych aplikacjach, które dla nas są teraz czymś normalnym. Jedną z cech protokołu HTTP była jego bezstanowość. Oznacza to, że serwer każde żądanie (i odpowiedź na nie) może traktować oddzielnie. Z kolei w przypadku aplikacji konieczne jest utrzymywanie pewnego stanu, kontekstu zawierającego dane, które powinny przenosić się między żądaniami pochodzącymi od tego samego użytkownika. By ten problem rozwiązać wymyślono coś, co nazwano sesją. Jedną z informacji, która może być zawarta w sesji, jest informacja o tożsamości użytkownika wysyłającego żądanie. Użytkownik najpierw loguje się do aplikacji z wykorzystaniem swojego loginu i hasła, w odpowiedzi dostaje identyfikator sesji, który przekazuje z każdym kolejnym żądaniem do serwera. Dzięki temu serwer wie kto wysłał dane żądanie i na tej podstawie może w odpowiedni sposób je obsłużyć.

Identyfikator sesji zwykle jest przekazywany w cookie , czyli nagłówku żądania HTTP. Cookie mają to do siebie, że przeglądarka wysyła je automatycznie razem z żądaniem , oczywiście o ile takie cookie istnieje. Serwer (aplikacja) po otrzymaniu żądania od klienta na podstawie identyfikatora sesji odtwarza kontekst aplikacji (w tym tożsamość klienta) i realizuje żądanie. I tu właśnie dochodzimy do sedna podatności CSRF. Jeśli atakujący jest w stanie zmusić przeglądarkę swojej ofiary do wysłania odpowiedniego żądania, serwer aplikacyjny obsłuży je i być może wykona akcję, której użytkownik wcale wykonać nie chciał. Przypominam, że przeglądarka radośnie, w sposób automatyczny, dołączy do takiego żądania wszystkie istniejące cookie (w tym cookie z identyfikatorem sesji), w związku z czym serwer (aplikacja) przed obsługą żądania odtworzy kontekst ofiary i w tym kontekście wykona żądaną operację.

Zwracam uwagę na odpowiednie żądanie , bo właśnie tutaj kryje się podstawowy sposób ochrony przed CSRF. Jeśli atakujący nie wie, jak powinno wyglądać prawidłowe żądanie, nie będzie w stanie przeprowadzić skutecznego ataku. Co to w praktyce oznacza?

Załóżmy, że chcemy przygotować atak z gatunku uciążliwych, który powoduje wylogowanie użytkownika z aplikacji (Google za to nie płaci, patrz: Vulnerability Reward Program). Każdy z użytkowników wie jak żądanie wylogowania użytkownika powinno wyglądać. A jeśli nie wie, to może to sprawdzić, bo ma dostęp do aplikacji (a jak nie ma, to może łatwo go uzyskać – po prostu musi założyć konto). Można jednak dodać do takiego żądania parametr, nazwijmy go subtelnie token , którego wartość jest losowa, unikalna dla każdego użytkownika (każdej sesji użytkownika, lub każdego kolejnego żądania w sesji) i weryfikowana przez serwer. Tylko te żądania, które zawierają prawidłowy token, są przetwarzane, pozostałe są odrzucane przez aplikację bez ich obsłużenia.

Ten pomysł można zaimplementować na wiele różnych sposobów, ale serwer musi wiedzieć jaki token powinien otrzymać w żądaniu. Taka informacja może być zapisana w sesji, ale można ten problem rozwiązać również inaczej, co wcale nie oznaczania, że lepiej/gorzej. Jednym z rozwiązań jest wykorzystanie wspominanego double submit cookies. W tym przypadku pomysł sprowadza się do tego, że ta sama wartość jest przekazywana w cookie i w żądaniu (np. w parametrach POST).

Najpierw opiszę patologiczny przypadek stosowania tego rozwiązania. Jako token anti-csrf stosowany był identyfikator sesji. Do ciała żądania identyfikator sesji był przenoszony na dwa sposoby:

W tym scenariuszu zabezpieczenie przed CSRF ułatwia przejęcie identyfikatora sesji. Dlaczego?

W normalnym (prawidłowym) przypadku identyfikator sesji przekazywany jest w cookie, a cookie to oznaczone jest flagą httpOnly (oraz Secure, ale to akurat nie jest istotne w tym przypadku). Flaga httpOnly uniemożliwia dostęp do wartości tego cookie z poziomu JavaScript. Tak więc nawet jeśli aplikacja jest podatna na XSS, atakujący nie odczyta wartości takiego cookie , a więc nie pozna wartości identyfikatora sesji.

Sytuacja zmienia się, gdy ta sama wartość pojawia się w dokumencie, czyli zwracanym przez serwer kodzie HTML. Nie ma analogicznych do httpOnly mechanizmów obrony, które chroniłyby pewne elementy DOM dokumentu. Jeśli jest więc XSS, atakujący może odczytać wartość identyfikatora sesji zawartą w dokumencie i w efekcie ją przejąć. Lekarstwo gorsze od choroby. Jeśli token nie jest osadzany w DOM dokumentu, ale dodawany do żądania przez JavaScript, identyfikator sesji nie może być oznaczony flagą httpOnly (patrz: problemy DWR i wątek Add support for HttpOnly cookies).

Załóżmy jednak, że na potrzeby ochrony przed CSRF wykorzystane jest dodatkowe cookie. Ta sama wartość przesyłana jest do serwera w cookie i w żądaniu, serwer porównuje je i przetwarza żądanie tylko i wyłącznie wówczas, gdy te wartości są takie same. Roboczo załóżmy też, że wartość tokenu jest losowa, dyskusję odnośnie tego, czy token powinien się zmieniać przy każdym żądaniu, czy może wystarczy unikalny token na każdą sesję pozostawmy na boku. Czy takie rozwiązanie jest wystarczająco skuteczne?

Tu zatrzymajmy się na chwilę i zdefiniujmy jeszcze raz co chcemy osiągnąć. Naszym celem jest uodpornienie naszej aplikacji na ataki typu CSRF poprzez wprowadzenie do żądania dodatkowej wartości, której atakujący nie jest w stanie przewidzieć lub uzyskać w inny łatwy sposób. Czy atakujący jest w stanie poznać istniejącą wartość cookie lub wymusić jej ustawienie? Właściwa odpowiedź brzmi: to zależy.

Po pierwsze rozwiązanie to nie będzie skuteczne, jeśli aplikacja podatna jest na XSS. Atakujący będzie w stanie:

Tylko trzeba pamiętać, że takie same słabości mają inne techniki obrony przed CSRF. Ogólnie rzecz biorąc jeśli atakujący ma XSS to może “automatyzować” działanie klienta. W zasadzie jedynymi rozwiązaniami, które mogą się w pewnym stopniu bronić będą te, które wymagają interakcji użytkownika (np. CAPTCHA czy przepisanie wskazania tokenu), choć nawet wówczas użytkownik nie może być pewny, że aplikacja wykona taką operację, jaką on chciał wykonać. Po prostu jeśli w aplikacji działa obcy kod (a do tego sprowadza się XSS), aplikacja może działać w kompletnie inny sposób, niż tego oczekujemy.

Czy są jakieś inne ciekawe scenariusze, w których to rozwiązanie jest nieskuteczne? Uprośćmy nasze rozważania i skupmy się tylko na różnicy między tym rozwiązaniem, a rozwiązaniem, w którym token jest przechowywany po stronie sesji. Ta różnica to przekazywanie wartości tokenu w cookie. Atakujący będzie w stanie przygotować prawidłowe żądanie (z prawidłowym tokenem), jeśli będzie w stanie odczytać wartość cookie. Dla uproszczenia przyjmijmy, że cookie to nie jest chronione przez flagę httpOnly. Nie interesują nas również sytuacje, w których atakujący monitoruje ruch między ofiarą i serwerem, bo wówczas zamiast bawić się w CSRF (w końcu wówczas trzeba skłonić ofiarę do odwiedzenia strony) atakujący może po prostu przejąć sesję.

W takim przypadku wszystkie pomysły, które przychodzą mi do głowy sprowadzają się do znalezienia się w tym samym źródle (origin), co atakowana strona. A jeśli tak, to również “klasyczny” sposób ochrony przed CSRF z wartością tokenu zapisaną w sesji jest wówczas niewystarczający. Trzeba jednak zwrócić uwagę, że same-origin policy różni się nieco dla JavaScript i dla cookies. W rezultacie mogą zaistnieć ciekawe przypadki, ale w dość nietypowych scenariuszach. Na początku proponuję zapoznać się z HTTP cookies, or how not to design protocols, w szczególności właśnie z fragmentem wspominającym te różnice. A teraz przykład.

Załóżmy, że atakujący kontroluje domenę attacker.example.com , a atakowana domena jest dostępna pod adresem victim.example.com. Z punktu widzenia JavaScript to są dwa oddzielne origin , ale już w przypadku cookies nie. Jeśli atakujący jest w stanie zwabić ofiarę na swoją stronę (attacker.example.com) jest on w stanie ustawić cookie na *.example.com. Najpierw jednak może ustawić wiele losowych cookies. W większości przypadków to wystarczy, by później móc ustawić “właściwe” cookie o znanej wartości, którą to wartość następnie wyśle również w parametrze żądania przy ataku CSRF. Proste?

Całość można obejrzeć w tym miejscu:

Sugeruję w jednej zakładce otworzyć “ofiarę”, a w drugiej “atakującego”. I co, działa? Może zadziałać, ale nie musi.

Proponuję rzucić okiem na te przykłady przez Fiddlera i zobaczyć co się dokładnie dzieje, bo jest drobna różnica np. między Chrome i IE.

W przypadku Chrome żądanie, które jest wysyłane ostatecznie do serwera wygląda tak:

POST http://victim.threats.pl/ HTTP/1.1 Host: victim.threats.pl Connection: keep-alive Content-Length: 22 Cache-Control: max-age=0 Origin: http://attacker.threats.pl User-Agent: Mozilla/5.0 (Windows NT 5.1) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.83 Safari/537.1 Content-Type: application/x-www-form-urlencoded Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Referer: http://attacker.threats.pl/ Accept-Encoding: gzip,deflate,sdch Accept-Language: pl-PL,pl;q=0.8,en-US;q=0.6,en;q=0.4 Accept-Charset: ISO-8859-2,utf-8;q=0.7,*;q=0.3 Cookie: token=csrfdemo

action=&token=csrfdemo

W przypadku IE natomiast tak:

POST http://victim.threats.pl/ HTTP/1.1 Accept: image/gif, image/jpeg, image/pjpeg, image/pjpeg, application/x-shockwave-flash, application/xaml+xml, application/vnd.ms-xpsdocument, application/x-ms-xbap, application/x-ms-application, application/x-silverlight, / Referer: http://attacker.threats.pl/ Accept-Language: en-US,pl;q=0.5 User-Agent: Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30; .NET CLR 3.0.04506.648; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729; .NET4.0C; .NET4.0E) Content-Type: application/x-www-form-urlencoded Accept-Encoding: gzip, deflate Host: victim.threats.pl Content-Length: 22 Connection: Keep-Alive Pragma: no-cache Cookie: token=17191e8e18e08c4a7a2b4158f5251d6fbca1669f; token=csrfdemo

action=&token=csrfdemo

I tutaj trzeba jedno wyjaśnienie. Powyższy układ cookies będzie taki wyłącznie wtedy, jeśli ofiara najpierw odwiedzi stronę victim.threats.pl , a dopiero później attacker.threats.pl. Jeśli kolejność będzie odwrotna, to przy kolejnej akcji układ cookies będzie następujący:

POST http://victim.threats.pl/ HTTP/1.1 Accept: image/gif, image/jpeg, image/pjpeg, image/pjpeg, application/x-shockwave-flash, application/xaml+xml, application/vnd.ms-xpsdocument, application/x-ms-xbap, application/x-ms-application, application/x-silverlight, / Referer: http://attacker.threats.pl/ Accept-Language: en-US,pl;q=0.5 User-Agent: Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; .NET CLR 1.1.4322; .NET CLR 2.0.50727; .NET CLR 3.0.04506.30; .NET CLR 3.0.04506.648; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729; .NET4.0C; .NET4.0E) Content-Type: application/x-www-form-urlencoded Accept-Encoding: gzip, deflate Host: victim.threats.pl Content-Length: 22 Connection: Keep-Alive Pragma: no-cache Cookie: token=csrfdemo; token=4b16cb5147b668772abc9f5297257c620e264550

action= &token=csrfdemo

Ten przykład jest akurat w PHP i $COOKIE[“token”]_ zwraca pierwsze cookie o podanej nazwie. W rezultacie całe rozwiązanie może być podatne/niepodatne w zależności od kolejności odwiedzin.

W “prawdziwym” przypadku raczej możemy założyć, że ofiara najpierw powinna odwiedzić atakowaną aplikację. W CSRF chodzi przecież o to, by skorzystać z uwierzytelnionej sesji ofiary (bez jej wiedzy), a uwierzytelnienie raczej wymusza odwiedzenie aplikacji.

Cały atak ma jeszcze jeden skutek uboczny – po udanym ataku, w większości przeglądarek, aplikacja przestaje działać. Dlaczego? :)

Jeśli ktoś ma jakieś inne pomysły na atakowanie tego typu ochrony przed CSRF – czekam na szczegóły. W przypadku każdego ataku proponuję zastanowić się, czy:

I w tym miejscu dochodzę do sedna sprawy – double submit cookies może i nie jest rozwiązaniem doskonałym, ale może być rozwiązaniem wystarczająco dobrym, co zresztą można przeczytać w Cross-Site Request Forgery (CSRF) Prevention Cheat Sheet.

Oryginał tego wpisu dostępny jest pod adresem CSRF, double submit cookies i good enough

Autor: Paweł Goleń