Давайте поговорим о наиболее опасной уязвимости, которая может присутствовать у вас в приложении  —  внедрение SQL-кода.

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

Предположим, например, что БД вашего приложения содержит таблицу Users. В этой таблице есть столбцы Id, Username и Password, которые содержат ID, имя и пароль каждого зарегистрированного пользователя, соответственно: 

А на сайте вы предлагаете пользователям форму для ввода их имени и пароля.

Отправляемые пользователем таким образом данные вставляются в SQL-запрос авторизации. Например, если пользователь ввел имя “user” и пароль “password123”, то этот SQL-запрос будет нацелен на поиск ID пользователя с совпадающими Username и Password. В случае обнаружения совпадения ваше приложение авторизует пользователя с соответствующим ID.

SELECT Id FROM Users WHERE Username='user' AND Password='password123';

Атаки по внедрению SQL-кода

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

username="admin'; —- "&password=""

Сгенерированный SQL-запрос станет таким:

SELECT Id FROM Users

WHERE Username='admin'; —- AND Password='';

Последовательность - — обозначает начало комментария. Добавив эти символы в часть username запроса, атакующий закомментирует оставшуюся его часть. В итоге запрос, по сути, станет следующим:

SELECT Id FROM Users WHERE Username='admin';

Такой запрос, независимо от переданного пароля, вернет ID пользователя с правами администратора. Внедрившись в SQL-запрос, атакующий обошел систему аутентификации и может авторизоваться как администратор даже без пароля. В этом и заключается суть внедрения SQL  —  вставляя особые символы и изменяя структуру SQL-запроса, атакующие могут вынудить БД выполнить незапланированный SQL-код.

Варианты внедрения SQL 

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

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

username="vickie"&accesskey="ZB6w0YLjzvAVmp6zvr"

Этот запрос, к примеру, сгенерирует обращение к БД со следующей SQL-инструкцией:

SELECT Title, Body FROM Emails WHERE Username='vickie' AND AccessKey='ZB6w0YLjzvAVmp6zvr';

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

username="vickie"&accesskey="ZB6w0YLjzvAVmp6zvr' UNION SELECT Username, Password FROM Users; —- "

Давайте немного разделим эту полезную нагрузку. Если атакующий отправляет серверу вышеуказанный ключ доступа, то сервер выполнит следующий SQL-запрос:

SELECT Title, Body FROM Emails WHERE Username='vickie' AND AccessKey='ZB6w0YLjzvAVmp6zvr'UNION SELECT Username, Password FROM Users; —- ;

Оператор SQL UNION используется для объединения результатов двух разных инструкций SELECT. Этот запрос совмещает результаты первой инструкции SELECT, представляющей электронные письма пользователя, и второй инструкции SELECT, возвращающей из таблицы Users все имена пользователей и пароли. Теперь атакующий может прочесть все имена и пароли пользователей, сохраненные в БД:

SELECT Title, Body FROM Emails WHERE Username='vickie' AND AccessKey='ZB6w0YLjzvAVmp6zvr'UNION SELECT Username, Password FROM Users; —- ;

Опасность не только в SELECT 

Внедрение SQL не ограничивается инструкциями SELECT. Атакующие могут также делать внедрение в инструкции UPDATE для обновления записи, DELETE для их удаления и INSERT для создания в таблице новых записей. Предположим, к примеру, что пользователи могут менять свои пароли, передавая их новые версии через HTTP-форму. 

new_password="password12345"

Эта форма приведет к выполнению сервером SQL-запроса UPDATE с новым паролем для текущего авторизованного пользователя, которым в данном случае является пользователем с ID 2.

UPDATE UsersSET Password='password12345'WHERE Id = 2;

Атакующие могут контролировать раздел SET инструкции UPDATE. Что если они отправят новый пароль, например такой:

new_password="password12345'; —- "

Этот запрос приведет к следующему изменению UPDATE:

UPDATE Users SET Password='password12345'; —- WHERE Id = 2;

