* Double submit cookie
Публикувано на 14 май 2024 в раздел ОСУП.
Техниката за двойно изпращане на бисквитка се използва за превенция на XSRF атаки в случаите, в които не желаем да записваме XSRF token в сесията или просто не използваме сесии.
Идеята е да запишем xsrf token в клиентско cookie. После този token ще се вмъкне в скрито поле в HTML формата, която трябва да бъде защитена. При приемането на резултата ще се прави проверка дали записаното в бисквитката съвпада с подаденото през формата. Защитата се корени в това, че ако хакерът накара клиента да отиде на някакъв негов уебсайт и да попълни автоматично форма, която изпраща данните към нашия, той не може да прочете бисквитките на клиента и съответно няма да знае какъв е записания token. Така въпреки, че с нашата заявка ние ще изпратим бисквитката с въпросния token, той няма да присъства в подадените от формата полета и съответно валидацията ще пропадне.
Нека започнем от пример с уязвима форма. Във файл form.php имаме следната уязвима на XSRF форма:
<!DOCTYPE html> <html lang="bg_BG"> <head> <title>Форма, защитена с Double cookie submit</title> <meta charset="utf-8"> </head> <body> <form action="form_destination.php" method="POST"> <input type="text" name="message" /> <input type="submit" name="submit" value="Изпрати" /> </form> </body> </html>
А във form_destination.php следното:
<?php if(!isset($_POST['message'])){ header("Location: form.php"); exit; } echo htmlentities($_POST['message'], ENT_QUOTES, "utf-8"); ?>
Най-простата форма на xsrf token за техниката double cookie submit е да генерираме случайно число с голяма ентропия и да го запишем в cookie при клиента. Това би могло да се случи с javascript (но в този случай httponly флага на бисквитката трябва да бъде изключен, а това я прави уязвима при евентуални XSS атаки). Друг вариант е преди достигането до страницата с формата (т.е. на предхождаща страница - например по време на login) да сме задали бисквитката по следния начин:
<?php $statelessXSRFToken = base64_encode(openssl_random_pseudo_bytes(32)); setcookie("statelessXSRFToken", $statelessXSRFToken, ['expires'=>0, 'samesite'=>'Lax', 'httponly' => true, 'secure'=>true]); ?>
След като сме задали COOKIE, можем да използваме statelessXSRFToken, за да защитим нашата форма:
form.php:
<!DOCTYPE html> <html lang="bg_BG"> <head> <title>Форма, защитена с Double cookie submit</title> <meta charset="utf-8"> </head> <body> <form action="form_destination.php" method="POST"> <input type="text" name="message" /> <input type="hidden" name="statelessXSRFToken" value="<?=$_COOKIE['statelessXSRFToken']?>" /> <input type="submit" name="submit" value="Изпрати" /> </form> </body> </html>
Този начин на работа може да е неудобен, защото не можем да имаме форма, която е директно защитена - трябва първо да имаме страница, в която създаваме token и чак след това можем да отидем да използваме формата. Бихте могли да решите този проблем като генерирате различен token на всяка страница - това има сериозни предимства и от гледна точка на сигурността, но същевременно създава известни проблеми на потребителите (нови табове, бутона назад в браузъра, и т.н.). Повечето от тези проблеми са отстраними, но засега ще ги оставим на заден план и ще използваме следния код за удобство при тестовете:
form.php:
<?php // вместо това може да генерирате token през JavaScript, но тогава // ще трябва да задавате cookie с 'httponly'=>false, което го прави // уязвимо на потенциални XSS атаки $statelessXSRFToken = base64_encode(openssl_random_pseudo_bytes(32)); setcookie("statelessXSRFToken", $statelessXSRFToken, ['expires'=>0, 'samesite'=>'Lax', 'httponly'=>true, 'secure'=>true]); ?> <!DOCTYPE html> <html lang="bg_BG"> <head> <title>Форма, защитена с Double cookie submit</title> <meta charset="utf-8"> </head> <body> <form action="form_destination.php" method="POST"> <input type="text" name="message" /> <input type="hidden" name="statelessXSRFToken" value="<?=$statelessXSRFToken?>" /> <input type="submit" name="submit" value="Изпрати" /> </form> </body> </html>
form_destination.php:
<?php if(!isset($_POST['statelessXSRFToken'], $_POST['message'])){ header("Location: form.php"); exit; } if($_POST['statelessXSRFToken']!==$_COOKIE['statelessXSRFToken']){ echo $_POST['statelessXSRFToken']; exit; } echo htmlentities($_POST['message'], ENT_QUOTES, "utf-8"); ?>
Това е най-базовата възможна защита, позната още като наивна. По принцип не е препоръчителна.
Проблемът с фиксиране на XSRF token
Макар и много прост, този вариант на защита за съжаление има потенциален недостатък. Ако сайтът няма HSTS (уязвим е на SSLStrip атака) и хакерът има възможност на пренасочва и модифицира негови заявки (man in the middle атака), то би могло да бъде осъществен следния сценарий:
- Клиентът желае да отвори mywebsite.dom.
- Хакерът извършва SSLStrip и му изпраща фалшив отговор от сървъра, като кара клиента да запише cookie с измислен от него XSRF token.
- След това хакерът пренасочва клиента към своя страница, където е заложил форма, в която измисления от него XSRF token е попълен в скрито поле.
- Накрая кара браузъра на клиента автоматично да натисне бутона на мнимата форма.
Макар и с много условности, това е потенциална уязвимост, отстраняването на която не зависи от кода на приложението, а от администраторите на домейна (трябва да активират HSTS, за да няма SSLStrip).
За да гарантираме в кода, че XSRF token е издаден от нас, можем да направим следното (това все още не решава описания проблем):
form.php
<?php DEFINE("XSRF_KEY", "1NWNmvm2p8Iew&hxUtaoX5PHQTDoyslb"); $statelessXSRFToken = base64_encode(openssl_random_pseudo_bytes(32)); setcookie("statelessXSRFToken", hash_hmac("sha256", $statelessXSRFToken, XSRF_KEY), ['expires'=>0, 'samesite'=>'Lax', 'httponly'=>true, 'secure'=>true]); ?> <!DOCTYPE html> <html lang="bg_BG"> <head> <title>Форма, защитена с Double cookie submit</title> <meta charset="utf-8"> </head> <body> <form action="form_destination.php" method="POST"> <input type="text" name="message" /> <input type="hidden" name="statelessXSRFToken" value="<?=$statelessXSRFToken?>" /> <input type="submit" name="submit" value="Изпрати" /> </form> </body> </html>
form_destination.php:
<?php DEFINE("XSRF_KEY", "1NWNmvm2p8Iew&hxUtaoX5PHQTDoyslb"); if(!isset($_POST['statelessXSRFToken'], $_POST['message'])){ header("Location: form.php"); exit; } if(hash_hmac("sha256", $_POST['statelessXSRFToken'], XSRF_KEY)!==$_COOKIE['statelessXSRFToken']){ echo 'XSRF'; exit; } echo htmlentities($_POST['message'], ENT_QUOTES, "utf-8"); ?>
По този начин предотвратихме възможността хакера да фиксира генериран от самия него XSRF token, но все още сме уязвими. Например ако има SSLStrip (или пък XSS уязвимост и флагът 'httponly' за бисквитката е изключен, защото например желаем да сме по-гъвкави и да генерираме бисквитката динамично през JavaScript), хакерът ще може да ни накара отново да използваме негов фиксиран XSRF token, като направи следното:
- Да отиде на нашия уебсайт и да генерира валиден подписан XSRF token за самия себе си.
- Да използва SSLStrip или XSS уязвимостта и да ни накара да фиксираме в cookie неговия валиден XSRF token.
- Оттам да извърши XSRF атаката - той вече може да генерира своя форма, която ще премине през защитата.
Решението на този проблем е да вградим като част от ключа нещо, което е уникално за потребителя. Като минимум може да се извлекат някакви характеристики на браузъра на клиента, но това е несигурно, защото вероятно хакерът може да ги предскаже (ако не успее да ги извлече от нас). За съжаление опциите ни при stateless решения не са много.
Ако обаче използваме сесия (напр. системата е с вход с наш потребителски профил), можем да конкатенираме към ключа сесийното id, което е гарантирано уникално - тогава хакерът няма да може да ни накара да използваме неговия token, защото той няма да съвпада.
form.php:
<?php session_start(); session_regenerate_id(true); DEFINE("XSRF_KEY", "1NWNmvm2p8Iew&hxUtaoX5PHQTDoyslb"); $statelessXSRFToken = base64_encode(openssl_random_pseudo_bytes(32)); setcookie("statelessXSRFToken", hash_hmac("sha256", $statelessXSRFToken, XSRF_KEY.session_id()), ['expires'=>0, 'samesite'=>'Lax', 'httponly'=>true, 'secure'=>true]); ?> <!DOCTYPE html> <html lang="bg_BG"> <head> <title>Форма, защитена с Double cookie submit</title> <meta charset="utf-8"> </head> <body> <form action="form_destination.php" method="POST"> <input type="text" name="message" /> <input type="hidden" name="statelessXSRFToken" value="<?=$statelessXSRFToken?>" /> <input type="submit" name="submit" value="Изпрати" /> </form> </body> </html>
form_destination.php:
<?php session_start(); DEFINE("XSRF_KEY", "1NWNmvm2p8Iew&hxUtaoX5PHQTDoyslb"); if(!isset($_POST['statelessXSRFToken'], $_POST['message'])){ header("Location: form.php"); exit; } if(hash_hmac("sha256", $_POST['statelessXSRFToken'], XSRF_KEY.session_id())!==$_COOKIE['statelessXSRFToken']){ echo 'XSRF'; exit; } // променяме го след валидацията на XSRF session_regenerate_id(true); echo htmlentities($_POST['message'], ENT_QUOTES, "utf-8"); ?>
С това решение проблемът е напълно решен, защото дори хакерът чрез XSS да открадне сесийното id, няма да може да генерира свой XSRF токен с него, който да фиксира (друг е въпроса, че открадването на сесийното id вероятно ще доведе до много по-тежки последици за потребителя и XSRF ще е проблем на много по-заден план).
Дали да се използва сесийно id е много спорно. Причината да използваме double cookie техниката вместо да вграждаме xsrf token генериран от страна на сървъра и записан в сесийна променлива е, че искахме защитата да е stateless. В случая добавяме сесия и правим отново всичко stateful...
П.П. За отстраняването на проблема с back бутона на браузъра или зареждането на страницата в отделен tab, може просто да се откажем да използваме различен token, а вместо това да е винаги един и същи (например да се задава еднократно при login). Това не се счита за сериозно намаляване на защитата при положение, че cookies са httponly (не могат да бъдат откраднати например през JavaScript при наличие на XSS уязвимост), както и ако сме се погрижили за BREACH. Но ако все пак искаме XSRF token да е различен на всяка страница, можем на направим следното:
- Да получаваме един статичен, който ни се задава еднократно;
- На всяка страница да се генерира втори - динамичен, който е различен за всеки refresh;
- Динамичния се подписва цифрово със статичния в нов, който ще наричаме подписан. Динамичният и подписаният се изпращат като двойка, след което се проверява дали подписът е валиден.
Тази техника вече е коментирана в статията за XSRF. Ето примерно решение:
form.php:
<?php // Еднократно генерираме фиксиран XSRF token if(!isset($_COOKIE['fixedXSRFToken'])){ $fixedXSRFToken = base64_encode(openssl_random_pseudo_bytes(32)); setcookie("fixedXSRFToken", $fixedXSRFToken, ['expires'=>0, 'samesite'=>'Lax', 'httponly'=>true, 'secure'=>true]); } else{ $fixedXSRFToken = $_COOKIE['fixedXSRFToken']; } // Генерираме уникален token за текущата страница $dynamicXSRFToken = base64_encode(openssl_random_pseudo_bytes(32)); // Подписваме го с фиксирания + уникален секрет от сорс кода // Тук е препоръчително към ключа да се добави уникална характеристика // за потребителя - например session_id, ако решим да използваме сесия DEFINE("XSRF_KEY", "1NWNmvm2p8Iew&hxUtaoX5PHQTDoyslb"); $signedXSRFToken = hash_hmac("sha256", $dynamicXSRFToken, $fixedXSRFToken.XSRF_KEY); ?> <!DOCTYPE html> <html lang="bg_BG"> <head> <title>Форма, защитена с Double cookie submit</title> <meta charset="utf-8"> </head> <body> <form action="form_destination.php" method="POST"> <input type="text" name="message" /> <input type="hidden" name="dynamicXSRFToken" value="<?=$dynamicXSRFToken?>" /> <input type="hidden" name="signedXSRFToken" value="<?=$signedXSRFToken?>" /> <input type="submit" name="submit" value="Изпрати" /> </form> </body> </html>
form_destination.php:
<?php DEFINE("XSRF_KEY", "1NWNmvm2p8Iew&hxUtaoX5PHQTDoyslb"); if(!isset($_COOKIE['fixedXSRFToken'], $_POST['dynamicXSRFToken'], $_POST['signedXSRFToken'], $_POST['message'])){ header("Location: form.php"); exit; } if(hash_hmac("sha256", $_POST['dynamicXSRFToken'], $_COOKIE['fixedXSRFToken'].XSRF_KEY)!==$_POST['signedXSRFToken']){ echo 'XSRF'; exit; } echo htmlentities($_POST['message'], ENT_QUOTES, "utf-8"); ?>
Добави коментар