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

Атака этого типа возможна, когда недостаточно фильтруются входные данные при использовании в SQL-запросах.

Принцип атаки внедрения SQL

Допустим, на нашем сайте есть страница показа истории погодных наблюдений для одного города. Идентификатор этого города передаётся в ссылке в параметре запроса: /weather.php?city_id=<ID>, где ID — это первичный ключ города. В PHP-сценарии используем этот параметр для подстановки в SQL запрос:

$city_id = $_GET['city_id'];
$res = mysqli_query($link, "SELECT * FROM weather_log WHERE city_id = " . $city_id);

Если на сервере передан параметр city_id, равный 10 (/weather.php?city_id=10), то выполнится SQL-запрос:

SELECT * FROM weather_log WHERE city_id = 10

Но если злоумышленник передаст в качестве параметра id строку -1 OR 1=1, то выполнится запрос:

SELECT * FROM weather_log WHERE city_id = -1 OR 1=1

Добавление во входные параметры конструкций языка SQL (вместо простых значений) изменяет логику выполнения всего SQL запроса. В этом примере вместо показа данных по одному городу, будут получены данные по всем городам, потому что выражение 1 = 1 всегда истинно. Вместо выражения SELECT ... могло быть выражение на обновление данных, и тогда последствия были бы ещё серьезнее.

Отсутствие должной обработки параметров SQL-запроса — это одна из самых серьёзных уязвимостей. Никогда не вставляйте данные от пользователя в SQL запросы «как есть»!

Приведение к целочисленному типу

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

PHP умеет присваивать переменной новый тип. Этот код принудительно назначит переменной целочисленный тип:

$city_id = $_GET['city_id'];
settype($city_id, 'integer');

После преобразования переменную $city_id можно без опаски использовать в SQL-запросах.

Экранирование значений

Что делать, если в SQL запрос требуется подставить строковое значение? Например, на сайте есть возможность поиска города по его названию. Форма поиска передаст поисковый запрос в GET-параметр, а мы используем его в SQL-запросе:

$city_name = $_GET['search'];
$sql = "SELECT * FROM cities WHERE name LIKE('%$city_name%')";

Но если в параметре city_name будет символ кавычки, то смысл запроса можно кардинально изменить. Передав в search_text значение ')+and+(id<>'0, мы выполним запрос, что выведет список всех городов:

SELECT * FROM cities WHERE name LIKE('%') AND (id<>'0%'))

Смысл запроса поменялся, потому что кавычка из параметра запроса считается управляющим символом: MySQL определяет окончание значение по символу кавычки после него, поэтому сами значения кавычки содержать не должны. Очевидно, приведение к числовому типу не подходит для строковых значений. Поэтому, чтобы обезопасить строковое значение, используют операцию экранирования.

Экранирование добавляет в строке перед кавычками (и другими спецсимволами) знак обратного слэша \. Такая обработка лишает кавычки их статуса — они больше не определяют конец значения и не могут повлиять на логику SQL-выражения.

За экранирование значений отвечает функция mysqli_real_escape_string(). Этот код обработает значение из параметра, сделав его безопасным для использования в запросе:

$city_name = mysqli_real_escape_string($link, $_GET['search']);
$sql = "SELECT * FROM cities WHERE name LIKE('%$city_name%')";

Подготовленные выражения

Вид атак типа «SQL-инъекция» возможен, потому что значения (данные) для SQL-запроса передаются вместе с самим запросом. Так как данные не отделены от SQL-кода, они могут влиять на логику всего выражения. К счастью, MySQL предлагает способ передачи данных отдельно от кода. Такой способ называется подготовленными запросами.

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

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

Этап подготовки. На этапе подготовки формируется SQL-запрос, где на месте значений будут находиться знаки вопроса — плейсхолдеры. Эти плейсхолдеры в дальнейшем будут заменены на реальные значения. Шаблон запроса отправляется на сервер MySQL для анализа и синтаксической проверки. Пример:

$sql = "SELECT * FROM cities WHERE name = ?";
$stmt = mysqli_prepare($link, $sql);

Этот код сформирует подготовленное выражение для выполнения вашего запроса.

За подготовкой идёт выполнение. Во время запуска запроса PHP привязывает к плейсхолдерам реальные значения и посылает их на сервер. За передачу значений в подготовленный запрос отвечает функция mysqli_stmt_bind_param(). Она принимает тип и сами переменные:

mysqli_stmt_bind_param($stmt, 's', $_GET['search']);

После выполнения запроса получить его результат в формате mysqli_result можно функцией mysqli_stmt_get_result():

$res = mysqli_stmt_get_result($stmt);

// чтение данных
while ($row = mysqli_fetch_assoc($res)) {
    // ассоциативный массив с очередной записью из результата
    var_dump($row);
}

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