Перейти к содержимому
New

DeepSeek V4.1 Flash free for 7 days: 10M tokens a day after a top-up of $10 or more. Until Nov 30.

How it works

SQL-инъекции на примере OWASP Juice Shop

Как SQL-инъекция меняет смысл запроса, почему параметризованные запросы её устраняют, лабораторная работа со входом в Juice Shop и демонстрация на Python и sqlite3.

Средний9 мин чтенияАвтор: NotHoly

SQL-инъекция возникает, когда приложение собирает запрос к базе данных, соединяя собственный SQL-текст с данными, полученными от пользователя. База данных получает одну строку и никак не может определить, какие символы написал разработчик, а какие ввёл пользователь. Как только текст пользователя доходит до парсера как SQL, он может изменить смысл запроса. Запрос может вернуть другие строки, пропустить проверку, изменить данные, а в худших случаях добраться до того, что приложение вовсе не собиралось открывать.

В этой статье объясняется механизм, показано единственное исправление, которое устраняет причину, и есть две практические части. Первая — задание со входом в OWASP Juice Shop, который вы запускаете на своей машине. Вторая — короткий скрипт на Python с sqlite3, воспроизводящий ту же ошибку и её исправление. Если вы сначала прочитали OWASP Top 10 для разработчиков, эта категория вам уже знакома. Здесь мы берём один пункт из того списка и спускаемся до текста запроса.

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

Определение этой слабости в CWE, CWE-89, описывает её прямо: программа строит часть SQL-команды из входных данных и не нейтрализует SQL-синтаксис внутри них. В результате ввод интерпретируется как команды, хотя должен был остаться значениями.

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

Почему это важно

Запрос на вход — самый маленький полезный пример. Он проверяет имя и пароль. Если атакующий может изменить запрос, он может изменить и то, кто пройдёт аутентификацию. Та же уязвимость в поле поиска может вернуть строки, которые пользователь видеть не должен. На админской странице она может изменить цены, роли или хранимые данные. Многие такие ошибки начинаются с одной кавычки, которая дошла до базы данных неэкранированной, и часто именно по этой кавычке тестировщик впервые подтверждает проблему.

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

Как запрос меняет смысл

Начнём с шаблона, который мог бы написать разработчик:

sql
SELECT * FROM users WHERE name = '<name>' AND pw = '<pw>'

Допустим, поле имени получает admin' --, а поле пароля получает wrong. После того как приложение подставит эти строки в шаблон, база данных разберёт вот такой запрос:

sql
SELECT * FROM users WHERE name = 'admin' --' AND pw = 'wrong'

Прочитаем запрос шаг за шагом. Первая кавычка после admin закрывает строковый литерал, который открыл разработчик. Затем -- начинает комментарий в SQLite и в большинстве диалектов SQL. Всё, что идёт дальше, игнорируется, включая условие на пароль. База данных получает корректный запрос, который ищет строку с именем admin, и больше ничего.

По порядку происходят четыре вещи:

  1. Приложение копирует символы пользователя в текст запроса.
  2. Парсер базы данных читает всю строку как SQL. Он не может определить, какие символы пришли от разработчика, а какие от пользователя.
  3. Кавычка пользователя завершает литерал раньше времени. Текст после неё разбирается как ключевые слова, операторы или комментарии SQL.
  4. Условие, написанное разработчиком, перестаёт быть условием, которое выполняет база данных.

У f-строк, конкатенации строк, форматирования через %, .format() и шаблонных литералов один и тот же недостаток. Всё решает способ соединения текста и SQL, на каком бы языке он ни был написан.

Настоящее исправление: параметризованные запросы

Параметризация разделяет текст SQL и значения. Вы отправляете запрос с плейсхолдерами, а значения идут в базу данных через драйвер как данные. База данных никогда не разбирает их как SQL, поэтому кавычка внутри значения остаётся обычным символом.

Документация Python sqlite3 предписывает использовать плейсхолдеры вместо форматирования строк при привязке значений Python к SQL-запросам, именно для защиты от SQL-инъекций. OWASP SQL Injection Prevention Cheat Sheet называет параметризованные запросы основной защитой по той же причине: структура запроса определяется отдельно от данных.

Валидация по-прежнему нужна, но это второй слой. Причину устраняет именно привязка параметров. Если вы проверили значение, а затем вклеили его в запрос конкатенацией, ошибка осталась на месте.

Практика: решите задание Login Admin

Juice Shop — намеренно уязвимое веб-приложение, которое OWASP поддерживает для обучения. Запускайте его только на своей машине и держите привязанным к loopback-адресу, чтобы никакой другой компьютер не мог до него достучаться.

Запустите лабораторию на localhost

bash
docker run -d -p 127.0.0.1:3000:3000 bkimminich/juice-shop

