Привет! Меня зовут Анастасия Соколенко, я отвечаю за безопасную разработку в Битрикс24. Вместе с коллегами мы не только проверяем код, который пишут наши разработчики, но и учим их делать его максимально безопасным.
Конечно, большинство разработчиков знакомы с разными уязвимостями веб-приложений, однако не все и не всегда применяют надлежащие методы защиты при написании кода.
Существует очень много типов уязвимостей, но я разберу несколько самых распространённых, которые обязательно нужно учитывать при разработке. Сегодня расскажу вам о двух, а еще пять рассмотрим в следующих частях этой статьи.
SQL-инъекции и XSS-атаки входят в топ уязвимостей по мнению экспертов в области ИБ. Лаборатория Касперского ставит их на 4 и 5 места в своем рейтинге. С них мы и начнём.
Экранирование: защищаемся от XSS-атак
Злоумышленники могут писать плохие вещи туда, куда, по-хорошему, писать бы только хорошее.
Например, они могут отправить через форму на сайте не требуемые данные, а вредоносный скрипт.
Все данные, которые мы получаем, либо отдаём пользователю в HTML или JavaScript, надо делать безопасными. Это значит, что все спецсимволы должны быть заменены HTML-аналогами, как при считывании данных из форм, так и при их выводе на страницу сайта.
Есть несколько функций, расскажу, как правильно их использовать и как нельзя делать.
Функция htmlspecialcharsbx
Также можно использовать метод \Bitrix\Main\Text\HtmlFilter::encode
.
Экранирует обычный текст перед выводом в HTML.
Вот пример, как надо:
<div><?=htmlspecialcharsbx($foo)?></div>
<textarea><?=htmlspecialcharsbx($foo)?></textarea>
И как не надо:
<div><?=$foo?></div>
<textarea><?=$foo?></textarea>
При этом значения HTML-атрибутов должны заключаться в двойные кавычки:
<input type="text" name="foo" value="<?=htmlspecialcharsbx($fooValue)?>" />
<select name="<?=htmlspecialcharsbx($name)?>" id="<?=htmlspecialcharsbx($id)?>">
С одиночными кавычками фокус не пройдёт и функция ничего не сделает. Так делать не надо:
<input type="text" name="foo" value='<?=htmlspecialcharsbx($fooValue)?>' />
CUtil::JSEscape
Экранирует строки, которые выводятся в JavaScript. Подходит для строк, заключённых в кавычки.
Вот так надо:
<script>
var foo = '<?=CUtil::JSEscape($foo)?>';
</script>
А вот так не надо (нет кавычек, это не строковый литерал):
<script>
var foo = <?=CUtil::JSEscape($foo)?>;
</script>
Тут функция аналогично отобразит JS в сыром формате, если злоумышленник попробует подставить какой-то код в переменную.
Json::encode
Эта функция помогает выводить JS-объекты и JSON-строки:
<?$foo = ['key' => 'value'];?>
<script>
var foo = <?=Bitrix\Main\Web\Json::encode($foo)?>;
</script>
CBXSanitizer
Если по какой-то причине нужно вывести HTML-код от пользователя, следует использовать класс CBXSanitizer. Его метод sanitizeHtml преобразует весь полученный HTML-текст.
То есть, делаем вот так:
<div><?=(new CBXSanitizer)->sanitizeHtml($foo);?></div>
Эта штука оставит в тексте только разрешённые теги (их даже можно настраивать, подробнее - в документации).
Пара замечаний
Можно не экранировать только текст, который никак не может быть изменён пользователем: явно заданные в коде строки (не относится к языковым фразам), автоинкрементные значения в БД и т. д.
У нас есть функция
htmlspecialcharsEx
, использовать её нежелательно. Ею можно воспользоваться только для вывода между тегами, когда нужно показать HTML-entity (она работает на основании чёрного списка и учитывает не все возможные варианты)
$foo = ''-prompt(/XSS/)-'';
$someData = CUtil::JSescape($foo);
$someData = htmlspecialcharsEx($someData);
?>
<script>
function doSome(message) {
console.log(message);
}
</script>
<a href="#" onclick="doSome('<?=$someData?>');">Do something!</a>
Если нужно экранировать HTML и JS одновременно (например, onclick), эскейпим двумя функциями:
<?
$someData = CUtil::JSescape($foo);
$someData = htmlspecialcharsbx($someData);
?>
<a href="#" onclick="doSome('<?=$someData?>');">Do something amazing!</a>
SQL инъекции
Все мы знаем, что такое SQL-инъекция и что так делать нельзя:SELECT * FROM b_user WHERE id=$_REQUEST['id'];
Но как тогда можно? Давайте разбираться.
К работе с базами данных у нас есть два подхода:
Прямые запросы (
CDatabase
иApplication::getConnection
) .ORM.
Прямые запросы
CDatabase
Она же $DB. Это класс для прямого взаимодействия с БД. В классе реализованы методы на все случаи жизни, но просто бездумное их использование нас не обезопасит.
Ключ к безопасности SQL-запросов — это приведение типов, экранирование и подготовленные выражения.
Первое — самое простое. Если в параметре должно быть целое число, приводим его к соответствующему типу. Пример выше сразу станет безопасным, если всего лишь поменять его таким образом:
$id = intval($_REQUEST['id']);
$res = $DB->Query("SELECT * FROM b_user WHERE id=$id");
Второе немногим сложнее. Если из запроса мы ждём строку, тогда нельзя позволить злоумышленнику выйти за пределы наших кавычек. В CDatabase
есть метод ForSql
, который этим и занимается. Результат его работы обязательно должен ставиться внутрь кавычек, иначе мы ни от чего не защитимся. Так абсолютно небезопасный запрос:
$login = $_REQUEST['login'];
$res = $DB->Query("SELECT * FROM b_user WHERE LOGIN='$login'");
Становится абсолютно безопасным:
$login = $DB->ForSql($_REQUEST['login']);
$res = $DB->Query("SELECT * FROM b_user WHERE LOGIN='$login'");
И, наконец, третье, самое универсальное. Для подготовленных выражений в CDatabase есть методы PrepareInsert и PrepareUpdate. Если мы хотим добавить или изменить записи в БД, тогда подготовленные выражения — то, что нам нужно. Под капотом этих функций происходят все необходимые преобразования входных данных.
Код ниже абсолютно безопасно вставляет в таблицу пользовательский ввод:
$arInsert = $DB->PrepareInsert("b_user", ["LOGIN" => $_REQUEST["login"]]);
$sql = "INSERT INTO b_user (".$arInsert[0].") VALUES (".$arInsert[1].")";
$res = $DB->Query($sql);
Замечания:
В функциях Add
, PrepareInsert
, PrepareUpdate
и PrepareUpdateBind
есть вот такой код:
if(array_key_exists("~".$strColumnName, $arFields))
{
$strInsert1 .= ", `".$strColumnName."`";
$strInsert2 .= ", ".$arFields["~".$strColumnName];
}
и
if(is_set($arFields, "~".$strColumnName))
{
$strUpdate .= ", $strTableAlias`".$strColumnName."` = ".$arFields["~".$strColumnName];
}
Это значит, что если в $arFields
какое-то поле начинается с ~, то оно попадёт в запрос без каких-либо преобразований (было поле ~field, а попадёт без обработки в поле field). Поэтому туда не должны передаваться непроверенные ключи.
Application::getConnection
Эта функция вернёт объект MysqliConnection
для выполнения запросов к базе, а для очистки запросов внутри неё есть функция getSqlHelper()
.
У объекта хелпера есть аналогичная функция forSql
:
$connection = \Bitrix\Main\Application::getConnection();
$helper = $connection->getSqlHelper();
$login = $helper->forSql($_GET['login']);
$res = $connection->query("SELECT * FROM b_user where LOGIN='$login'");
И аналогичные функции prepareInsert
и prepareUpdate
. Здесь актуальны все советы из предыдущего пункта.
ORM
ORM прекрасен не только удобством, но и безопасностью. С его помощью в большинстве случаев читать и записывать можно даже неэкранированные пользовательские данные. Но не забывайте про XSS-атаки, которые мы рассмотрели выше — в БД может попасть HTML/JS код. Также важно учитывать уязвимости, такие как IDOR, которые могут позволить пользователю получить к данным из БД доступ, которого у него быть не должно, из-за недостаточной проверки прав.
Однако и ORM-инъекции — это не миф.
Как это работает у нас:
При чтении данных из БД (метод getList) можно указать select и filter. И оба эти параметра уязвимы, если получают на вход пользовательские данные. В этом случае можно вытащить или сбрутить некоторые записи в текущей таблице, а если есть поле ReferenceField, то ещё и из связанных.
Select
Select можно задать прямо в getList
:
$result = \Bitrix\Landing\Internals\LandingTable::getList([
'select' => $some_select
]);
Или с помощью setSelect
:
$query = WorkgroupTable::query();
$query->setSelect($select);
Что произойдёт, если в него попадёт что-то непроверенное? Тогда метод вернёт данные из любых неприватных столбцов текущей таблицы (а вдруг у юзера не должно быть к ним доступа) или, через точку, из любой связанной:
$select = $_REQUEST['select']
//['ID' => 'ID', 'TITLE' => 'TITLE', 'LAST_NAME' => 'RESPONSIBLE.LAST_NAME'];
$result = \Bitrix\Tasks\Internals\TaskTable::getList([
'select' => $select
]);
var_dump($result->fetchAll());
/* ...
array(3) {
["ID"]=>
string(1) "1"
["TITLE"]=>
string(4) "Task Title"
["LAST_NAME"]=>
string(20) "Lastname"
...
}
*/
Filter
С фильтром ситуация аналогичная: при наличии ReferenceField
можно достать что-то из связанных таблиц, только выглядеть это будет как слепая инъекция. Например:
$result = \Bitrix\Main\UserTable::getList([
'filter' => $some_filter
]);
Если в фильтр попадает ['ID' => 1, '%=EMAIL' => "A%"]
и что-то возвращается, значит email начинается с буквы А. Если нет - пробуем другой символ. Немного времени с терпением и можно сдампить всё, что плохо лежит (пример абстрактный, подставьте нужное поле).
Решение аналогичное: никаких пользовательских массивов сюда попадать не должно, а входные данные должны проходить проверку.
Замечания:
Стоит упомянуть, что если колонка отмечена как приватная, её не получится сдампить такими методами. Но если полностью работа по разметке всей базы и всех полей не проведена — некоторые данные всё ещё могут быть извлечены.
Есть у нас такой класс
\Bitrix\Main\DB\SqlExpression
, нужный для определения пользовательских SQL-запросов в фильтрах ORM. Если там будет хотя бы один непроверенный параметр, получится очередная инъекция. Например:
$filterIds = [1, 2, 3, $_GET['id']];
//$_GET['id'] = (select * from b_user where login="admin")
$filterList = \Bitrix\Disk\Internals\VolumeTable::getList([
'filter' => [
'@ID' => new \Bitrix\Main\DB\SqlExpression(implode(', ', $filterIds)),
],
]);
Если передать сюда запрос, он отработает на сервере и вернёт желаемый ответ, а значит хакер получит из базы данных всё, что захочет.
Аналогично с \Bitrix\Main\Entity\ExpressionField
. Это тоже будет инъекцией:
$filterList = \Bitrix\Disk\Internals\VolumeTable::getList([
'runtime' => array(
new \Bitrix\Main\Entity\ExpressionField('CNT', $_GET['count'])
)
]);
Следовательно, сюда тоже нельзя передавать непроверенный пользовательский ввод.
На сегодня пока всё. В следующий раз разберу на примерах защиту от CSRF- и SSRF-атак, подписывайтесь и не пропустите продолжение :)