Loading...

Безопасность · 11 мин чтения

Что такое SQL-инъекция? Как она работает и как её остановить

SQL-инъекция — уязвимость, при которой ввод пользователя склеивается в запрос к базе данных, и база выполняет часть этого ввода как SQL. Она позволяет атакующему читать, изменять или удалять данные, а иногда захватить сервер. Решение — отправлять запрос и значения отдельно через параметризованные запросы; принцип наименьших привилегий, валидация и WAF ограничивают ущерб, если что-то всё же проскочит.

Обновлено

Что такое SQL-инъекция? Как она работает и как её остановить

Что такое SQL-инъекция

SQL-инъекция (SQLi) — уязвимость, при которой текст, предоставленный пользователем, вставляется в запрос к базе данных, и база в итоге выполняет его как SQL. Приложение намеревалось отправить значение, например email-адрес или ID товара; атакующий вместо этого отправляет фрагмент языка запросов, и смысл запроса меняется.

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

Поэтому SQL-инъекция не специфична для MySQL, PostgreSQL, SQL Server или SQLite, и не специфична для PHP. Любой язык, любой драйвер и любая база данных уязвимы в тот момент, когда запрос собирается из ненадёжных строк. Она занесена в каталог как CWE-89, и инъекции присутствовали в каждой редакции OWASP Top 10. Кластер запросов вокруг неё — «sql attack», «sql vulnerability», «sql injection attack» — все описывают одну и ту же ошибку.

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

Как это работает: классический пример

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

При обычном вводе запрос делает то, что должен. Теперь представим, что посетитель вводит в поле пароля ' OR '1'='1. Кавычка закрывает строковый литерал, открытый разработчиком, а остальное становится частью предложения WHERE. Поскольку '1'='1' всегда истинно, а AND связывает крепче, чем OR, условие совпадает с каждой строкой, и код пускает посетителя как первого пользователя в таблице — которым часто оказывается администратор.

Эта одна кавычка — вся суть концепции. Всё остальное в этом руководстве: разные типы атак, ущерб и защита — следует из того факта, что база данных получила одну строку и разобрала текст атакующего как код.

(Хранение паролей в открытом виде и их сравнение в SQL — вторая ошибка в этом примере. Настоящий код получает пользователя по имени и проверяет пароль функцией password_verify() по хешу. Здесь оставлено так, потому что это самый короткий способ показать инъекцию.)

Уязвимо: ввод пользователя склеен в запрос (PHP)

<?php
// DO NOT DO THIS
$user = $_POST['username'];
$pass = $_POST['password'];

$sql = "SELECT id FROM users
        WHERE username = '$user' AND password = '$pass'";
$row = $pdo->query($sql)->fetch();

// With password  ' OR '1'='1  the database receives:
// SELECT id FROM users
// WHERE username = 'admin' AND password = '' OR '1'='1'
// ...which is true for every row.

Решение: параметризованные запросы на PHP и Python

Параметризованный запрос (также prepared statement, или связанные параметры) отправляет текст SQL с заполнителями — ?, :name или %s, в зависимости от драйвера, — а значения отправляет отдельно. База сначала разбирает и планирует запрос, а затем подставляет значения как данные. Кавычка в значении — это просто символ в строке; она никогда не может закрыть литерал или добавить предложение, потому что разбор уже завершён.

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

В PHP используйте PDO или mysqli с заполнителями. С PDO задайте кодировку символов в DSN и отключите эмуляцию prepared statements, чтобы драйвер отправлял настоящие server-side prepared statements, и включите исключения, чтобы сбои не замалчивались. В Python каждый драйвер DB-API (sqlite3, psycopg, mysqlclient, PyMySQL) принимает значения отдельным аргументом в execute(). Ловушка в Python — форматировать строку самостоятельно через f-строку или % перед вызовом execute(): результат выглядит параметризованным, но на деле это конкатенация.

То же правило действует в любом другом стеке: PreparedStatement в Java, SqlParameter в .NET, заполнители $1 в node-postgres, ? в database/sql Go.

Безопасно: заполнители, значения передаются отдельно (PHP PDO и Python)

<?php
$pdo = new PDO('mysql:host=db;dbname=shop;charset=utf8mb4', $dbUser, $dbPass, [
    PDO::ATTR_ERRMODE          => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES => false,
]);

$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE username = ?');
$stmt->execute([$_POST['username']]);
$row = $stmt->fetch();
$ok  = $row && password_verify($_POST['password'], $row['password_hash']);

