SQL Injection: что это такое и как это предотвратить
Введение
SQL Injection (сокращённо SQLi) — это уязвимость, позволяющая атакующему внедрить свой код в SQL-запрос, который приложение отправляет в базу данных. Причина почти всегда одна и та же: пользовательский ввод склеивается со текстом SQL-запроса как обычная строка.
Несмотря на то, что уязвимость старая и хорошо известная, она до сих пор не покидает OWASP Top 10 (A03:2021 — Injection). Она появляется снова в каждом новом проекте, в каждой наспех написанной админке и в каждом «временном» отчёте.
Что атакующий может сделать через SQLi:
- выгрузить всю таблицу
users(хеши паролей, email-адреса, телефоны); - обойти аутентификацию и войти как администратор;
- изменить или удалить данные;
- в отдельных случаях прочитать файлы сервера или выполнить команды (если у пользователя БД больше прав, чем нужно).
Примеры атак в этом руководстве приведены только для проверки и защиты ваших собственных систем. Проверка чужой системы без письменного разрешения — преступление.
Как работает SQL Injection
Классический пример — форма входа. Уязвимый код собирает запрос конкатенацией строк:
// ❌ УЯЗВИМЫЙ КОД — никогда так не пишите
const email = req.body.email;
const rows = await conn.query(
"SELECT id, email FROM users WHERE email = '" + email + "'"
);Если обычный пользователь введёт otabek@example.com, запрос будет таким:
SELECT id, email FROM users WHERE email = 'otabek@example.com'Теперь атакующий вводит в поле email следующее:
' OR 1=1 -- И запрос превращается в:
SELECT id, email FROM users WHERE email = '' OR 1=1 -- 'Условие OR 1=1 всегда истинно, а -- закомментирует остаток. Запрос возвращает всех пользователей, а во многих приложениях это означает вход под первым пользователем — обычно администратором.
Корень проблемы: для базы данных ' OR 1=1 -- перестал быть текстом и стал SQL-кодом. Данные и код смешались.
Виды SQL Injection
1. In-band (классическая) SQLi
Атакующий видит результат в том же ответе.
UNION-based — атакующий добавляет свой SELECT и читает другую таблицу:
/products?id=1 UNION SELECT username, password FROM usersError-based — данные утекают через сообщение об ошибке. Например, через сломанное приведение типа в PostgreSQL:
/products?id=1 AND CAST((SELECT current_user) AS int) = 1В ошибке будет invalid input syntax for type integer: "app_user" — то есть данные вернулись в самом тексте ошибки. Именно поэтому в production SQL-ошибки нельзя показывать пользователю.
2. Blind SQLi
Приложение не показывает ни результат, ни ошибку. Но атакующий задаёт вопросы в формате «да/нет» и читает ответ по поведению приложения.
Boolean-based — по изменению содержимого страницы:
/products?id=1 AND (SELECT substring(current_user,1,1)) = 'a'Time-based — по времени ответа. Если условие истинно, база ждёт 5 секунд:
-- MySQL
1 AND IF((SELECT COUNT(*) FROM users) > 100, SLEEP(5), 0)
-- PostgreSQL
1 AND (SELECT CASE WHEN (SELECT COUNT(*) FROM users) > 100
THEN pg_sleep(5) ELSE pg_sleep(0) END) IS NULLBlind SQLi работает медленно, но автоматизированный инструмент способен вычитать всю базу символ за символом.
3. Out-of-band SQLi
Данные выводятся по другому каналу — например, сервер БД отправляет DNS- или HTTP-запрос на сервер атакующего. Обычно это работает при избыточных правах (FILE в MySQL, xp_dirtree в MS SQL).
4. Second-order (stored) SQLi
Самый коварный вид. Ввод атакующего безопасно сохраняется первым запросом, но позже используется через конкатенацию в другом месте — в cron-задаче, генераторе отчётов или админке — и срабатывает уже там.
Именно поэтому правило «значение пришло из нашей же базы, значит оно доверенное» — неверно. Любое значение, независимо от источника, должно передаваться как параметр.
Пример реального уязвимого эндпоинта
Ниже типичный уязвимый эндпоинт, который действительно встречается в реальных проектах:
// ❌ УЯЗВИМО: id, sort и order — всё склеивается в запрос напрямую
app.get('/api/products', async (req, res) => {
const { category, sort, order } = req.query;
const sql = `
SELECT id, name, price FROM products
WHERE category = '${category}'
ORDER BY ${sort} ${order}
`;
const [rows] = await conn.query(sql);
res.json(rows);
});Здесь сразу три ошибки:
category— строковое значение вставлено конкатенацией;sort— имя колонки, его нельзя передать параметром (решим через allowlist);order—ASC/DESC, тоже требует allowlist.
Способы защиты
1. Prepared statements (параметризованные запросы)
Это основная и единственная надёжная защита. В prepared statement структура SQL отправляется в базу отдельно от значений, поэтому база никогда не выполняет значение как код.
Node.js (mysql2):
// ✅ ПРАВИЛЬНО — execute() использует настоящий prepared statement
const [rows] = await conn.execute(
'SELECT id, email FROM users WHERE email = ? AND status = ?',
[email, 'active']
);В mysql2 есть разница между query() и execute(): query() экранирует значения на стороне клиента, а execute() отправляет prepared statement на сервер. Оба безопасны с плейсхолдерами, но execute() предпочтительнее.
Python (psycopg2 / PostgreSQL):
# ✅ ПРАВИЛЬНО — драйвер сам подставляет значение
cur.execute(
"SELECT id, email FROM users WHERE email = %s AND status = %s",
(email, "active"),
)
# ❌ УЯЗВИМО — не используйте % форматирование или f-строки
cur.execute(f"SELECT id FROM users WHERE email = '{email}'")PHP (PDO):
// ✅ ПРАВИЛЬНО
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_EMULATE_PREPARES => false, // настоящие prepared statements
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$stmt = $pdo->prepare('SELECT id, email FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);Go (database/sql):
// ✅ ПРАВИЛЬНО — $1 для PostgreSQL, ? для MySQL
row := db.QueryRow(
"SELECT id, email FROM users WHERE email = $1", email,
)Java (JDBC):
// ✅ ПРАВИЛЬНО
PreparedStatement ps = conn.prepareStatement(
"SELECT id, email FROM users WHERE email = ?");
ps.setString(1, email);
ResultSet rs = ps.executeQuery();2. Использовать ORM — но осторожно
ORM (Prisma, SQLAlchemy, GORM, Hibernate, Eloquent) обычно параметризуют запросы автоматически. Но возможность выполнить raw-запрос есть в каждой из них, и уязвимость появляется именно там:
// ❌ УЯЗВИМО — значение внутри шаблонной строки
await prisma.$queryRawUnsafe(
`SELECT * FROM users WHERE email = '${email}'`
);
// ✅ ПРАВИЛЬНО — $queryRaw сам привязывает параметры
await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;# ❌ УЯЗВИМО — в SQLAlchemy raw-строки тоже опасны
session.execute(f"SELECT * FROM users WHERE email = '{email}'")
# ✅ ПРАВИЛЬНО — связанный параметр
from sqlalchemy import text
session.execute(
text("SELECT * FROM users WHERE email = :email"), {"email": email}
)3. Allowlist для имён колонок и ORDER BY
Плейсхолдеры работают только для значений. Имя таблицы, имя колонки, ASC/DESC, направление сортировки — их нельзя передать параметром. Проверяйте их по allowlist (списку разрешённых):
// ✅ ПРАВИЛЬНО — пользовательский ввод только выбирает из фиксированного списка
const SORTABLE = { name: 'name', price: 'price', date: 'created_at' };
const ORDERS = { asc: 'ASC', desc: 'DESC' };
const sortColumn = SORTABLE[req.query.sort] ?? 'created_at';
const direction = ORDERS[String(req.query.order).toLowerCase()] ?? 'DESC';
const [rows] = await conn.execute(
`SELECT id, name, price FROM products
WHERE category = ?
ORDER BY ${sortColumn} ${direction}
LIMIT ?`,
[req.query.category, limit]
);Здесь sortColumn — не текст пользователя, а значение из нашего собственного кода. Если атакующий отправит sort=price; DROP TABLE users, такого ключа в SORTABLE нет и будет использовано значение по умолчанию.
4. Валидация ввода — дополнительный слой
Валидация не является решением проблемы SQLi, но сокращает поверхность атаки. Если id должен быть числом — приведите его к числу; проверьте формат email; ограничьте длину:
const id = Number.parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: 'invalid id' });
}Blacklist (фильтрация ', --, DROP и подобных) — ненадёжный подход. Его легко обойти через кодирование, варианты комментариев и смену регистра. Используйте allowlist и prepared statements.
5. Least privilege — ограничьте права пользователя БД
Если уязвимость всё же проскочит, масштаб ущерба определяют права пользователя базы. Приложение никогда не должно подключаться как root или суперпользователь postgres.
MySQL:
CREATE USER 'app'@'10.0.%' IDENTIFIED BY 'strong-password';
-- Только нужные права и только на нужную базу
GRANT SELECT, INSERT, UPDATE, DELETE ON app_db.* TO 'app'@'10.0.%';
-- FILE, SUPER, PROCESS, GRANT OPTION — никогда не выдавайте
FLUSH PRIVILEGES;Право FILE даёт атакующему возможность читать файлы сервера через LOAD_FILE() и записывать их через INTO OUTFILE. Не выдавайте его и держите secure_file_priv включённым:
# /etc/mysql/mysql.conf.d/mysqld.cnf
secure_file_priv = /var/lib/mysql-files
local_infile = 0PostgreSQL:
CREATE ROLE app LOGIN PASSWORD 'strong-password';
-- Убрать право создавать объекты в схеме public для всех
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
GRANT CONNECT ON DATABASE app_db TO app;
GRANT USAGE ON SCHEMA app TO app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app;
-- Для миграций используйте отдельную роль с большими правамиОтдельный пользователь только с SELECT для read-only отчётов — тоже хорошая практика.
6. Скрывайте сообщения об ошибках
В production SQL-ошибка не должна доходить до пользователя — это готовая информация для error-based SQLi. Логируйте ошибку, а пользователю возвращайте общее сообщение:
try {
const [rows] = await conn.execute(sql, params);
res.json(rows);
} catch (err) {
logger.error({ err }, 'db query failed'); // полная ошибка — в лог
res.status(500).json({ error: 'internal error' }); // общая — пользователю
}Детализацию ошибок можно уменьшить и на стороне PostgreSQL:
# postgresql.conf
log_min_error_statement = error
log_statement = 'ddl'7. WAF — последний слой, а не первый
WAF (ModSecurity + OWASP CRS, Cloudflare, AWS WAF) блокирует большинство автоматизированных атак и даёт вам время. Но он не заменяет исправление кода — способы обхода находятся всегда.
Пример ModSecurity для Nginx:
# nginx.conf
modsecurity on;
modsecurity_rules_file /etc/nginx/modsec/main.conf;# /etc/nginx/modsec/main.conf
Include /etc/nginx/modsec/modsecurity.conf
Include /usr/share/modsecurity-crs/crs-setup.conf
Include /usr/share/modsecurity-crs/rules/*.conf
SecRuleEngine On8. Хранимые процедуры не безопасны автоматически
Многие считают, что «если использовать хранимые процедуры, SQLi не будет». Это неверно — если внутри процедуры собирается динамический SQL, уязвимость остаётся:
-- ❌ УЯЗВИМАЯ процедура
CREATE PROCEDURE find_user(IN p_email VARCHAR(255))
BEGIN
SET @sql = CONCAT('SELECT * FROM users WHERE email = ''', p_email, '''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
END;Проверка на уязвимость
Ручная проверка с помощью sqlmap
sqlmap — стандартный инструмент для поиска и эксплуатации SQLi. Используйте его только на системах, которые принадлежат вам или на тестирование которых есть письменное разрешение:
# Проверка одного параметра
sqlmap -u "https://staging.example.com/api/products?id=1" \
--batch --level=2 --risk=1
# Если нужна аутентификация — передайте cookie
sqlmap -u "https://staging.example.com/api/orders?id=1" \
--cookie="session=abc123" --batch
# POST-тело и JSON
sqlmap -u "https://staging.example.com/api/login" \
--data='{"email":"a@b.c","password":"x"}' \
--headers="Content-Type: application/json" --batchЧтобы подтвердить находку, можно попробовать --dbs или --current-user, но не выгружайте production-данные.
Добавьте статический анализ в CI/CD
Самая дешёвая защита — поймать уязвимый код до мержа. semgrep хорошо находит паттерны SQLi.
GitLab CI:
sast:sqli:
stage: test
image: returntocorp/semgrep:latest
script:
- semgrep --config=p/sql-injection --config=p/security-audit --error .
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"GitHub Actions:
name: SAST
on: [pull_request]
jobs:
semgrep:
runs-on: ubuntu-latest
container:
image: returntocorp/semgrep
steps:
- uses: actions/checkout@v4
- run: semgrep --config=p/sql-injection --error .Флаг --error роняет пайплайн при находке. Сначала запустите без --error, устраните существующие находки, и только потом делайте проверку блокирующей — иначе все MR станут красными.
Мониторинг и обнаружение
Даже после исправления кода стоит следить за попытками атак:
- настройте алерты по правилам
sqliв WAF (правила CRS диапазона942xxx); - в логах приложения серия ошибок 500 с одного IP или неестественно длинные query-строки — признак сканирования на SQLi;
- в PostgreSQL
pg_stat_statementsпоказывает нетипичные формы запросов; - в MySQL
slow_query_logвыявляет попытки time-based SQLi (SLEEP()).
Чек-лист
Пройдите этот список перед сдачей проекта:
- в коде не осталось ни одного SQL-запроса, собранного конкатенацией строк;
- все значения передаются через плейсхолдеры (
?,$1,:name); - имена колонок и
ORDER BYпроверяются по allowlist; - все места с raw-запросами в ORM просмотрены отдельно;
- пользователь БД приложения не суперпользователь и имеет только необходимые права;
- для миграций используется отдельная роль;
- SQL-ошибки не видны пользователю, только в логах;
- в CI работает SAST (semgrep/CodeQL);
- WAF включён, и его логи подключены к мониторингу.
Заключение
Предотвратить SQL Injection несложно — достаточно никогда не вставлять значение в текст SQL. Prepared statements есть в каждом языке и в каждом драйвере, и работают они обычно быстрее, потому что база кеширует план запроса.
Остальные меры — least privilege, скрытие ошибок, WAF, SAST — это слои защиты (defense in depth). Они снижают ущерб, когда вы всё-таки допустили ошибку, но не заменяют prepared statements.
Дополнительно
Дополнительные материалы
Источники, использованные при подготовке руководства
- OWASP: SQL Injection (opens in a new tab)
- OWASP SQL Injection Prevention Cheat Sheet (opens in a new tab)
- OWASP Top 10: A03:2021 – Injection (opens in a new tab)
- PortSwigger Web Security Academy: SQL injection (opens in a new tab)
- Официальная документация sqlmap (opens in a new tab)
Дата: 2026.07.30 (30 июля 2026)
Автор: Otabek Ismoilov
| Telegram (opens in a new tab) | GitHub (opens in a new tab) | LinkedIn (opens in a new tab) |
|---|