В этом запросе секция WHERE закомментирована, а значит запрос изменит все пароли в таблице Users на “password12345”. После этого атакующий сможет с помощью этого пароля авторизовываться от лица любого пользователя.

Защита от внедрений SQL 

Думаю, вы поняли, что возможность такого внедрения SQL-кода является серьезной уязвимостью, которую необходимо устранять. Давайте поговорим о том, как можно защитить от этого ваше web-приложение. 

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

  1. Заранее подготовленные инструкции.
  2. Утвержденный список.
  3. Приведение типов.
  4. Чистка.

Заранее подготовленные инструкции

Начнем с первого варианта: подготовленные инструкции, которые иначе называются параметризованными запросами. Они сводят на нет вероятность внедрения SQL.

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

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

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

После того, как параметры будут вставлены в итоговый запрос, этот запрос уже не будет анализироваться и перекомпилироваться. Все, что отсутствовало в исходной инструкции, будет рассматриваться как строковые данные, а не исполняемый SQL-код. Поэтому логическая часть программы SQL-запроса останется нетронутой. Это позволяет БД различать SQL-запрос, как состоящий из части с кодом и части с данными, независимо от того, как выглядит пользовательский ввод. Например, вот как можно реализовать подготовленные инструкции на PHP:

<?php
  $stmt = $mysqli->prepare(“SELECT Id FROM Users WHERE Username=? AND Password=?”);
  $stmt->bind_param(“ss”, $username, $password);
  $stmt->execute();
?>

В подготовленной инструкции первой определяется структура запроса. Вы прописываете запрос без параметров, и в качестве их плейсхолдера ставите знак вопроса. Затем SQL-сервер скомпилирует эту строку в SQL-код, после чего вы отдельно отправляете параметры запроса. В данном случае ss означает, что передается два параметра, оба представленные строками. После этого вы выполняете запрос.

Здесь также нужно запомнить, что подготовленные инструкции не обязательно на 100% обезопасят сайт от внедрений SQL, так как использовать их нужно продуманно. Например, мы конкатенируем пользовательский ввод с функцией prepare, а затем сразу ее выполняем:

<?php
  $stmt = $mysqli->prepare(“SELECT Id FROM Users WHERE Username=’$username’ AND Password=’$password’”);
  $stmt->execute();
?>

Если не отправить пользовательский ввод отдельно в качестве параметров для подготовленной инструкции, а создать SQL-запрос объединением строк, то уязвимость внедрению SQL сохранится, несмотря на применение этих инструкций. 

Утвержденный список

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

SELECT Title, Body FROM EmailsWHERE Username=’vickie’ AND AccessKey=’mykey_123'ORDER BY Date DESC;

Секция ORDER BY позволяет запросу указывать, по какому столбцу упорядочивать результаты. Этот запрос вернет все письма пользователя в таблице, отсортированные по столбцу Date в порядке убывания. 

Но вам нужно, чтобы пользователи могли выбирать, по какому полю сортировать письма. В этом случае вы не можете использовать для защиты запроса подготовленные инструкции. Их можно задействовать только для защиты тех полей, которые не нужны в процессе компиляции. Вы не можете применить подготовленные инструкции к именам столбцов, таблиц, операторов SQL или в секции ORDER BY. Поэтому, если вам нужно использовать в этих полях передаваемый пользователем ввод, то лучше всего для защиты от внедрения SQL подойдет утвержденный список.

Утвержденный список подразумевает прием заведомо действительных входных значений и отказ в случае ввода других. В данной ситуации вместо того, чтобы допускать со стороны пользователя произвольный ввод, можно использовать утвержденный список с названиями столбцов для секции ORDER BY. Предположим, что пользователи могут сортировать письма только по дате или по отправителю. Вы можете проверить, соответствует ли ввод пользователя одному из утвержденных значений, а затем уже вставить его в SQL-инструкцию. Вот как можно реализовать эту защиту на PHP:

