Что такое SQL-инъекция
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 будет одинарная кавычка?
$name = "Д'Артаньян";
Тогда SQL-запрос станет таким:
UPDATE `users` SET `name` = 'Д'Артаньян'
Попытка выполнить этот запрос приведёт к ошибке синтаксиса. Чтобы её не было, вторую кавычку нужно экранировать, т.е. добавить к ней обратный слеш.
Способы экранирования и их надёжность разберём чуть ниже, а сейчас для простоты возьмём addslashes():
<?php
$name = "Д'Артаньян";
$name = addslashes($name);
$query = "UPDATE `users` SET `name` = '$name'";
Что будет в итоге:
UPDATE `users` SET `name` = 'Д\'Артаньян'
Готово, запрос выполнится даже при наличии кавычек.
Экранировать можно не только кавычки. Разные функции умеют экранировать разные символы, об этом мы подробно поговорим чуть позже.
А теперь важный момент. Некоторые разработчики считают, экранирования достаточно для полной защиты от SQL-инъекций.
Хорошо, ещё раз посмотрим на самый первый пример с SQL-инъекцией:
$_GET['id'] = '-1 OR 1=1';
$query = 'SELECT * FROM `articles` WHERE `id` = ' . $_GET['id'];
SELECT * FROM articles WHERE id = -1 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 '<br>';
}
В итоге 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 id = 15.
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)
{
if(!in_array($k, $allowed, true))
die('Некорректное поле: ' . $k);
}
Если полей много и не хочется всех их вбивать ручками - можно просто достать их всех из базы (SHOW COLUMNS FROM `products`). Другой логичный вариант - валидировать названия столбцов, разрешая, к примеру, только буквы, цифры и подчёркивания.
В общем, опять надо что-то вручную допиливать, придумывать собственные функции генерации запросов. Не комильфо. Рекомендую поступить иначе.
4. Готовые библиотеки
Разработчики популярных библиотек наверняка гораздо умней и опытней нас. Они давно всё продумали и протестировали на десятках тысяч программистов. Так почему нет?
Для простых проектов вполне хватит Medoo или RedBeanPHP, для средних рекомендую (и всегда использую) Eloquent, ну а для крупных проектов лучше всего подойдёт мощная и суровая Doctrine.