Безопасный код: Подделка межсайтовых запросов (CSRF)
Статья эвакуирована с DrupalDance.com
Поводом к написанию этой статьи послужило нахождение мною уязвимости в одном довольно известном модуле. Так как по правилам обнаружения уязвимостей, я пока не вправе распространяться о деталях, то расскажу об уязвимости в общих чертах, а также о методах борьбы с ней.
Итак, подделка межсайтовых запросов (анг. Сross Site Request Forgery, или, сокращенно, CSRF): что это такое и с чем его едят.
CSRF — это вид атак на посетителей веб-сайтов, использующий недостатки протокола HTTP. Если жертва заходит на сайт, созданный злоумышленником, от её лица тайно отправляется запрос на другой сервер (например, на сервер платёжной системы), осуществляющий некую вредоносную операцию (например, перевод денег на счёт злоумышленника). Для осуществления данной атаки, жертва должна быть авторизована на том сервере, на который отправляется запрос, и этот запрос не должен требовать какого-либо подтверждения со стороны пользователя.
Данный тип атак, вопреки распространённому заблуждению, появился достаточно давно: первые теоретические рассуждения появились в 1988 году, а первые уязвимости были обнаружены в 2000 году.
Одно из применений СSRF — эксплуатация пассивных XSS, обнаруженных на другом сервере. Так же возможны отправка спама от лица жертвы и изменение каких-либо настроек учётных записей на других сайтах(например, секретного вопроса для восстановления пароля).
Живой пример
Например, нам нужно сделать небольшой модуль, который должен аяксом удалять ноды. Это можно реализовать служебной ссылкой ноды, при нажатии которой, отправляется аякс запрос на друпаловский путь. К этому пути прицеплен обработчик, который и удаляет ноду. Вот примерно таким модулем все и делается:
node_destroy.module
/**
* Реализация hook_menu(). Регистрирует наш коллбек в системе меню.
*/
function node_destroy_menu() {
$menu['node/%node/destroy'] = array(
'page_callback' => 'node_destroy',
'page_arguments' => array(1),
'access_arguments' => array('administer nodes'),
'type' => MENU_CALLBACK,
);
return $menu;
}
/**
* Реализация коллбека.
*/
function node_destroy($node) {
if ($node->nid) {
node_delete($node->nid);
print('SUCCESS');
}
// в коллбеках для аякса почти всегда надо принудительно завершать скрипт,
// чтобы не выводить оформление сайта вместе с вашими данными
exit();
}
/**
* Реализация hook_link(). Добавляем свою ссылку в служебные ссылки ноды.
*/
function node_destroy_link($type, $node = NULL, $teaser = FALSE) {
switch ($type) {
case 'node':
// если эта функция вызывается, значит мы выводим ссылки ноды,
// а это значит, что нам и скрипты нужны
$path = drupal_get_path('module', 'node_destroy');
drupal_add_js($path .'/node_destroy.js');
// собственно, добавление ссылки
$links['node_destroy'] = array(
'title' => t('Destroy node'),
'href' => "node/$node->nid/destroy",
'attributes' => array('class' => 'node_destroy_link'),
);
break;
}
return $links;
}
node_destroy.js
// Таким нехитрым путем правильно инициализировать некие действия
// вместо обычного $(document).ready(function() { ... })
Drupal.behaviors.node_destroy = function(context) {
// Мы перебираем все наши ссылочки и навешиваем на них аякс запросы.
// Заметьте необычный селектор. Он предотвратит двойное навешивание обработчиков.
$('.node_destroy_link:not(.processed)', context).addClass('processed').click(function(){
href = $(this).attr('href');
$.ajax({
type: "GET",
url: href,
success: function(result){
// SUCCESS нам возвращает наш коллбек меню, если все замечательно
if (result != 'SUCCESS') {
alert('Error');
}
}
});
});
}
И все бы хорошо, но в один солнечный день, на сайт приходит злой тролль... Или более жизненная ситуация — озлобленный бывший сотрудник приходит на сайт и пытается его поломать. Помня старый опыт, он пробует зайти по адресу http://site.ru/node/123/destroy
, но получает от ворот поворот, так как уже не имеет прав на удаление материалов.
И тут, в порыве деструктивного креатива, он создает ноду с таким контетом:
![](http://site.ru/node/123/destroy)
Что происходит в этот момент? Никакая картинка, естественно, не подгрузится, но браузер тролля выполнит запрос на этот путь с прежним результатом.
Смирившись с неудачей, тролль уходит с сайта. Через день, администратор сайта замечает эту мусорную ноду, заходит в нее и удаляет. А вернувшись в список материалов, не находит в нем ноды с айдишником 123. Атака удалась. Занавес.
Для тех, кто не понял, когда администратор зашел в ноду, его браузер тоже ломанулся по ссылке картинки. Но здесь уже прав доступа хватило, и нода была успешно удалена, а админ даже ничего не заметил.
Как избежать CSRF уязвимостей?
Ответ — использовать уникальные ссылки для действий по изменению данных. Как это возможно? В друпале используется метод токенизации ссылок. Это означает, что к ссылке активного действия, прибавляется уникальный параметр, который проверяется при осуществлении самого действия. В друпале сгенерировать такой параметр можно функцией drupal_get_token(). Проверить —drupal_valid_token(). Токен генерируется на основе подаваемого значения, сессии пользователя, а также приватного ключа сайта, что практически сводит на ноль вероятность генерации вредителем правильного токена.
Внесем изменения в наш модуль. Начнем с выставления правильной ссылки:
function node_destroy_link($type, $node = NULL, $teaser = FALSE) {
switch ($type) {
case 'node':
$path = drupal_get_path('module', 'node_destroy');
drupal_add_js($path .'/node_destroy.js');
$links['node_destroy'] = array(
'title' => t('Destroy node'),
'href' => "node/$node->nid/destroy",
'attributes' => array('class' => 'node_destroy_link'),
// query — это все GET параметры, т.е. все что в ссылке находится после знака вопроса
// мы добавляем параметр token
'query' => 'token='. drupal_get_token('node_destroy_'. $node->nid)
);
break;
}
return $links;
}
Как вы помните, мы шлем аякс запрос по адресу, который зашит в ссылке, поэтому в коллбеке нам остается только проверить $_GET
стандартным способом.
function node_destroy($node) {
if ($node->nid && isset($_GET['token']) && drupal_valid_token($_GET['token'], 'node_destroy_'. $node->nid)) {
node_delete($node->nid);
print('SUCCESS');
}
exit();
}
Хотите что-то добавить?