Привет! Меня зовут Анастасия Соколенко, я отвечаю за безопасную разработку в Битрикс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-атак, подписывайтесь и не пропустите продолжение :)
