SQL injection (SQL-инъекция) - это одна из старейших уязвимостей, ее сутью являеться внедрение вредоносного SQL-кода в строку запроса, отправляемого приложением к базе данных. В первые ее опсиал Джефф Форристал 25 декабря 1998 года в 54 номере журнала Phrack Magazine. Не смотря на то, что уязвимость выявлена давно она до сих пор являеться причиной взлома веб-приложений и по сей день. Причина простая. Разработчик строит SQL-запрос строкой, подставляет туда данные пользователя, а база принимает часть этих данных за команду. После этого логика приложения уже не управляет запросом полностью.
Для бизнеса это не абстрактная проблема. Через SQL inj можно получить чужие записи, обойти авторизацию, изменить цены, испортить отчеты, удалить данные или положить сервис тяжелыми запросами. Чем ближе приложение к деньгам или ценной информации, к примеру: персональные данные пользователей, описание внутренних систем, атакуемая инфраструктура располагается на "земле", тем выше риск и вероятность атаки на ваш сервис или веб приложение.
В этой статье мы разберем такие темы как принцип SQL inj, основные виды инъекций, ручная проверка, работа с sqlmap и Semgrep, примеры уязвимого и безопасного кода, методы закрытия уязвимости и роль Check Risk WAF. Это нужно не ради набора трюков. Сильный специалист по ИБ должен понимать, где именно данные смешались с SQL, как доказать риск без вреда для системы и как закрыть проблему так, чтобы она не вернулась через неделю.
Как SQL inj появляется в коде
SQL inj возникает там, где приложение собирает запрос через склейку строк. Пользователь вводит имя, email, id товара или параметр сортировки. Код берет это значение и вставляет прямо в SQL. База не знает, где данные, а где команда. Она видит один общий текст запроса.
Пример уязвимого кода на Python:
@app.get("/users")
def users():
name = request.args.get("name", "")
query = f"SELECT id, name, role FROM users WHERE name = '{name}'"
rows = db.execute(query).fetchall()
return jsonify(rows)
Выглядит удобно в плане реализации написания кода, но значение name приходит из URL. Если там окажется не обычное имя, а строка с SQL-синтаксисом, запрос изменит смысл. В тестовой лаборатории это можно увидеть на одномаркерной проверке:
curl -i "http://localhost:3000/users?name=ivan%27"
# команда отправляет одинарную кавычку и помогает увидеть ошибку SQL или странный ответ приложения
Если сервер вернул текст ошибки базы, изменил код ответа или показал неожиданные данные, точка уязвимости требует проверки. Один такой симптом еще не доказывает SQL inj, но дает направление для дальнейшего анализа, сегмента кода.
Исправление данной уязвимости строится на параметрах. SQL запрос задается отдельно. Данные передаются отдельно. База получает команду и значения в разных полях, что демонстрирует данный пример кода:
@app.get("/users")
def users():
name = request.args.get("name", "")
rows = db.execute(
"SELECT id, name, role FROM users WHERE name = ?",
(name,)
).fetchall()
return jsonify(rows)
Теперь пользовательский ввод не меняет структуру запроса. Даже если в поле прилетит кавычка или SQL-оператор, база обработает это как строку.
Виды SQL inj, которые нужно знать
Классическая in-band SQL inj видна прямо в ответе приложения. Сайт может вернуть лишние строки, ошибку синтаксиса или данные из другой таблицы. Такой вариант проще найти, потому что поведение заметно сразу.
Существуют следующие классификации SQL inj:
- Error-based SQL inj опирается на ошибки базы. Приложение показывает пользователю слишком подробное сообщение. Например, название таблицы, тип базы, фрагмент запроса или имя колонки. Это подарок для атакующего и плохой признак для разработчика. В продакшене ошибки SQL не должны уходить в браузер или API-ответ.
- Blind SQL inj сложнее. Приложение не показывает данные и не пишет ошибку, но отвечает по-разному на разные условия. Один запрос дает обычную страницу. Другой возвращает пустой блок или другой статус. Специалист сравнивает поведение и делает вывод, что условие внутри SQL влияет на ответ.
- Time-based SQL inj еще менее заметна. Ответы почти одинаковые, но время меняется. Если специально созданное условие заставляет базу думать дольше, это может показать инъекцию. Такой тест нельзя запускать без разрешения. Он нагружает базу и может повлиять на сервис.
- Second-order SQL inj часто пропускают. Ввод сначала сохраняется в базу как обычное значение. Позже другой модуль берет это значение и строит из него SQL. Например, пользователь указал имя отчета. Оно сохранилось. Через день админ открывает отчет, а код генератора вставляет это имя в динамический запрос. Уязвимость срабатывает не на первом шаге, а позже.
- ORM не спасает автоматически. Django, SQLAlchemy, Sequelize, Prisma и другие ORM обычно дают безопасные методы. Но raw query, template literals, ручная сортировка и динамические имена колонок снова открывают дверь к вашим данным приложения.
Уязвимый пример на Node.js:
app.get("/orders", async (req, res) => {
const userId = req.query.user_id
const result = await db.query(
`SELECT id, amount FROM orders WHERE user_id = ${userId}`
)
res.json(result.rows)
})
Безопасный вариант:
app.get("/orders", async (req, res) => {
const userId = req.query.user_id
const result = await db.query(
"SELECT id, amount FROM orders WHERE user_id = $1",
[userId]
)
res.json(result.rows)
})
Сортировка требует отдельного подхода. Параметры хорошо работают для значений, но не для имен таблиц и колонок. Поэтому имена нужно выбирать только из заранее заданного списка:
const allowedSort = {
created: "created_at",
amount: "amount"
}
app.get("/orders", async (req, res) => {
const sort = allowedSort[req.query.sort] || "created_at"
const result = await db.query(
`SELECT id, amount FROM orders ORDER BY ${sort} LIMIT $1`,
[50]
)
res.json(result.rows)
})
Здесь строковая подстановка осталась, но в нее попадает не ввод пользователя, а значение из allow-list. Это принципиальная разница, которая спасает приложения от атак.
Методика ручной проверки уязвимости
Ручная проверка начинается с карты входных точек. Нужно смотреть не только URL. SQL inj может прийти через GET-параметры, JSON body, формы, cookies, заголовки, GraphQL-поля, фильтры, поиск, сортировку, id объекта и параметры отчетов.
Базовый безопасный подход к ручному поиску следующий: cначала фиксируем нормальный ответ, потом меняем один параметр, затем сравниваем код ответа, размер, текст, время и записи в логах.
curl -i "http://localhost:3000/users?name=ivan"
# команда получает нормальный ответ для сравнения
curl -i "http://localhost:3000/users?name=ivan%27"
# команда отправляет тестовую кавычку и проверяет реакцию приложения
curl -s -o /dev/null -w "status=%{http_code} time=%{time_total}\n" "http://localhost:3000/users?name=ivan%27"
# команда выводит код ответа и время выполнения запроса
Если есть подозрение, не нужно сразу усиливать тесты. Лучше открыть код, найти обработчик маршрута и посмотреть, как строится SQL. На практике код-ревью часто быстрее и точнее, чем агрессивный сканер.
Логи приложения тоже помогают в поиске. Хороший лог покажет endpoint, параметр, пользователя, request id, код ответа и ошибку на стороне сервера. Если логирование настроино плохо, то лог покажет только "500 internal error" и заставит гадать а был ли мальчик.
Автоматизированная проверка через sqlmap на своем стенде
Sqlmap полезен, когда нужно подтвердить гипотезу на тестовом стенде или в своем приложении. Его нельзя запускать по чужим сайтам. Даже базовый режим может создать нагрузку и попасть под правила антифрода или WAF.
Установка из GitHub:
git clone --depth 1 https://github.com/sqlmapproject/sqlmap.git
# команда скачивает актуальную версию sqlmap из официального репозитория
cd sqlmap
# команда переходит в каталог инструмента
python3 sqlmap.py -h
# команда показывает справку и доступные параметры
Проверка зависимостей:
python3 sqlmap.py --dependencies
# команда проверяет дополнительные библиотеки, которые могут понадобиться для отдельных режимов
Базовая проверка одного параметра в локальном приложении:
python3 sqlmap.py -u "http://localhost:3000/users?name=ivan" --batch --level=1 --risk=1
# команда запускает мягкую автоматическую проверку параметра name
Для POST или JSON удобнее сохранить исходный HTTP-запрос в файл:
cat > request.txt << "EOF"
POST /login HTTP/1.1
Host: localhost:3000
Content-Type: application/json
{"email":"test@example.com","password":"test"}
EOF
# команда создает файл с HTTP-запросом для проверки
python3 sqlmap.py -r request.txt --batch --level=1 --risk=1
# команда запускает проверку запроса из файла
Для учебной проверки можно поднять лабораторное приложение. Например, OWASP Juice Shop в Docker:
docker run --rm -p 3000:3000 bkimminich/juice-shop
# команда запускает учебное веб-приложение на localhost:3000
В рабочей среде лучше не использовать режимы, которые читают данные, пишут файлы, пытаются получить shell или усиливают нагрузку на хост. Цель защитника - доказать наличие проблемы и дать разработчику точку исправления, а не выкачать базу.
Автоматизированный поиск SQL inj в коде через Semgrep
Динамическое сканирование видит поведение приложения. Статический анализ видит опасные паттерны в коде. Эти подходы дополняют друг друга. Именно для этого и используют semgrep.
Установка Semgrep:
python3 -m pip install semgrep
# команда устанавливает Semgrep CLI
Запуск правил для SQL injection:
semgrep --config p/sql-injection .
# команда сканирует текущий проект на типовые SQL injection паттерны
Запуск с отчетом в JSON:
semgrep --config p/sql-injection --json --output semgrep-sqli.json .
# команда сохраняет результат проверки в файл для анализа или CI
Semgrep хорошо находит прямую склейку SQL и пользовательского ввода. Но он не понимает весь бизнес-контекст. Поэтому результаты нужно проверять руками. Иногда это реальная уязвимость, иногда безопасный код выглядит подозрительно, иногда уязвимость спрятана в нестандартном helper-методе, и стандартное правило ее не увидит.
Как закрывать SQL inj правильно
Главная защита - параметризованные запросы. Не фильтр по кавычкам, не регулярка и не ручное экранирование. Именно разделение SQL-команды и данных.
Python с безопасным параметром:
rows = db.execute(
"SELECT id, email FROM users WHERE email = ?",
(email,)
).fetchall()
Node.js с PostgreSQL:
const result = await db.query(
"SELECT id, email FROM users WHERE email = $1",
[email]
)
Если проект использует ORM, выбирайте штатные методы фильтрации:
const user = await prisma.user.findFirst({
where: {
email: req.body.email
}
})
Raw SQL оставляйте только там, где без него нельзя. Каждый raw query должен проходить ревью. Если в нем есть ${}, +, .format, f-string или ручная сборка WHERE, это повод остановиться и проверить.
Валидация входных данных нужна, но она не заменяет параметры. Для id проверяем число. Для enum используем allow-list. Для даты проверяем формат и диапазон. Для текста задаем разумную длину.
Пример проверки sort-поля:
const allowedSort = ["created", "amount", "status"]
if (!allowedSort.includes(req.query.sort)) {
return res.status(400).json({error: "bad sort"})
}
Ошибки базы не должны уходить наружу:
try:
rows = db.execute(
"SELECT id FROM users WHERE email = ?",
(email,)
).fetchall()
except DatabaseError:
logger.exception("database error on users lookup")
return {"error": "request failed"}, 500
Пользователь получает нейтральный ответ. Команда разработки получает подробный лог.
Права базы нужно урезать. Приложение не должно ходить в базу под владельцем схемы или суперпользователем.
psql -d shop -c "CREATE ROLE app_user LOGIN PASSWORD 'change_me'"
# команда создает отдельного пользователя базы для приложения
psql -d shop -c "REVOKE ALL ON SCHEMA public FROM PUBLIC"
# команда снимает лишние права со схемы public
psql -d shop -c "GRANT USAGE ON SCHEMA public TO app_user"
# команда разрешает приложению использовать нужную схему
psql -d shop -c "GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA public TO app_user"
# команда выдает только нужные права на таблицы
Если приложению не нужно удалять записи, не давайте DELETE. Если модуль только читает справочник, не давайте INSERT и UPDATE. Так SQL inj не исчезнет, но ущерб станет меньше.
Для MySQL-клиентов стоит отключать выполнение нескольких SQL-команд в одном запросе, если стек это поддерживает:
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
database: process.env.DB_NAME,
multipleStatements: false
})
Это не основная защита, но полезный ограничитель.
Где помогает Check Risk WAF
WAF не чинит плохой код. Он закрывает внешний контур и снижает риск, пока команда исправляет приложение. Это особенно полезно, когда уязвимость найдена в продакшене, релиз нельзя выкатить сразу, а endpoint уже доступен из интернета.
Check Risk WAF работает перед приложением как reverse proxy. Запрос сначала проходит через защитный экран. Там анализируются URL, параметры, заголовки, тело запроса, частота обращений и поведение клиента. Если запрос похож на SQL inj, WAF может заблокировать его, отправить событие в журнал, применить правило к конкретному endpoint или дать команде время на исправление кода.
Хорошая схема внедрения выглядит так. Сначала включается режим наблюдения. Команда смотрит, какие запросы WAF считает подозрительными. Потом правила переводятся в блокировку для уверенных SQL inj-срабатываний. После этого настраиваются исключения для легитимных сложных запросов, например для поиска по спецсимволам. Логи WAF отправляются в SIEM или хотя бы регулярно разбираются в панели.
Check Risk WAF полезен и как виртуальный патч. Например, в старом модуле есть raw SQL, но команда не может быстро его переписать. Тогда WAF ограничивает опасные шаблоны на конкретном URI, пока разработчики готовят нормальное исправление. Это не отменяет патч в коде. Это снижает окно риска.
Уровень профессионала
Профессиональный подход к SQL inj не сводится к знанию payload. Настоящая работа начинается с понимания потока данных. Откуда пришло значение. Где оно прошло валидацию. Как оно попало в query builder. Какие права есть у пользователя базы. Что увидит атакующий при ошибке. Что попадет в лог. Как быстро команда заметит повторную попытку.
Проверяйте цепочку целиком. Один endpoint может быть безопасным, потому что использует параметры. Второй endpoint рядом может брать тот же параметр из сохраненного профиля и вставлять в отчет через raw SQL. Именно так и появляется second-order инъекция.
Проверяйте не только поиск и логин. Часто уязвимость лежит в фильтрах админки, экспорте CSV, генераторе отчетов, webhook-обработчиках, API для мобильного приложения и параметрах сортировки.
После исправления не ограничивайтесь словами "заменили запрос". Нужен регрессионный тест. Он должен отправлять опасные символы в параметр и проверять, что приложение не падает, не меняет выборку и не пишет SQL-ошибку наружу.
Пример простого pytest-теста:
def test_users_search_treats_input_as_data(client):
response = client.get("/users?name=ivan%27")
assert response.status_code in [200, 404]
assert "syntax error" not in response.text.lower()
assert "select" not in response.text.lower()
Такой тест не доказывает полную безопасность, но защищает от возврата грубой ошибки пользователю.
Комплексные рекомендации
- Используйте параметризованные запросы везде. Это главный контроль против SQL inj.
- Не передавайте пользовательский ввод в raw SQL. Если raw query нужен, проводите отдельное ревью.
- Для имен колонок, сортировок и типов отчетов используйте allow-list. Эти части SQL нельзя безопасно передать обычным параметром.
- Не показывайте SQL-ошибки пользователю. Ошибка должна быть в логах, а не в API-ответе.
- Разделяйте права в базе. Отдельный пользователь для приложения, минимум прав, запрет лишних операций.
- Проверяйте код в CI через Semgrep или другой SAST. Динамический тест не заменяет анализ кода.
- Запускайте sqlmap только на своих системах, staging или лаборатории. Фиксируйте разрешение и границы проверки.
- Логируйте подозрительные запросы. Нужны endpoint, request id, пользователь, IP, статус, время и причина блокировки.
- Ставьте WAF перед публичными приложениями и API. Он снижает риск эксплуатации и дает время на исправление.
- После фикса добавляйте регрессионный тест. Без теста та же ошибка часто возвращается в новом коде.
Заключение
SQL inj появляется не из-за сложной магии, а из-за простой ошибки: данные пользователя становятся частью SQL-команды. Поэтому защита тоже начинается с простых вещей. Параметризованные запросы, allow-list, короткие права в базе, нормальные ошибки, тесты и проверка кода.
Но одного исправления в коде мало. Публичное приложение живет под постоянным шумом сканеров и автоматических атак. Нужен защитный контур перед приложением. Check Risk WAF помогает отфильтровать подозрительный трафик, закрыть риск и дать команде понятные события для анализа.
Хорошая безопасность складывается из двух частей. Первая - код, который не дает смешивать данные и команды. Вторая - внешний слой защиты, который видит атаку раньше приложения. Начните с ревью raw SQL в проекте, добавьте SAST-проверку в CI и проверьте публичные endpoint на тестовом стенде. Если приложение доступно из интернета, поставьте его за WAF и настройте блокировку SQL inj по логам, а не по догадкам.
