Безопасность PHP скриптов

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

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

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

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

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

Из-за этого мы изначально считаем, что любой код имеет потенциальные проблемы безопасности. И задача разработчика свести предполагаемый ущерб к минимуму. Я поделюсь своим опытом в этом вопросе.

Защита от прямого вызова

Как правило код располагается в нескольких php-файлах. Подключение обычное — либо require(), либо через автозагрузку классов, но суть в том, что в итоге код оказывается в нескольких файлах.

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

<?php
// файл a.php
// что-то делаем, потом подключаем файл
require 'b.php';
 
<?php
// файл b.php
echo 'Привет!';

По задумке разработчика вызываться для исполнения должен только a.php, но любой посторонний посетитель может вызвать b.phpпрямо из браузера. Если этот файл принимает данные, скажем из POST/GET-запроса, то нет никаких сложностей его передать напрямую в исполняемый код.

Чтобы защититься от прямого вызова php-файла используется несколько подходов.

Первый — защита на уровне сервера.

Например переместить все php-файлы из public_htmlв каталог выше. Таким образом посетители в принципе не смогут получить к ним доступ. Другой способ — использование .htaccessв каталоге php-файлов с директивой Deny from all. Работает идентично, только проще для использования.

Второй способ — защита на уровне PHP. Здесь мы не полагаемся на сервер, а встраиваем защиту в каждый php-файл.

<?php
// файл a.php
define('MYSCRIPT', 1);
 
// что-то делаем, потом подключаем файл
require 'b.php';
 
<?php
// файл b.php
if (!defined('MYSCRIPT')) exit('No direct script access allowed');
 
echo 'Привет!';

В первом файле задаётся произвольная константа MYSCRIPT, а во всех подключаемых файлах, проверяется её существование. Таким образом, при прямом вызове php-файла скрипт завершит работу с сообщением «No direct script access allowed». И уже не важно, будет ли в файле какая-то потенциальная брешь в безопасности или нет. Выполнить код получится только в одном случае — через первый файл.

Где можно не делать такую защиту

Есть три случая, когда можно не использовать защиту от прямого вызова.

Первый — если файл состоит только из объявлений функций, но не использует их.

<?php
// файл lib.php 
function a1() { ... код функции ... }
function a2() { ... код функции ... }
function a3() { ... код функции ... }

Если сделать прямой вызов такого файла, то ничего не произойдёт, поскольку в файле нет реального выполнения функций. А вот если файл состоит из объявления функций и их вызовов, то такой файл уже нужно защищать. То есть вот так делать не следует:

<?php
// выполнение 
$a = a1();
function a1() { ... код функции ... }

Второй случай — это файл php-класса. Как правило они содержат только объявление класса, но не его использование. Поэтому прямой вызов файла, так же ни к чему не приведёт.

Третий случай — если это «return-файл». Такой вариант часто встречается для конфигурационных файлов:

<?php
return [
	'user' => 'Bill'	
];

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

Вывод ошибок

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

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

  • Вы элементарно не знаете, что сайт работает с ошибкой и это часто приводит к проблемам вывода html-кода, тормозам сервера и т.д.
  • Чтобы найти эту ошибку нужно как минимум посмотреть логи на сервере. Стоит ли говорить, что у многих он просто отключен?

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

Получение данных от пользователя

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

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

«Быстрая» реакция

Например пользователь заполняет форму, скрипт на сервере её получает и формируется email-письмо, которое приходит вам. В данном случае полученные данные нигде не сохраняются, а значит, если предположить существование уязвимости, то проблемой может быть:

  • некорректный email — письмо либо не отправится, либо не получится.
  • вредоносный код в тексте письма, который ловится почтовой программой.

В самом-самом худшем случае, выполнение скрипта сможет привести к php-ошибке, но вредоносные действия никак не скажутся ни на сервере, ни на работе сайта.

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

Чтобы предотвратить подобные атаки используется валидация данных. Например мы точно знаем, что нужен email-адрес. Если он указан в некорректном формате, значит никакой отправки почты и быть не может. Поэтому мы должны проверить адрес:

<?php
$email = $_POST['email'] ?? '';
 
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
	// ошибочный адрес
}
...

Валидация нужна для проверки email, адреса сайта, допустимые символы имени пользователя, пароля и т.д. Это критичные данные и если они некорректные, то нужно сообщить пользователю, что они ошибочные. При этом, естественно, основная работа скрипта (отправка письма) выполнена не будет.

Фильтрация данных

В некоторых случаях данные можно отфильтровать. Например в имени пользователя используются крайние пробелы. Возможно они были нажаты по ошибке, а значит данные можно пропустить через trim()и тем самым получить корректные данные.

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

Или пользователь ввёл имя, которое состоит из некорректных символов. Можно исправить их через функцию фильтрации и показать пользователю корректный вариант.

То есть там, где можно исправить ввод пользователя — это делается через фильтрацию.

Валидация данных на входе

Сейчас HTML 5 предлагает множество готовых вариантов ввода данных (тэг INPUT). Поэтому нужно их использовать. Это позволяет уже на этапе ввода и отправки данных, передать задачу валидации браузеру.

Конечно же это рассчитано на добросовестных пользователей и не отменяет проверок на уровне сервера.

