SQL-инъекция - это уязвимость, которая возникает, когда у злоумышленника появляется возможность модифицировать SQL запрос в приложении. Она может привести как минимум к краже любых данных из базы (например, списка пользователей). В особо запущенных случаях она позволяет менять данные в базе, читать или создавать файлы с произвольным содержимым на сервере и даже выполнять какие-то команды.
Чтобы понять, как работает эта уязвимость, надо представлять, что такое SQL базы данных, и как обычно приложение с ними работает. База данных - это такое хранилище информации. Работа с ним ведется путем отправки ему запросов на языке SQL. Запросы могут получать или изменять какие-то данные в базе. Например, запрос SELECT * FROM news ORDER BY added_date DESC LIMIT 10
запрашивает из базы данных 10 последних новостей (при условии что колонка added_date
хранит дату публикации новости).
Часто у нас появляется необходимость подставлять в запрос пришедшие от пользователя данные. В случае, если это делать неправильно (не используя проверок и экранирования), мы можем создать уязвимость, которая позволит злоумышленнику взломать наше приложение.
SQL инъекции не привязаны к конкретному языку программирования и базе данных. Они могут быть в любом приложении, которое использует SQL запросы. Мы будем рассматривать примеры кода на PHP, работающем с сервером баз данных MySQL.
Допустим, у нас есть страница просмотра архива новостей за определенный год. При переходе по ссылке /archive.php?year=2011 мы должны увидеть заголовки новостей за этот год. Рассмотрим пример неправильного уязвимого кода на PHP, реализующего эту задачу:
// Опасное место: $year содержит присланные пользователем данные,
// в которых может быть что угодно
$year = $_GET['year'];
// Формируем SQL запрос, подставляя в него данные от пользователя
// безо всякой проверки. В этой строчке и содержится уязвимость.
// (SQL-функция YEAR() получает год из даты)
$sql = "SELECT title FROM news WHERE YEAR(added_date) = $year";
// выполняем запрос и получаем массив результатов
$titles = executeQuery($sql);
// Выводим заголовки в виде HTML-списка
echo "<ul>\n";
foreach ($titles as $title) {
// защищаемся от XSS и экранируем выводимые данные
$titleHtml = htmlspecialchars($title);
echo "<li>$titleHtml</li>\n";
}
echo "</ul>\n";
Если приглядеться к строчке $sql = "SELECT title FROM news WHERE YEAR(added_date) = $year";
, то видно, что мы подставляем в запрос пришедшие от пользователя данные безо всякой проверки. Тут и кроется уязвимость. Что, если пользователь передаст нам в параметре year такую строку?
0 UNION SELECT name FROM users
В этом случае после подстановки получится SQL запрос:
SELECT title FROM news WHERE YEAR(added_date) = 0 UNION SELECT email FROM users
Первая часть этого запроса не найдет ни одной новости, а вторая (после UNION) - выберет из базы email-ы всех наших пользователей и выведет их вместо заголовков новостей. Разумеется, злоумышленник не будет ограничиваться email-ами - он может по очереди выбрать все данные из нашей базы. Более того, есть программы, автоматизирующие такое извлечение данных - достаточно дать им уязвимую ссылку, а остальное они сделают сами.
В данном случае уязвимости можно было бы избежать, преобразовав $year в число:
$year = intval($_GET['year']);
Но это не сработает в том случае, если подставляемый параметр - строка. Правильный способ борьбы - использовать подготовленные запросы с плейсхолдерами (о них ниже).
Рассмотрим второй пример уязвимости. Допустим, у нас есть такой неправильный код проверки логина и хеша пароля (мы разумеется не храним в базе сами пароли, а только их соленый хеш):
$login = $_POST['login'];
$password = $_POST['password'];
// Функция получает хеш пароля с солью, так как мы следуем
// рекомендациям и не храним в базе пароли
$hash = getSaltedHash($login, $password);
// Проверяем, есть ли в базе пользователь с таким логином и хешем пароля
// В этой строчке уязвимость
$sql = "SELECT COUNT(*) FROM users WHERE login = '$login' AND hash = '$hash'";
$count = getOneResult($sql);
if ($count == 1) {
echo "Вы успешно вошли в систему\n";
....
} else {
echo "Неправильный логи или пароль\n";
....
}
Здесь мы опять подставляем данные от пользователя в запрос и как следствие получаем уязвимость. Злоумышленник может передать такой логин: login' --
. При подстановке в запрос получается:
SELECT COUNT(*) FROM users WHERE login = 'login' -- ' AND hash = 'xxxxx'
Знак --
обозначает в языке SQL комментарий и сервер базы данных проигнорирует все, что за ним, таким образом из условия пропадет проверка на совпадение хеша, что позволяет злоумышленнику залогиниться под любым логином, не зная пароля.
В данном случае методом защиты (кроме самого правильного способа - использования плейсхолдеров и подготовленных запросов) могло бы быть экранирование переданной строки. Например, в библиотеке mysqli это делается методом real_escape_string():
$escapedLogin = $mysqli->real_escape_string($login);
$sql = "... WHERE login = '$escapedLogin' ... ";
В PDO экранирование делается методом quote(), который не только экранирует переданную строку, но еще и заключает ее в нужные кавычки (в зависимости от используемой базы данных):
$quotedLogin = $pdo->quote($login);
$sql = "... WHERE login = $quotedLogin ... ";
При экранировании перед спецсимволами вроде символа кавычки (а также двойной кавычки, перевода строки, бекслеша) подставляются бекслеши. Таким образом, переданная злоумышленником строка login' --
преобразуется в login\' --
, и экранированная бекслешем кавычка не закрывает строку, а является ее частью. И символ --
тоже воспринимается как часть строки, а не комментарий. Получается запрос WHERE login = 'login\' -- ' AND hash = '...'
который работает как и задумано. Подробнее про экранирование можно прочесть в мануале по MySQL рус. англ..
Иногда результаты уязвимого запроса не выводятся на экран. Но даже в таком случае злоумышленник может получать данные, используя задержку в выполнении запроса (делая SQL запрос такого вида: если логин администратора начинается на "а", то сделать паузу в 5 секунд). Это называется "слепая" SQL инъекция.
Найдя уязвимость, злоумышленник может полностью прочитать содержимое базы данных, изменять его. Также, в некоторых случаях он получает возможность читать и записывать файлы на сервере за счет соответствующих команд в SQL. Например, MySQL позволяет записать произвольные значения из таблицы в любой файл командой SELECT ... INTO OUTFILE '/var/www/example.com/file.txt'
, а также прочитать любой файл командой LOAD DATA INFILE '/etc/passwd' ...
. Конечно, для этого ему еще надо найти доступные на чтение или запись файлы, но я думаю, что при желании это вполне возможно.
Но даже доступ к базе многое дает. Злоумышленник может прочитать данные администратора или создать нового пользователя с такими правами. Так он получит доступ к админке сайта, в которой сможет найти другие уязвимости (например загрузку на сервер произвольных файлов).
В некоторых случаях (зависит от конфигурации сервера) некоторые базы данных позволяют через SQL запрос запустить произвольную программу на сервере.
Также, база данных может содержать какие-то персональные данные. Вот пример, показывающий какие последствия для бизнеса может иметь утечка данных.
В 2015 году злоумышленники взломали платный сайт знакомств "Эшли Мэддисон", который предлагал респектабельным женатым мужчинам возможность найти развлечения на стороне. Хакеры выложили в интернет личные данные пользователей, включавшие в себя имена, email, адрес и географические координаты пользователей. Кроме того, выяснилась еще пара интересных подробностей - во-первых, более 99% активных пользователей сайта были мужчинами, так что успешно познакомиться друг с другом могли разве что представители нетрадиционных ориентаций, во-вторых, несмотря на то, что услуга удаления аккаунта с сайта была платной, сайт в реальности не удалял данные. Очевидно, что после такой утечки сайт фактически прекратил свое существование.
Для борьбы с уязвимостью надо использовать подготовленные запросы, когда данные не вставляются в запрос напрямую, а передаются отдельно. В этом случае сама база данных беспокоится о корректном экранировании и модифицировать запрос невозможно. Вот пример проверки логина с использованием подготовленных запросов в PDO (мануал):
// Подготавливаем запрос
$statement = $pdo->prepare("SELECT COUNT(*) FROM users WHERE login = :login AND hash = :hash");
// задаем значения плейсхолдеров. PDO или база данных сами позаботятся о коректной
// вставке этих значений в запрос
$statement->bindValue(':login', $login);
$statement->bindValue(':hash', $hash);
// Выполняем запрос и получаем результат
$count = $statement->fetchCoumn();
Вот, как можно использовать подготовленные запросы с библиотекой mysqli (мануал):
// Подготавливаем запрос
$statement = $mysqli->prepare("SELECT COUNT(*) FROM users WHERE login = ? AND hash = ?");
if (!$statement) {
throw new Exception("MySQLi prepare error: {$mysqli->errno} {$mysqli->error}");
}
// передаем значения для пдейсхолдеров в том же порядке,
// в котором они идут в запросе
// Буква "s" задает тип параметра - строка
if (!$statement->bind_param("s", $login)) {
throw new Exception("MySQLi bind login error: {$mysqli->errno} {$mysqli->error}");
}
if (!$statement->bind_param("s", $hash)) {
throw new Exception("MySQLi bind hash error: {$mysqli->errno} {$mysqli->error}");
}
// Выполняем запрос и получаем результат
if (!$statement->execute()) {
throw new Exception("MySQLi execute error: {$mysqli->errno} {$mysqli->error}");
}
$result = $statement->get_result();
Код получился немного громоздкий из-за того, что в mysqli все ошибки надо проверять вручную, но суть в общем понятна.
Использование параметризованных запросов позволяет не беспокоиться о безопасности и сохраняет запрос читабельным.
Через плейсхолдеры можно подставлять только числа или строки, но нельзя подставлять имена таблиц или колонок. В этом случае придется вставлять их напрямую в запрос, проверив их по списку разрешенных значений. Например, если нам надо сортировать новости по заголовку или дате в зависимости от переданного параметра, можно поступить так:
$sort = $_GET['sort'];
// Разрешенные значения сортировки
$allowed = ['title', 'added_date'];
// Гарантируем, что в переменной будет только допустимое значение
if (!in_array($sort, $allowed)) {
$sort = $allowed[0];
}
$sql = "SELECT * FROM news ORDER BY $sort LIMIT 10";
Кроме использования плейсхолдеров, можно вручную экранировать строковые значения через функции вроде real_escape_string()
или quote()
и intval
/floatval
для чисел, но это замусоривает код, и появляется вероятность, что кто-то подставит в запрос неэкранированное значение.
Экранирование или проверка данных всегда должны делаться рядом с тем местом, где выполняется запрос (а не в другом месте кода), чтобы с одного взгляда на функцию можно было понять, безопасная она или нет. Подготовленные запросы соответствуют этому требованию.
- http://php.net/manual/ru/security.database.sql-injection.php
- https://ru.wikipedia.org/wiki/%D0%92%D0%BD%D0%B5%D0%B4%D1%80%D0%B5%D0%BD%D0%B8%D0%B5_SQL-%D0%BA%D0%BE%D0%B4%D0%B0
- https://rdot.org/forum/showthread.php?t=124 (подробная статья)
Не пытайся искать уязвимости на чужих сайтах без разрешения владельца. Это может быть уголовно наказуемым деянием.