SQL-инъекция: или как ваш код превращается в SQL-калькулятор для злоумышленников
Введение: почему SQL-инъекция — это не просто "баг в коде"
SQL-инъекция — это не академический термин из учебников. Это проблема, которая конкретно бьёт по бизнесу: утечка данных клиентов, кража денег, взлом аккаунтов, а иногда и полный контроль над сервером. В 2023 году уязвимости этого типа всё ещё занимают топ-3 в рейтинге самых опасных багов (по данным OWASP и CVE-базы). Причём речь не только о стартапах — даже крупные компании регулярно становятся жертвами из-за банальных ошибок в обработке пользовательского ввода.
Главная особенность SQL-инъекции в том, что она не требует сложных инструментов — достаточно текстового поля в форме, некорректного запроса и базы данных, которая доверяет пользователю. В этой статье разберём:
- Как именно выглядит эксплойт на практике (с реальными примерами).
- Какие ошибки в коде приводят к уязвимостям (и как их не допускать).
- Как защищаться эффективно, без перегибов вроде полного отказа от ORM.
- Что делать, если уязвимость уже есть (пошаговый алгоритм для DevOps и разработчиков).
Проблема: как злоумышленник превращает ваш запрос в свой скрипт
Представьте, что у вас есть форма авторизации. Пользователь вводит логин и пароль, а ваш код формирует SQL-запрос вроде такого:
SELECT * FROM users WHERE login = '$login' AND password = '$password';
Если $login и $password не экранированы, то злоумышленник может подставить вместо них SQL-код. Например, вместо логина admin он введёт:
admin' --
Результат запроса:
SELECT * FROM users WHERE login = 'admin' --' AND password = '$password';
Знаком -- в SQL начинается однострочный комментарий. Всё после него игнорируется. Получается, запрос сводится к:
SELECT * FROM users WHERE login = 'admin';
То есть пользователь авторизовался без пароля.
Пример 2: кража данных через UNION
Допустим, у вас есть поисковая форма, которая выполняет запрос:
SELECT * FROM products WHERE name LIKE '%$search_query%';
Злоумышленник вводит:
' UNION SELECT username, password FROM users --
Если таблица products имеет те же количество колонок, что и users, запрос вернёт все логины и пароли из таблицы пользователей. В реальных системах это работает ещё проще — достаточно знать структуру базы.
Почему это работает в продакшене
- Доверчивые библиотеки: Многие ORM (например, старые версии Django ORM или SQLAlchemy) позволяют формировать "сырые" запросы, если разработчик не следит за параметризацией.
- Ленивая валидация: Проверка на "только буквы" или "только цифры" не спасает — хакеры используют Unicode, HTML-сущности, многобайтовые символы.
- Логирование и отладка: В продакшене часто встречаются запросы вроде:
ЗдесьEXECUTE('SELECT * FROM ' || table_name || ' WHERE id = ' || id);table_nameиidберутся из пользовательского ввода без экранирования.
Практика: как SQL-инъекция выглядит в реальных проектах
Пример 1: Уязвимость в API на Node.js (Express + Sequelize)
Допустим, у вас есть эндпоинт для получения пользователя по ID:
// Уязвимый код (не используйте так!)
app.get('/user/:id', async (req, res) => {
const user = await User.findOne({
where: {
id: req.params.id // <-- Прямое внедрение в SQL!
}
});
res.json(user);
});
Как это эксплуатируется:
Запрос GET /user/1; DROP TABLE users-- приведёт к удалению таблицы пользователей.
Исправление:
// Безопасный вариант с параметризацией
app.get('/user/:id', async (req, res) => {
const user = await User.findOne({
where: { id: req.params.id } // Sequelize автоматически экранирует
});
res.json(user);
});
Но! Если вы используете sequelize.literal, то риск возвращается:
// Опасно!
const user = await User.findOne({
where: sequelize.literal(`id = ${req.params.id}`) // <-- SQL-инъекция!
});
Пример 2: PHP + PDO — как не надо делать
Многие считают PDO защищённым от SQL-инъекций, но только если использовать подготовленные выражения (prepare + execute). Вот как не работает:
// Уязвимый код
$stmt = $pdo->query("SELECT * FROM users WHERE username = '$username'");
$user = $stmt->fetch();
Рабочий вариант:
// Безопасно
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username");
$stmt->execute(['username' => $username]);
$user = $stmt->fetch();
Но даже тут есть подводные камни:
- Если вы используете
sprintfдля вставки параметров:// ОПАСНО! sprintf обходит prepare $stmt = $pdo->prepare("SELECT * FROM users WHERE username = %s"); $stmt->execute([sprintf($username)]); - Если базу конфигурируют через переменные окружения, а не хардкодят, риск снижается, но не исчезает полностью.
Типичные ошибки, которые ведут к SQL-инъекциям
Конкатенация строк в SQL-запросах
# Python + psycopg2 (опасно) cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")Использование
evalили динамического выполнения SQL// Node.js (катастрофа) const query = `SELECT * FROM ${table} WHERE ${condition}`; connection.query(query, callback);Неправильная параметризация в ORM
// Hibernate (опасно, если использовать CriteriaQuery неверно) CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<User> query = cb.createQuery(User.class); Root<User> root = query.from(User.class); query.where(cb.equal(root.get("id"), userId)); // <-- Если userId не экранированЗабывчивость при работе с JSON или XML
-- Уязвимость через JSON_EXTRACT (MySQL) SELECT * FROM orders WHERE JSON_EXTRACT(data, '$.user_id') = '$user_id';Здесь
$user_idдолжен экранироваться, но часто этого не делают.Ошибки в логировании и отладке
# Логирование полного запроса (утечка данных!) logger.error(f"Failed query: SELECT * FROM users WHERE id = {user_id}")Использование устаревших библиотек
mysql_*функции в PHP (устарели в 2012 году).java.sql.DriverManagerбез параметризации.
Предположение о "безопасных" данных
-- Допущение, что IP-адрес безопасен SELECT * FROM logs WHERE ip = '$ip';На самом деле
$ipможет быть127.0.0.1' OR '1'='1.
Как защищаться: практические шаги
1. Параметризация запросов ( Prepared Statements )
Это первое правило. Все современные библиотеки поддерживают подготовленные выражения:
- Python:
psycopg2,sqlite3(используйте%sили?). - Java:
PreparedStatement. - Node.js:
mysql2/promiseилиpg(неmysql). - PHP:
PDO::prepareилиmysqli_prepare.
Пример на Python:
import psycopg2
conn = psycopg2.connect("dbname=test user=postgres")
cursor = conn.cursor()
# Безопасно
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
2. Используйте ORM с умом
ORM (Django ORM, SQLAlchemy, Hibernate) частично защищают от SQL-инъекций, но только если не использовать "сырые" запросы. Например:
# Безопасно (Django ORM)
User.objects.filter(id=user_id)
Но:
# Опасно (сырой запрос в Django)
User.objects.raw(f"SELECT * FROM auth_user WHERE id = {user_id}")
3. Экранирование на уровне приложения
Если параметризация невозможна (например, в legacy-коде), используйте экранирование:
- MySQL:
mysql_real_escape_string()(но лучше перейти на подготовленные выражения). - PostgreSQL:
pg_escape_string(). - Для Unicode: используйте
binascii.b2a_hex()для ручного экранирования.
Пример экранирования в Python:
import binascii
def escape_sql(input_str):
return input_str.replace("'", "''").replace('"', '""')
# Или для Unicode:
def escape_unicode(input_str):
return binascii.b2a_hex(input_str.encode('utf-8')).decode('ascii')
4. Принцип наименьших привилегий
База данных должна работать под непривилегированной учёткой:
- Учётка для приложения не должна иметь прав
DROP TABLE,ALTER DATABASEи т.д. - Используйте роли с минимальными правами (например, только
SELECTдля чтения данных).
Пример конфигурации PostgreSQL:
CREATE USER app_user WITH PASSWORD 'secure_password';
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_user;
5. Веб-приложение: валидация + фильтрация
- Валидируйте входные данные на уровне приложения (например, с помощью
Pydantic,ZodилиJoi). - Фильтруйте опасные символы (но не полагайтесь только на это!):
Но: это не замена параметризации!import re def filter_suspicious(input_str): return re.sub(r"[\'\";\\-]", "", input_str)
6. Обновление зависимостей
Регулярно проверяйте уязвимости в используемых библиотеках:
- npm:
npm audit. - Python:
pip list --outdated. - Java:
dependency-check.
Пример команды для поиска уязвимостей в Maven:
mvn dependency:check-versions org.owasp:dependency-check-maven:check
Вывод: как не стать жертвой SQL-инъекции
- Параметризуйте все запросы — это единственный надёжный способ. Никакие фильтры или валидаторы не заменят подготовленные выражения.
- Используйте ORM правильно — избегайте сырых SQL-запросов, если это возможно.
- Ограничьте права базы данных — учётка приложения не должна иметь административных прав.
- Автоматизируйте проверки:
- Используйте статические анализаторы (например, SQLMap, Bandit для Python, Checkmarx).
- Настройте динамический анализ (например, OWASP ZAP или Burp Suite).
- Обновляйте зависимости — многие уязвимости появляются из-за старых версий библиотек.
- Логируйте подозрительную активность — если в запросе появляются символы
';--,UNION,DROP, это повод для алерта.
Последний практический совет
Если вы наследуете проект с потенциальными уязвимостями:
- Сначала проверьте все места, где пользовательский ввод попадает в SQL-запрос (формы, API, логи, отчёты).
- Начните с самых критичных (авторизация, финансовые операции, данные клиентов).
- Не полагайтесь на "это никогда не произойдёт" — SQL-инъекции эксплуатируются автоматически сканерами, даже если у вас нет публичного API.
Итог: SQL-инъекция — это не теоретическая угроза, а реальный риск, который можно предотвратить здесь и сейчас с помощью простых правил. Не ждите, пока хакерам удастся взломать вашу систему — защитите её сегодня.