Сохранение данных пользователя

Здесь похожая схема — нужно проверить данные перед тем, как отправлять её в базу данных. Но возникает ещё одна проблема: SQL-инъекции — когда злоумышленник пытается подобрать SQL-запрос во входящих данных так, чтобы он был выполнен в теле основного SQL-запроса.

Поэтому перед тем, как что-то добавлять в базу, нужен этап валидации и фильтрации. А уже после этого необходимо обеспечить корректное добавление данных. Поскольку в основном используется MySQL, то данные необходимо пропускать через функцию mysqli_real_escape_string(), которая выполняет экранирование специальных символов так, чтобы они не повлияли на основной SQL-запрос.

Важный момент — экранирование символов нужно только для защиты от SQL-инъекции, но это не отменяет тот факт, что вредоносный код может быть всё равно добавлен в базу, поскольку представляет собой обычный текст. Именно поэтому и нужна начальная фильтрация данных.

Другой способ — использовать PDO::prepare. Сейчас многие используют PDO, поэтому такой способ более предпочтительный.

Вывод данных

Предположим пользователь отправил комментарий с кодом <script>alert(1)</script>. Такой код проходит все проверки и добавляется в базу данных. После этого он выводится на сайте и, естественно, срабатывает.

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

Решается это на этапе вывода данных. Общий смысл в том, что вывод также нужно обрабатывать.

Например, если данные выводятся в поле INPUT, то его необходимо пропускать через htmlspecialchars()

<input type="text" name="username" value="<?= htmlspecialchars($username, ENT_QUOTES) ?>">

Такой подход позволит сразу обнаружить проблемную зону. Но, что делать, если данные на самом деле состоят из html-тэгов?

Здесь используется функция strip_tags(), которая удаляет все тэги, кроме разрешённых.

echo strip_tags($text, '<img><strong><em><i><b><u><s><blockquote>');

Таким образом, хоть в данных и будет вредоносный код, на сайте он не будет выведен.

XSS-фильтрация

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

Решение состоит в том, чтобы пропускать текст для вывода через специальную функцию, которая умеет находить опасные куски кода и сигнализирует об этом. Например в MaxSite CMS используются функции CodeIgniter.

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

$textXSS = myFilterXSS($text); // функция возвращает отфильтрованный текст
 
if ($text != $textXSS) {
	// возможно опасное содержимое
	...
}

Например в форме отправки вы точно знаете, что нельзя применять тэг SCRIPT. Значит в своей myFilterXSS()можно проверить наличие этого тэга и если он есть, то блокировать отправку почты. Другой способ — прогнать входящий текст через strip_tags()и если там будут запрещённые тэги, то также блокировать отправку. Таким образом почта будет отправлена только с корректными данными.

С базой данных делается аналогично: вначале проверка, потом действие.

URL-атака

Если сайт принимает POST/GET-запросы, то это первый сигнал, что входные данные нужно обрабатывать. Но, даже если сайт не работает с входными данными, всё равно существует угроза атаки через входящий URL.

Например я часто встречаю такие запросы:

https://сайт/(select(0)from(select(sleep(6)))v)

Здесь злоумышленник пытается подобрать SQL-запрос, поскольку предполагает, что адрес URL напрямую участвует в запросе к базе данных.

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

Например навигация по сайту строится как get-параметр от корня сайта: https://сайт/contact— подключит contact.php, а https://сайт/aboutabout.php.

$page = ...; // данные из $_GET;
 
require $page . '.php';

Так делать нельзя, поскольку через адрес можно передать имя любого другого файла, включая уровнем выше. Даже если мы поставим file_exists(), всё равно можно подобрать существующий файл на сервере.

Поэтому такие случаи должны работать «жестко»:

$page = ...; // данные из $_GET;
 
if ($page == 'contact') require 'contact.php';
elseif ($page == 'about') require 'about.php';
else require '404.php';
То есть смысл в том, что по возможности не использовать get-запрос напрямую в исполняемой функции.

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

Даже если ваш сайт использует достаточно сложный роутинг, всё равно есть риск атаки через URL-адрес. Если работа сайта базируется именно на роутинге, то иногда проще запретить некоторые символы в адресе сайта. Например в Albireo Framework нельзя использовать в адресах страниц символы < > " ' ( { [, поскольку они могут являться частью вредоносного кода. Эти символы в 99,99% случаев не используются в адресах, так что это не создаёт проблем пользователям.

Некоторые атаки очень специфичные. Например есть атака через svg/onload. В адрес добавляется код %3Csvg/onload=alert(1)%3E, который не что иное, как <svg/onload=alert(1)>. Если мы получаем текущий адрес страницы как $_SERVER['REQUEST_URI']и вводим его где-то на сайте, то по факту происходит внедрение вредоносного кода.

Однако стоит всё-таки отметить, что такие атаки носят больше теоретический характер, поскольку нужно ещё убедить «жертву» нажать такую ссылку. Здесь на сцену выходят уже другие приёмы, больше психологические. Но это уже другая история. :-)

Оставьте комментарий!

Комментарий будет опубликован после проверки. Вы соглашаетесь с правилами сайта.

(обязательно)