# Python (psycopg 3, PostgreSQL)
cur.execute(
    "SELECT id, password_hash FROM users WHERE username = %s",
    (username,),            # values go here, never into the string
)

# WRONG, still injectable: the string is built before execute() sees it
cur.execute(f"SELECT id FROM users WHERE username = '{username}'")

Типы SQL-инъекций

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

In-band, UNION-based. Результат внедрённого запроса возвращается прямо в странице. Добавляя UNION SELECT, атакующий добавляет строки из другой таблицы, скажем таблицы пользователей, к списку товаров, который страница уже показывала. Это самый быстрый для эксплуатации и самый легко обнаруживаемый при тестировании вид.

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

Blind, boolean-based. Страница не показывает ни данных, ни ошибок, но ведёт себя по-разному, когда условие истинно или ложно: товар появляется или нет, страница отвечает 200 или 404. Задавая вопросы «да/нет» по одному, атакующий читает данные по чуть-чуть. Медленно, но инструменты это автоматизируют.

Blind, time-based. Даже содержимое страницы идентично, поэтому атакующий заставляет базу данных ждать, когда условие истинно, и измеряет время ответа. Если все ответы выглядят одинаково, тайминг всё равно остаётся каналом.

Out-of-band. Атакующий заставляет сам сервер базы данных связаться с системой, которую он контролирует, обычно через DNS-запрос или HTTP-запрос, запущенный функцией базы данных. Это зависит от возможностей базы данных и доступности исходящего сетевого трафика, что лишний раз показывает: серверу баз данных не стоит иметь возможность открывать исходящие соединения, которые ему не нужны.

Second-order (хранимая). Ввод в первый раз сохраняется безопасно, корректно параметризованным, а затем позже считывается и склеивается в другой запрос, часто в админском отчёте или фоновой задаче. Разработчики доверяют данным, пришедшим из собственной базы данных; это доверие и есть ошибка. Защита та же, что и везде: параметризуйте каждый запрос, включая построенные из значений, которые вы сами сохранили.

Что на самом деле получает атакующий

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

Чтение данных. Самый частый исход: записи клиентов, email-адреса, хеши паролей, заказы, API-ключи, хранящиеся в таблицах настроек. Читаема любая таблица, из которой этот аккаунт может SELECT, а не только та, к которой обращается уязвимый запрос.

Обход аутентификации. Как в примере с логином, условие, которое должно быть конкретным, становится всегда истинным.

Изменение или уничтожение данных. Если аккаунт может UPDATE, INSERT или DELETE, то же самое может и атакующий: менять цены, создавать администраторов, стирать таблицы. Некоторые комбинации драйвера и базы данных позволяют выполнять несколько операторов за один вызов, что расширяет это ещё больше.

Доступ к серверу. При достаточных привилегиях некоторые базы данных умеют читать или писать файлы на хосте или выполнять команды операционной системы. На аккаунте базы данных с административными правами SQL-инъекция может превратиться в полный захват сервера.

Это не теория. SQL-инъекция была точкой входа во взломе Heartland Payment Systems в 2008 году, где были украдены данные более 100 миллионов карт; во взломе TalkTalk в Великобритании в 2015 году, за которым последовал регуляторный штраф; и в массовой эксплуатации MOVEit Transfer в 2023 году (CVE-2023-34362), где одна уязвимость инъекции в продукте для передачи файлов использовалась против тысяч организаций. Старая ошибка, современные заголовки новостей.

Защита, по порядку важности

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

2. Правильно используйте ORM или query builder и знайте его лазейки. Eloquent, Doctrine, Django ORM, SQLAlchemy, Hibernate и Entity Framework параметризуют обычные запросы за вас. Они не защищают raw-фрагменты: whereRaw, DB::raw, selectRaw и orderByRaw в Laravel, .extra() и .raw() в Django, text() в SQLAlchemy, FromSqlRaw в EF Core. Все они принимают привязки — используйте их. Поиск по этим именам методов в коде — самый быстрый code review, который можно сделать.

3. Составьте allowlist того, что не может быть параметром. Заполнители хранят значения, а не идентификаторы. Имя таблицы, столбец в ORDER BY или слова ASC/DESC нельзя связать, так что параметр «сортировать по» нужно сопоставлять с фиксированным списком имён столбцов в коде. Никогда не пропускайте его напрямую.