Префикс 127.0.0.1: публикует порт только на loopback-интерфейсе. Если порт 3000 уже занят, Docker завершится ошибкой вроде port is already allocated. На тестовой машине, использованной для этой статьи, порт 3000 уже занимал другой локальный контейнер, и команда завершилась неудачей именно по этой причине. Выберите свободный порт хоста, например 127.0.0.1:3001:3000, и используйте его в остальных шагах.

Убедитесь, что приложение отвечает, и проверьте его версию:

bash
curl -s http://127.0.0.1:3000/rest/admin/application-version

На тестовой машине это вернуло:

text
{"version":"20.3.0"}

Найдите точку инъекции

Официальное руководство к Juice Shop, Pwning OWASP Juice Shop, не приводит payload для этого задания. Оно даёт подсказки: описание задания указывает на форму, которую нужно атаковать, целевая атака хорошо работает, если вы уже знаете email админа, а стандартный шаблон инъекции может сработать и без этого знания. Следуйте этим подсказкам. Начните с формы входа и одной кавычки в поле email. Обычный неверный пароль должен дать ошибку. Сломанный запрос часто даёт другой ответ.

На тестовой машине неверный пароль для email админа без всякой инъекции вернул:

text
Invalid email or password.

с HTTP-статусом 401. Это точка отсчёта. Дальше тот же запрос с кавычкой и комментарием в поле email:

bash
curl -s -X POST http://127.0.0.1:3000/rest/user/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]'\''--","password":"wrong"}'

Ответ содержит объект authentication с полем token. Пароль был неверным, но сервер выдал токен сессии. Если декодировать среднюю часть JSON Web Token в кодировке base64url, там будет "role":"admin". На тестовой машине ответ пришёл с HTTP 200.

Проверьте состояние таблицы результатов через API лаборатории:

bash
curl -s "http://127.0.0.1:3000/api/Challenges/?name=Login%20Admin"

На тестовой машине задание вернулось как решённое со сложностью 2. Состояние задания определяет таблица результатов. Та же проверка у вас должна показать такой же результат.

Точный запрос, стоящий за этой формой, можно подтвердить по официальному руководству и исходному коду Juice Shop. Читайте их после того, как получите результат, чтобы понять механизм, а не только скопировать payload.

Остановите лабораторию

bash
docker ps
docker stop <container-name>

Используйте имя или ID контейнера, которые показывает docker ps. Закрытие вкладки браузера контейнер не останавливает.

Воспроизведите ошибку и исправление на Python

Сохраните это как sqli_demo.py. Нужна только стандартная библиотека. Скрипт создаёт небольшую таблицу users в памяти и предоставляет две функции входа. Одна строит запрос через f-строку. Другая использует плейсхолдер.

python
import sqlite3

con = sqlite3.connect(":memory:")
cur = con.cursor()
cur.execute("CREATE TABLE users (name TEXT, pw TEXT)")
cur.execute("INSERT INTO users VALUES ('alice', 'correct-horse'), ('admin', 'S3cret!')")

def login_unsafe(user, pw):
    query = f"SELECT * FROM users WHERE name='{user}' AND pw='{pw}'"
    print("  built:", query)
    return cur.execute(query).fetchall()

def login_safe(user, pw):
    return cur.execute('SELECT * FROM users WHERE name=? AND pw=?', (user, pw)).fetchall()

if __name__ == "__main__":
    print("same valid login, unsafe:", login_unsafe("admin", "S3cret!"))
    print("same valid login, safe:  ", login_safe("admin", "S3cret!"))
    print("crafted input, unsafe:")
    print("  result:", login_unsafe("admin' --", "wrong"))
    print("crafted input, safe:")
    print("  result:", login_safe("admin' --", "wrong"))

Запустите скрипт. В Windows, где Python установлен как python, команда выглядит так: python sqli_demo.py. На тестовой машине python3 не было в path, поэтому там используйте python. Вывод на тестовой машине с Python 3.12.10:

text
same valid login, unsafe: [('admin', 'S3cret!')]
same valid login, safe:   [('admin', 'S3cret!')]
crafted input, unsafe:
  built: SELECT * FROM users WHERE name='admin' --' AND pw='wrong'
  result: [('admin', 'S3cret!')]
crafted input, safe:
  result: []

Сравните две половины. При обычном входе обе функции возвращают одну и ту же строку, поэтому небезопасная версия выглядит правильной. Из-за этого такая ошибка и переживает код-ревью. Со специально составленным вводом небезопасная функция выводит запрос, который получила база данных. Комментарий убирает проверку пароля, и строка админа возвращается, хотя пароль был неверным. Безопасная функция считает admin' -- буквальным именем. Пользователя с таким именем нет, поэтому она возвращает пустой список.

