- Защита от SQL-инъекций в PHP
- Что может сделать злоумышленник?
- Экранирование кавычек
- Неэффективные способы защиты от SQL-инъекций
- 1. Функция htmlspecialchars()
- 2. Фильтрация по чёрному списку символов
- 3. Функция stripslashes()
- 4. Функция addslashes()
- Эффективные способы защиты
- 1. Функция mysql(i)_real_escape_string
- 2. Приведение к числу
- 3. Подготовленные запросы
- 4. Готовые библиотеки
Защита от SQL-инъекций в PHP
SQL инъекция — это подстановка в SQL-запрос таких данных, которые меняют структуру этого запроса. Злоумышленник может использовать уязвимость для выполнения произвольного SQL-кода.
Представим типичную задачу — вывод статей на сайте. При переходе по адресу /index.php?id=15 должна быть отображена статья, идентификатор которой в базе данных равен числу 15.
Как начинающие разработчики обычно пишут запрос к базе данных:
$query = 'SELECT * FROM `articles` WHERE `id` = ' . $_GET['id'];
Разработчик ожидает, что в $_GET[‘id’] будет число и конечный запрос станет таким:
SELECT * FROM `articles` WHERE `id` = 15
Но вместо этого злоумышленник может передать строку -1 OR 1=1 :
SELECT * FROM `articles` WHERE `id` = -1 OR 1=1
При запуске этого запроса будут выбраны все записи вместо одной, поскольку записей с отрицательными идентификаторами скорее всего нет в базе, а условие 1=1 всегда истинно.
Но суть в другом. После фрагмента 1=1 злоумышленник может дополнить запрос любым произвольным SQL-кодом.
Что может сделать злоумышленник?
Это зависит от конкретного запроса, а также способа его запуска.
Если запрос выполняется не через функцию mysqli_multi_query() , которая поддерживает мультизапросы (несколько запросов через точку с запятой), тогда у злоумышленника нет возможности выполнить совсем произвольный запрос вроде такого:
SELECT * FROM `articles` WHERE `id` = 1; DROP TABLE `articles`
Так сделать не получится, поскольку выполнение нескольких запросов по-умолчанию не поддерживается.
Но кое-что плохое злоумышленник сделать может. Например, с помощью UNION можно получить любые данные из любых таблиц.
Представим, что у нас есть таблица articles с 4 полями: id | title | content | created_at , а также таблица users с 3 полями: id | login | password .
Поскольку UNION позволяет объединять данные из таблиц только с одинаковым количеством столбцов, злоумышленник может указать 2 необходимых ему столбца, а остальные 2 заполнить любыми значениями, например единицами:
SELECT * FROM `articles` WHERE `id` = -1 UNION SELECT 1, `login`, `password`, 1 FROM `users`
В итоге вместо title и content на страницу будут выведены login и password одного из пользователей. И это только один из десятков возможных вариантов взлома.
Экранирование кавычек
Прежде чем перейти к существующим способам защиты, хочу отдельно объяснить, что такое вообще экранирование и зачем оно нужно.
$name = 'Вася'; $query = "UPDATE `users` SET `name` = '$name'";
С этим запросом всё в порядке, он выполнится как мы и ожидаем:
UPDATE `users` SET `name` = 'Вася'
Но что если в переменной $name будет одинарная кавычка?
Тогда SQL-запрос станет таким:
UPDATE `users` SET `name` = 'Д'Артаньян'
Попытка выполнить этот запрос приведёт к ошибке синтаксиса. Чтобы её не было, вторую кавычку нужно экранировать, т.е. добавить к ней обратный слеш.
Способы экранирования и их надёжность разберём чуть ниже, а сейчас для простоты возьмём addslashes() :
UPDATE `users` SET `name` = 'Д\'Артаньян'
Готово, запрос выполнится даже при наличии кавычек.
Экранировать можно не только кавычки. Разные функции умеют экранировать разные символы, об этом мы подробно поговорим чуть позже.
А теперь важный момент. Некоторые разработчики считают, экранирования достаточно для полной защиты от SQL-инъекций.
Хорошо, ещё раз посмотрим на самый первый пример с SQL-инъекцией:
$_GET['id'] = '-1 OR 1=1'; $query = 'SELECT * FROM `articles` WHERE `id` = ' . $_GET['id'];
SELECT * FROM articles WHERE OR 1=1
В этом запросе нет никаких кавычек. Но уязвимость есть. Отсюда делаем вывод, что экранирование не гарантирует защиту от SQL-инъекций.
Неэффективные способы защиты от SQL-инъекций
Очевидно, самый худший вариант — не иметь никакой защиты от SQL инъекций и передавать данные, полученные от пользователя, напрямую в SQL-запрос.
$query = 'SELECT * FROM `users` WHERE `id` = ' . $_GET['id'];
Никогда так не делай! Любые данные перед подстановкой в SQL-запрос должны проходить фильтрацию и/или валидацию.
1. Функция htmlspecialchars()
Время от времени встречаю статьи, где авторы используют функцию htmlspecialchars() для экранирования данных:
$name = "Д'Артаньян"; $name = htmlspecialchars($name); $query = "UPDATE `users` SET `name` = '$name'";
Это опасно! Штука в том, что функция htmlspecialchars() пропускает без экранирования опасные символы: \ (слеш), \0 (nul-байт) и \b (backspace).
Вот полный пример кода, демонстрирующего уязвимость:
$mysqli = new mysqli('localhost', 'root', 'password', 'database'); $login = '\\'; $password = ' OR 1=1 #'; $login = htmlspecialchars($login, ENT_QUOTES, 'UTF-8'); $password = htmlspecialchars($password, ENT_QUOTES, 'UTF-8'); $sql = "SELECT * FROM `users` WHERE `login` = '$login' AND `password` = '$password'"; $items = $mysqli->query($sql) or die($mysqli->error); while($item = $items->fetch_assoc()) < var_dump($item); echo '
'; >
В итоге SQL-запрос будет таким:
SELECT * FROM `users` WHERE `login` = '\' AND `password` = ' OR 1=1 #'
С помощью / экранируется кавычка, идущая сразу после $login. `login` = ‘$login’ по факту превращается в `login` = ‘\’ AND `password` = ‘ . После этого любой код, который мы напишем, будет выполнен, в нашем случае это просто OR 1=1 . В конце добавляем # (комментарий), чтобы скрыть последнюю кавычку.
2. Фильтрация по чёрному списку символов
По каким-то непонятным мне причинам ещё существуют разработчики, использующие чёрные списки символов:
$disallow = ['~', '\'', '"', '', '.', '%']; $name = 'Вася'; $name = str_replace($disallow, '', $name); $query = "SELECT * FROM `users` WHERE `name` = '$name'";
Все символы, входящие в чёрный список, удаляются из строки перед вставкой в базу.
Я не хочу сказать, что этот подход не будет работать, но его применение под большим вопросом:
- Зачем вообще составлять какие-то списки, если есть более простые и надёжные способы защиты?
- Нужно знать все потенциально опасные символы.
- Что делать если нужно разрешить пользователям использовать какие-либо символы из списка?
Кроме этого, я считаю фильтрацию в SQL-запросах плохой идеей. Если в строке есть недопустимые символы — лучше сообщить о них пользователю и попросить исправить, а не просто обрезать часть контента.
К примеру, пользователь хочет использовать логин ~!Mega_!Pihar!_!9000!~ , а после регистрации оказывается, что его ник превратился в MegaPihar9000 .
Я считаю, лучше уточнить у пользователя, нравится ли ему такой отфильтрованный логин или он хотел бы что-то поменять. Короче, я за валидацию по белому списку вместо фильтрации по чёрному.
3. Функция stripslashes()
Редко, но встречается код, использующий stripslashes() перед записью в базу. Поскольку новички до сих пор копируют этот код в свои проекты, объясню, зачем эта функция нужна.
Раньше в PHP была такая штука как волшебные кавычки (Документация). Если эта директива была включена, то все данные, содержащиеся в $_GET, $_POST и $_COOKIE автоматически экранировались.
Сделано это было для защиты новичков, которые подставляли данные напрямую в SQL-запросы. На практике это было не самое удачное решение:
- Не очень удобно, когда все данные по-умолчанию экранируются, ведь зачастую они нужны в исходном виде.
- В идеале экранирование должно учитывать кодировку соединения с базой данных, о чём мы поговорим чуть позже. Из-за этого разработчикам приходилось убирать экранирование функцией stripslashes() и затем опять экранировать данные более подходящими функциями, в случае MySQL это была mysql_real_escape_string() .
Вот почему функцию stripslashes() можно встретить в старых учебниках. Чтобы отменить экранирование символов и получить исходную строку.
Начиная с PHP 5.4 функционал волшебных кавычек удалён, поэтому использовать stripslashes() перед записью в базу нет никакого смысла.
4. Функция addslashes()
В некоторых книгах ещё можно встретить рекомендации экранировать данные функцией addslashes() .
Эта функция надёжней, чем htmlspecialchars() , поскольку экранирует и обратный слеш, и nul-байт. Однако эта функция хуже, чем mysql_real_escape_string , поскольку не учитывает кодировку текущего соединения с базой.
Поэтому даже в документации прямо написано, что эту функцию не нужно использовать для защиты от SQL инъекций.
Эффективные способы защиты
1. Функция mysql(i)_real_escape_string
Работает эта функция примерно по тому же принципу, что и addslashes() , только учитывает текущую кодировку соединения с базой данных.
Есть две важные детали, которые вы должны знать, когда используете эту функцию.
Первая — вы всегда должны подставлять экранированные данные в кавычки. Если этого не делать, толку от экранирования не будет:
// Неправильно, сначала надо экранировать! $query = 'SELECT * FROM `articles` WHERE `id` = ' . $_GET['id']; // Экранируем $id = $mysqli->real_escape_string($_GET['id']); // Тоже неправильно, нет кавычек $query = 'SELECT * FROM `articles` WHERE `id` = ' . $id; // Правильно $query = "SELECT * FROM `articles` WHERE `id` = '$id'";
Вторая опасность подстерегает тех, кто использует некоторые специфические кодировки вроде GBK. В этом случае вам обязательно нужно указывать кодировку при установке соединения с базой.
Почитать о проблеме можно тут (блог разработчика, обнаружившего ошибку), здесь и подробней с примерами там.
2. Приведение к числу
Простой и эффективный способ защиты для числовых полей — приведение данных к числу. Пример:
$_POST['id'] = '15'; $id = (int) $_POST['id']; // Или так: $id = intval($_POST['id']); // Или для дробных чисел: $id = (float) $_POST['id']; $query = 'SELECT * FROM `users` WHERE `name` = ' . $id;
Кавычки здесь не обязательны, поскольку в запрос в любом случае подставится число.
Есть один нюанс. Как я писал выше, мне не очень нравится идея фильтрации данных и здесь она может выйти боком с точки зрения SEO.
Допустим, есть интернет-магазин, где URL адреса страниц товаров выглядят как /product/15 , где 15 — идентификатор товара.
Если алгоритм поиска статьи заключается в том, что мы берём вторую часть URL и приводим её к числу, вроде такого:
$segments[2] = '15'; $id = (int) $segments[2];
Тогда можно писать какие угодно символы после числа 15 (только один следующий символ должен быть не цифровым), например /product/15abcde13824_ahaha_lol и система всё равно будет отображать статью c >
3. Подготовленные запросы
Один из лучших способов защиты от SQL инъекций. Суть в том, что SQL запрос сначала «подготавливается», а затем в него отдельно передаются данные.
$stmt = $db->prepare('SELECT * FROM `users` WHERE `name` LIKE ?'); $stmt->execute([$_GET['name']]);
Такой подход гарантирует отсутствие SQL-инъекций в момент подстановки данных, поскольку запрос уже «подготовлен» и не может быть изменён.
Но, как обычно, всё портят детали.
Первая деталь. Чуть выше я указывал ссылку на обсуждение уязвимости mysql_real_escape_string.
Если ты героически прочитал его до конца (нет), там есть интересное утверждение — что PDO с подготовленными запросами также может иметь уязвимость, связанную с кодировками.
Чтобы её избежать, нужно либо отключить эмуляцию подготовленных запросов, либо использовать только надёжные кодировки (например UTF-8), либо обязательно указывать кодировку соединения (через $mysqli->set_charset($charset) или DSN для PDO, но не через SQL-запрос SET NAMES).
Вторая деталь. Нужно понимать, что защита от SQL-инъекций будет действовать только в том случае, если мы не подставляем никаких данных напрямую в запрос. Если разработчик решит сделать так:
$stmt = $db->prepare("SELECT * FROM `users` WHERE `name` = '$_POST[name]'"); $stmt->execute();
Тогда его не спасут никакие подготовленные запросы.
И третья деталь. В подготовленные запросы нельзя подставлять названия столбцов и таблиц.
// Так делать нельзя $stmt = $pdo->prepare('SELECT ? FROM ?);
Прекрасно. И что теперь делать?
Один из распространённых вариантов — белые списки. Простой пример:
$_POST['product'] = [ 'title' => 'Название товара', 'article' => 'Артикул товара', 'content' => 'Описание товара' ]; $allowed = ['title', 'article', 'content']; foreach($_POST['product'] as $k => $v)
Если полей много и не хочется всех их вбивать ручками — можно просто достать их всех из базы ( SHOW COLUMNS FROM `products`) .
Другой логичный вариант — валидировать названия столбцов, разрешая, к примеру, только буквы, цифры и подчёркивания.
В общем, опять надо что-то вручную допиливать, придумывать собственные функции генерации запросов. Не комильфо. Рекомендую поступить иначе.
4. Готовые библиотеки
Разработчики популярных библиотек наверняка гораздо умней и опытней нас. Они давно всё продумали и протестировали на десятках тысяч программистов. Так почему нет?
Для простых проектов вполне хватит Medoo или RedBeanPHP, для средних рекомендую (и всегда использую) Eloquent, ну а для крупных проектов лучше всего подойдёт мощная и суровая Doctrine.