4. Минимальные привилегии для пользователя базы данных. Веб-приложение должно подключаться пользователем, который может SELECT, INSERT, UPDATE и DELETE в своей собственной схеме и больше ничего: без DROP, без GRANT, без доступа к файлам, без других баз данных и никогда не под root или sa. Миграции выполняются отдельным, более сильным пользователем. Если инъекция всё же проскочит, это решает, утечёт ли одна схема или будет захвачен весь сервер.

5. Валидация ввода как дополнительный слой защиты. ID заказа должен быть целым числом, дата должна парситься как дата, код страны — две буквы. Валидация типов и форматов на границе приложения отклоняет множество вредоносного ввода на раннем этапе и находит баги. Это не решение: поле имени должно принимать O'Brien, а поле комментария принимает почти всё.

6. Не раскрывайте ошибки. Логируйте ошибки базы данных на сервере с запросом и контекстом; посетителю показывайте общую страницу ошибки. В PHP это означает display_errors=Off в продакшене.

Raw-фрагменты с привязками, allowlist для сортировки и пользователь с минимальными привилегиями

// Laravel: raw expressions must carry their own bindings
$orders = DB::table('orders')
    ->whereRaw('total > ? AND status = ?', [$min, $status])
    ->get();

// ORDER BY cannot be bound: map input to known columns
$sortable = ['created_at', 'total', 'status'];
$column   = in_array($request->sort, $sortable, true) ? $request->sort : 'created_at';
$dir      = $request->dir === 'asc' ? 'asc' : 'desc';
$orders   = Order::orderBy($column, $dir)->paginate(50);

-- MySQL: the app user gets data access to its own schema only
CREATE USER 'shop_app'@'10.0.%' IDENTIFIED BY '...';
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'shop_app'@'10.0.%';

Почему экранирования недостаточно

До того как prepared statements стали распространены, советовали экранировать кавычки: addslashes(), затем mysql_real_escape_string(), затем mysqli::real_escape_string(). Экранирование до сих пор встречается в большом количестве кода, и оно подводит предсказуемым образом.

Оно защищает только контексты в кавычках. В WHERE id = $id вокруг значения нет кавычек, так что нечего экранировать, и ввод вроде 1 OR 1=1 проходит без изменений. То же касается LIMIT, ORDER BY и всего числового.

Оно зависит от кодировки символов. Функции экранирования должны совпадать с кодировкой соединения. Несовпадения между кодировкой клиента и сервера позволяли многобайтовым последовательностям поглощать экранирующий обратный слеш; addslashes() вообще не знала о кодировке.

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

Та же логика применима к чёрным спискам и «очищающим» функциям, вырезающим слова вроде SELECT или --. Атакующие потратили двадцать лет на поиск кодировок, комментариев и вариаций регистра, проскальзывающих мимо таких фильтров. Относитесь к любому самодельному фильтру как к удобству, но никогда как к защите.

Безопасное тестирование собственного приложения

Начните с кода, а не с атаки. Большая часть SQL-инъекций видна при поиске по коду: запросы, собранные через ., +, интерполяцию строк или sprintf, и raw-методы ORM, вызванные с переменной. Инструменты статического анализа, такие как Semgrep и CodeQL, поставляются с правилами ровно для этого паттерна и могут запускаться в CI на каждый pull request.

Затем напишите тесты, передающие опасно выглядящие, но безвредные значения — одиночную кавычку, имя вроде O'Brien, очень длинную строку — через ваши формы и API, и проверяйте, что приложение возвращает обычный результат или ошибку валидации, но никогда 500 и никогда сообщение об ошибке базы данных. Эти тесты остаются в наборе и защищают от регрессий.

Для динамического тестирования open-source сканеры вроде OWASP ZAP и sqlmap проверяют работающие приложения на наличие внедряемых параметров. sqlmap в частности — инструмент эксплуатации: он извлекает данные, когда находит дыру. Запускайте его только на системах, которыми вы владеете или на тестирование которых у вас есть письменное разрешение, предпочтительно на копии staging с фейковыми данными, потому что его зонды пишут в логи, могут изменять данные и срабатывать на мониторинг. Сканирование чужого сайта без разрешения незаконно в большинстве стран, какими бы ни были намерения.

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

Найти склеенный SQL в коде раньше, чем это сделает кто-то другой
# PHP: query calls that mention a variable (review each hit)
grep -rnE '(query|exec|prepare)\(.*\$[A-Za-z_]' --include='*.php' app/