Скрипт хранит пароли открытым текстом, потому что это демонстрация. Настоящие системы хранят хеш пароля с солью и сравнивают хеши.

Где ORM и хелперы всё равно ошибаются

ORM не делает эту ошибку невозможной. Он даёт безопасное поведение по умолчанию для обычных запросов и набор лазеек для всего остального. Вот где ошибка обычно возвращается:

  • Методы для сырого SQL. Вызовы вроде raw, text, extra или обычный execute принимают строку. Если вы собираете её через f-строку или конкатенацию, вы вернулись к небезопасному шаблону.
  • Имена таблиц и столбцов. Плейсхолдеры привязывают значения. Идентификаторы они привязать не могут. Столбец ORDER BY или имя таблицы, которыми управляет пользователь, должны проходить через белый список, сопоставляющий выбор пользователя с именами, которые вы написали сами. OWASP рекомендует такой же подход для фиксированных структурных вариантов вроде направления сортировки.
  • Динамические списки IN. Количество плейсхолдеров меняется вместе с вводом. Генерируйте нужное число маркеров ? своим кодом, а затем передавайте значения кортежем. Не вклеивайте значения пользователя в текст.
  • Хелперы фильтрации. Функция, которая принимает строку фильтра и дописывает её к условию WHERE, становится точкой конкатенации, даже если остальное приложение правильно использует ORM.

Частые ошибки

Чёрные списки слов. Фильтр, отвергающий OR, SELECT, кавычки или --, легко обойти заменой регистра, кодировками и разными стилями комментариев. OWASP cheat sheet ясно говорит, что валидация не заменяет привязку параметров. Сначала используйте привязку. Валидация полезна для фиксированных структурных вариантов, описанных выше, но как основная защита она не годится.

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

Расчёт только на минимальные привилегии. Учётная запись базы данных с меньшими правами ограничивает то, что атакующий сможет сделать после инъекции. Саму инъекцию она не останавливает. OWASP относит минимальные привилегии к вспомогательным мерам и говорит, что у учётных записей приложения никогда не должно быть прав DBA или админа. Делайте и то и другое: параметризуйте запросы и выдавайте приложению минимальный набор нужных ему прав.

Параметризация значений и забытое остальное. Команда переводит условие WHERE на плейсхолдеры, а затем, не задумываясь, вклеивает столбец сортировки. Проверяйте каждое место, где текст попадает в запрос, включая наименее очевидные.

Тестирование только обычного ввода. Ошибка проявляется только тогда, когда ввод содержит синтаксис SQL. Добавьте тест, который отправляет специально составленное значение и ожидает пустой результат или отклонённый запрос.

Что дальше

Прочитайте раздел документации Python sqlite3 о плейсхолдерах, затем попробуйте тот же шаблон с драйвером вашего проекта. Прочитайте OWASP SQL Injection Prevention Cheat Sheet от начала до конца, особенно разделы о хранимых процедурах и динамических идентификаторах.

Вернитесь в лабораторию Juice Shop и так же внимательно решите остальные задания, связанные с SQL. Для каждого запишите запрос, который, по вашему мнению, выполняет сервер, а затем сверьтесь с исходным кодом.

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

Поищите в своём коде f-строки, форматирование через %, .format() и конкатенацию строк рядом с execute, raw, text или extra. Каждое совпадение стоит проверить, хотя автоматически ошибкой оно не считается.

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

Следующая статья цикла посвящена межсайтовому скриптингу (XSS), устроенному похожим образом: недоверенный текст в итоге интерпретируется как то, чем быть не должен.

Источники

Источники

  1. OWASP SQL Injection Prevention Cheat Sheet
  2. MITRE CWE-89: Improper Neutralization of Special Elements used in an SQL Command
  3. OWASP Pwning OWASP Juice Shop companion guide
  4. Python sqlite3 DB-API 2.0 interface for SQLite databases

Ещё по теме: Безопасность веба и API

Безопасность веба и API

Чтение JavaScript с ИИ для поиска endpoints

Фронтенд на JavaScript перечисляет API, с которым говорит приложение. Модель ИИ читает бандлы и выносит endpoints и параметры, а вы проверяете каждый.

Начальный3 мин чтения

Безопасность веба и API

Тестирование частых веб-багов с помощью ИИ

Методика для IDOR, XSS, SSRF и сломанной аутентификации: модель ИИ как читатель и второе мнение, плюс как проверить находку и избежать ложных срабатываний.

Начальный3 мин чтения

Безопасность веба и API

Тестирование API с ИИ: OpenAPI и GraphQL

API описывают свою форму через OpenAPI и introspection в GraphQL. Модель ИИ читает схему и планирует тесты авторизации, которые вы проверяете.

Средний3 мин чтения