<?php
  if($_POST[“order_by”] == “Date” || $_POST[“order_by”] == “Sender”) {
   $order_by = $_POST[“order_by”];
  }
  $stmt = $mysqli->prepare(“SELECT Id FROM Emails WHERE Username=? AND AcessKey=? ORDER BY $order_by”);
  $stmt->bind_param(“ss”, $username, $accesskey);
  $stmt->execute();
?>

У вас также есть возможность отображать передаваемые пользователем значения в предопределенные строки программы, чтобы избежать конкатенации пользовательского ввода в SQL-запросах. Например, если вам нужно, чтобы пользователи сортировали результаты в порядке возрастания либо убывания, то можно позволить им указывать логическое значение, которое будет отображаться в строку для вставки в запрос.

<?php
  if($_POST[“order_by”] == “Date” || $_POST[“order_by”] == “Sender”)   {
   $order_by = $_POST[“order_by”];
  }  $sort_by = “ASC”;
  if($_POST[“desc”] == “1”) {
   $sort_by = “DESC”;
  }
  $stmt = $mysqli->prepare(“SELECT Id FROM Emails WHERE Username=? AND AcessKey=? ORDER BY $order_by $sort_by”);
  $stmt->bind_param(“ss”, $username, $accesskey);
  $stmt->execute();
?>

Приведение типов

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

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

SELECT Title, Body FROM EmailsWHERE Id='2' AND AcessKey='mykey_123';

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

<?php
  $id = (int)$_POST[“id”];
  $accesskey = $_POST[“access_key”];  $stmt = $mysqli->prepare(“SELECT Title, Body FROM Emails WHERE Id=$id AND AcessKey=?”);
  $stmt->bind_param(“s”, $accesskey);
  $stmt->execute();
?>

Возможно, вы заметили, что в этом примере можно было использовать подготовленные инструкции. Так и есть. Я советую при любой возможности задействовать вместо приведения типов подготовленные инструкции, потому что они работают для всех типов данных. Таким образом, вы сможете сохранить согласованный стиль написания кода для всех типов пользовательского ввода. Метод приведения типов должен придерживаться на случай, когда подготовленные инструкции нельзя применить.

<?php
  $id = $_POST[“id”];
  $accesskey = $_POST[“access_key”];  $stmt = $mysqli->prepare(“SELECT Title, Body FROM Emails WHERE Id=? AND AcessKey=?”);
  $stmt->bind_param(“ss”, $id, $accesskey);
  $stmt->execute();
?>

Чистка

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

Эту технику стоит использовать в самом крайнем случае и особо на нее не полагаться, потому что она не гарантирует предотвращение всех SQL-внедрений во всех ситуациях. Очень легко упустить один спецсимвол, который сможет такое внедрение осуществить.

Давайте еще раз взглянем на эти запросы. Что если вам нужно, чтобы пользователи имели возможность сортировать письма по случайному полю? Если вы не можете определить утвержденный список, который в такой ситуации бы подошел, то можете сначала очистить пользовательский ввод, а затем вставить его в запрос. 

<?php
  $order_by = $mysqli->real_escape_string($_POST[“order_by”]);
  $stmt = $mysqli->prepare(“SELECT Id FROM Emails WHERE Username=? AND AcessKey=? ORDER BY $order_by”);
  $stmt->bind_param(“ss”, $username, $accesskey);
  $stmt->execute();
?>

Защищайтесь эффективно!

Как вы видите, есть немало вариантов защиты от внедрения SQL-кода. Лучший способ  —  это по возможности использовать везде подготовленные инструкции или же утвержденный список, если такие инструкции применить невозможно. Приведение типов можно рассмотреть в качестве альтернативы утвержденному списку, но опять же, если нет возможности использовать подготовленные инструкции. Чистку ввода не рекомендуется задействовать в качестве единственного метода защиты от внедрений SQL-кода. Этот метод следует применять в совокупности с подготовленными инструкциями и утвержденным списком, чтобы добиться наилучшей безопасности, а также устранить ряд других web-уязвимостей.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Vickie Li: Learn About SQL Injection Attacks

Предыдущая статьяМаршрутизация 101 в Angular 9+
Следующая статья10 популярных проектов GitHub, написанных на Python