# Laravel / Eloquent raw fragments: check each one has bindings
grep -rnE '(whereRaw|selectRaw|orderByRaw|havingRaw|DB::raw)\(' app/

# Python: f-strings or % formatting inside execute()
grep -rnE "execute\(\s*f['\"]|execute\(.*['\"]\s*%\s" --include='*.py' .

Место WAF: слой, а не решение

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

Это всё равно сопоставление паттернов против уязвимости, которая живёт в вашем коде. Целевой атакующий подгоняет полезную нагрузку так, чтобы избежать паттернов, а некоторые точки инъекции, например JSON-поля в необычной кодировке, значения, доходящие до запроса через фоновую задачу, или second-order инъекция, вообще не выглядят подозрительно в запросе. Руководство OWASP Top 10 детально сопоставляет, что WAF может и не может покрыть. Параметризованные запросы остаются решением.

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

На CDN.com.tr WAF — это ModSecurity с OWASP Core Rule Set, включаемый для каждого аккаунта со страницы правил доставки. В этом наборе правил семейство 942 — правила против SQL-инъекций. Заблокированный посетитель получает брендированную страницу 403 с ID запроса, который является идентификатором запроса в журнале аудита, так что вы можете точно увидеть, какое правило сработало и на каком поле. Страница лога WAF в панели перечисляет заблокированные события с категорией атаки, страной, IP и правилом, экспортируется в CSV или XLSX, а cdnctl waf logs показывает то же из командной строки. Когда легитимное поле срабатывает на правило, исправление должно быть узким: это правило, на этом поле, на этом пути, а не отключение защиты для всего сайта. Исключения для отдельных путей пока не доступны в панели, так что отправьте ID запроса в поддержку. Для форм логина ограничение частоты запросов по маршруту находится на той же странице и замедляет брутфорс-трафик, часто сопровождающий сканирование на инъекции.

Частые вопросы про SQL-инъекции

Остаётся ли SQL-инъекция проблемой в 2026 году?

Да. Современные фреймворки делают безопасный путь путём по умолчанию, но raw-запросы, устаревший код, плагины и быстрые внутренние инструменты всё ещё склеивают строки, а новые уязвимости инъекции в широко используемых продуктах продолжают раскрываться и эксплуатироваться массово. Инъекция присутствовала в каждой редакции OWASP Top 10.

Предотвращают ли prepared statements все SQL-инъекции?

Они предотвращают инъекцию через каждое связываемое значение. Они не могут связывать идентификаторы, такие как имена таблиц или столбцов, или направление сортировки, и не помогают, если вы сначала строите строку, а затем «подготавливаете» результат. Составляйте allowlist идентификаторов и никогда не форматируйте ввод в текст SQL.

Делает ли использование ORM меня защищённым от SQL-инъекций?

В основном да, для обычных запросов. У каждого ORM есть raw-методы, такие как whereRaw, DB::raw, .raw(), text() или FromSqlRaw, пропускающие SQL без изменений. Используйте их с привязками и проверяйте каждый вызов.

Достаточно ли валидации ввода, чтобы остановить SQL-инъекцию?

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

Может ли WAF полностью остановить SQL-инъекцию?

Он останавливает большинство автоматических попыток и повышает порог усилий для целевых атак, но он сопоставляет паттерны в запросах, и настроенный атакующий может подогнать payload, чтобы их избежать. Second-order инъекция вообще не выглядит подозрительно в запросе. Используйте WAF как дополнительный слой, а параметризованные запросы — как решение.

Законно ли тестировать сайт на SQL-инъекции с помощью sqlmap?

Только на системах, которыми вы владеете, или на тестирование которых у вас есть явное письменное разрешение. Сканирование чужого сайта — неавторизованный доступ в большинстве юрисдикций. Тестируйте собственную staging-среду с фейковыми данными; если вы нашли уязвимость в чужом продукте, сообщите о ней через их процесс раскрытия.

В чём разница между SQL-инъекцией и XSS?

SQL-инъекция заставляет вашу базу данных выполнять написанный атакующим SQL. Межсайтовый скриптинг заставляет браузер посетителя выполнять написанный атакующим JavaScript в контексте вашего сайта. Оба — инъекции с одной и той же корневой причиной смешивания данных и кода, и каждая требует своего решения: параметризованные запросы для SQL, контекстно-зависимое кодирование вывода для HTML. См. Что такое XSS?.