SQL-инъекции на учебном стенде

Учимся видеть SQL-инъекцию в коде и закрывать её параметризованными запросами — на безопасном стенде, чтобы потом грамотно защищать продакшен.

SQL-инъекция (SQLi) — уязвимость, при которой ввод пользователя попадает в текст SQL-запроса как код, а не как данные, и позволяет изменить смысл запроса.

Этот урок — практическая работа на учебном стенде (DVWA в режиме low/medium или OWASP Juice Shop, развёрнутые в локальной виртуальной машине). Мы не атакуем чужие сайты: SQLi мы изучаем, чтобы научиться находить и закрывать её в собственном коде. Несанкционированный доступ к чужой базе данных — это статья 272 УК РФ, и никакой «интерес» этого не оправдывает. Тренируемся только там, где разрешено.

Зачем это знать защитнику

SQL-инъекция десятилетиями держится в верхушке рейтинга OWASP Top 10 (категория Injection). Одна уязвимая строка может отдать злоумышленнику всю таблицу пользователей с хешами паролей. Разработчик, который понимает механику атаки, пишет код, в котором инъекция невозможна в принципе. Специалист Blue Team, который понимает её, умеет настроить мониторинг и распознать попытку эксплуатации в логах. Поэтому начинаем с понимания, как уязвимость вообще появляется.

Как возникает уязвимость: конкатенация запроса

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

# УЯЗВИМО: ввод склеивается в текст запроса
login = request.form["login"]
password = request.form["password"]
query = "SELECT id FROM users WHERE login = '" + login + "' AND password = '" + password + "'"
cursor.execute(query)

Пока в login приходит обычное имя вроде alice, всё работает. Но ввод — это часть текста запроса. Если в поле логина оказывается строка, содержащая одинарную кавычку и логическое условие, она закрывает кавычку шаблона и дописывает свою логику. На стенде в учебном поле это выглядит так:

login:    admin' -- 
password: (любое)

Запрос превращается в ... WHERE login = 'admin' -- ' AND password = '...'. Последовательность -- в SQL — это начало комментария, поэтому проверка пароля просто отбрасывается. Это и есть суть инъекции: ввод изменил структуру запроса. Заметьте — мы разобрали принцип, а не «рецепт взлома сайта»: цель в том, чтобы вы узнавали такую конкатенацию в своём коде и убирали её.

Как это находят на CTF

На CTF и в легальном пентесте первый сигнал инъекции — реакция приложения на спецсимволы. Исследователь подставляет в поле одиночную кавычку и смотрит: появилась ошибка SQL ("unterminated string", "syntax error near") — значит, ввод дошёл до интерпретатора без экранирования. Дальше для подтверждения сравнивают поведение на логически истинном и ложном условии (boolean-based): если страница реагирует по-разному, инъекция подтверждена. Автоматизируют разведку сканерами вроде sqlmap — строго против своего стенда:

# Только против СВОЕГО учебного стенда (например, DVWA на localhost)
sqlmap -u "http://localhost/vulnerabilities/sqli/?id=1" --batch --level=1

Инструмент здесь — иллюстрация метода: он подставляет пробные строки и анализирует отклик. На реальной чужой системе такой запуск — уже правонарушение.

Как это работает под капотом

База данных получает строку и проходит её через парсер, который строит дерево разбора (какие таблицы, условия, операторы). Парсер не знает, что часть текста «пришла от пользователя» — он видит единый запрос и честно исполняет всё, что распарсил. Поэтому защита не может быть «отфильтровать плохие слова»: список обхода бесконечен (разный регистр, кодировки, комментарии внутри ключевых слов). Правильное решение — отделить код запроса от данных на уровне протокола, чтобы данные физически не могли стать кодом. Это и делают параметризованные запросы.

Как защититься

1. Параметризованные запросы (prepared statements) — главная защита. Текст запроса с плейсхолдерами отправляется в СУБД отдельно от значений. Драйвер передаёт значения как данные мимо парсера — кавычка внутри значения остаётся обычным символом и не закрывает строку. Тот же логин-сценарий, но безопасно:

# БЕЗОПАСНО: структура и данные разделены плейсхолдерами
login = request.form["login"]
password = request.form["password"]
cursor.execute(
    "SELECT id FROM users WHERE login = %s AND password = %s",
    (login, password),
)

Теперь даже ввод admin' -- ищется как буквальный логин с такими символами — и просто не находится. Инъекция невозможна, потому что значение никогда не парсится как SQL.

2. ORM по умолчанию параметризует. Django ORM, SQLAlchemy и подобные строят запросы безопасно. Опасность возвращается, только если вы вручную склеиваете «сырой» SQL:

# БЕЗОПАСНО: ORM сам параметризует
User.objects.filter(login=login, password_hash=ph)

# ОПАСНО даже внутри ORM — снова конкатенация:
User.objects.raw("SELECT * FROM users WHERE login = '" + login + "'")

3. Валидация и принцип наименьших привилегий — слои в глубину. Проверяйте формат ввода (например, числовой id приводите к int — тогда строковая инъекция в него невозможна). Заводите для приложения отдельного пользователя БД без прав DROP/GRANT: даже при ошибке в коде ущерб ограничен. Храните пароли как хеши (bcrypt/argon2) — тогда утечка таблицы не отдаёт пароли в открытом виде.

4. Обнаружение. WAF и аномалии в логах (всплеск ошибок SQL, спецсимволы в параметрах, странные UNION/--) — сигнал попытки эксплуатации. Это задача мониторинга, а параметризация — задача кода; работают они вместе.

Итоги

  • SQL-инъекция возникает из конкатенации ввода в текст запроса: данные становятся кодом.
  • На CTF её находят по реакции на спецсимволы и разному поведению при истинном/ложном условии — только на своём стенде.
  • Главная защита — параметризованные запросы / ORM: они разделяют структуру и данные на уровне протокола.
  • Дополнительно: валидация типов, минимальные права пользователя БД, хеширование паролей, мониторинг.
  • Любая практика — только на DVWA/Juice Shop/своей ВМ. Чужая БД = ст. 272 УК РФ.
Проверьте себя
1. Почему параметризованный запрос защищает от SQL-инъекции, а ручная фильтрация «плохих слов» — нет?
AПараметризованный запрос разделяет структуру SQL и данные на уровне протокола, поэтому ввод не может стать кодом; чёрный список спецсимволов бесконечно обходится
BПараметризованный запрос шифрует данные перед отправкой в базу
CФильтрация работает, просто она медленнее параметризации
DПараметризация запрещает базе выполнять оператор SELECT
2. Какой из вариантов гарантированно сохраняет уязвимость к SQL-инъекции?
Acursor.execute("... WHERE login = %s", (login,))
BUser.objects.filter(login=login)
Cquery = "... WHERE login = '" + login + "'"
DПриведение id к int перед подстановкой