SQL-инъекцией называют атаку, при которой данные пользователя попадают в текст SQL-запроса и меняют его смысл. Приложение склеивает запрос к базе данных из строки. Кавычка с условием в поле ввода переписывает проверку, и база возвращает всю таблицу или пускает в чужой аккаунт без пароля. Через ту же дыру злоумышленник читает чужие данные и меняет записи. Это одна из самых частых уязвимостей веб-приложений.
Рабочая защита для значений одна: параметризованные запросы. Валидация ввода, права учётной записи в базе и межсетевой экран её дополняют.
Код ниже запускается на Python 3.14 со встроенным модулем sqlite3 (SQLite 3.50). Все фрагменты идут по порядку и складываются в один файл, база данных живёт в памяти, ставить ничего не нужно. В других языках механизм тот же: в PHP это PDO, в Java PreparedStatement, в C# коллекция SqlCommand.Parameters с именами вида @id.
- Как работает SQL-инъекция
- Обход авторизации: пример атаки
- UNION: чтение данных из других таблиц
- Точка с запятой и DROP TABLE
- Чем опасна SQL-инъекция
- Виды SQL-инъекций
- Защита: параметризованные запросы
- Почему экранирование кавычек не заменяет параметры
- Имена столбцов: whitelist
- ORM и права учётной записи
- Как найти SQL-инъекцию в своём коде
- Частые вопросы
- Достаточно ли экранировать кавычки, если поле строковое?
- Защищает ли ORM сам по себе?
- Можно ли закрыться только межсетевым экраном (WAF)?
Как работает SQL-инъекция
Учебная база состоит из двух таблиц: users с логинами и паролями и products с товарами. Пароли лежат открытым текстом только ради наглядности, в настоящем приложении хранят хеш.
import sqlite3
def make_db():
conn = sqlite3.connect(":memory:")
cur = conn.cursor()
cur.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, password TEXT, role TEXT)")
cur.executemany(
"INSERT INTO users (username, password, role) VALUES (?, ?, ?)",
[("admin", "S3cret!", "admin"), ("ivan", "12345", "user"), ("anna", "qwerty", "user")],
)
cur.execute("CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, price INTEGER)")
cur.executemany("INSERT INTO products (name, price) VALUES (?, ?)",
[("Книга", 500), ("Кружка", 300)])
conn.commit()
return conn
Уязвимая функция входа собирает SQL конкатенацией строк: подставляет логин и пароль прямо в текст запроса. Строку print добавили, чтобы видеть, что уходит в базу.
def login_vulnerable(conn, username, password):
cur = conn.cursor()
query = ("SELECT id, username, role FROM users "
"WHERE username = '" + username + "' AND password = '" + password + "'")
print("SQL:", query)
cur.execute(query)
return cur.fetchall()
conn = make_db()
print(login_vulnerable(conn, "anna", "qwerty"))
print(login_vulnerable(conn, "anna", "wrong"))
SQL: SELECT id, username, role FROM users WHERE username = 'anna' AND password = 'qwerty'
[(3, 'anna', 'user')]
SQL: SELECT id, username, role FROM users WHERE username = 'anna' AND password = 'wrong'
[]
С верным паролем функция вернула строку пользователя, с неверным пустой список. Сбой начинается с ввода, в котором есть одинарная кавычка: она закрывает строковый литерал, и всё, что идёт за ней, база данных читает как SQL-код.
Тот же корень у опасного eval, который исполняет чужую строку как программу. Почему его не берут для пользовательского ввода, показано на примере парсера математических выражений.

Обход авторизации: пример атаки
В поле пароля вводим ' OR '1'='1:
print(login_vulnerable(conn, "anna", "' OR '1'='1"))
SQL: SELECT id, username, role FROM users WHERE username = 'anna' AND password = '' OR '1'='1'
[(1, 'admin', 'admin'), (2, 'ivan', 'user'), (3, 'anna', 'user')]
Условие '1'='1' истинно для каждой строки, а AND связывает сильнее OR, поэтому проверка пароля больше ни на что не влияет. База вернула всех трёх пользователей, первым идёт администратор. Код, который берёт первую строку результата как вошедшего пользователя, пустит атакующего под admin.
Второй приём короче. В поле логина вводим admin'--: кавычка закрывает имя, а два дефиса начинают комментарий до конца строки.
print(login_vulnerable(conn, "admin'--", "что угодно"))
SQL: SELECT id, username, role FROM users WHERE username = 'admin'--' AND password = 'что угодно'
[(1, 'admin', 'admin')]
Проверка пароля попала в комментарий, и вход под администратором прошёл с любым паролем.
UNION: чтение данных из других таблиц
Инъекция работает в любом месте, где ввод попадает в SQL. Возьмём поиск товаров по названию:
def search_vulnerable(conn, term):
cur = conn.cursor()
query = "SELECT name, price FROM products WHERE name LIKE '%" + term + "%'"
print("SQL:", query)
cur.execute(query)
return cur.fetchall()
print(search_vulnerable(conn, "' UNION SELECT username, password FROM users-- "))
SQL: SELECT name, price FROM products WHERE name LIKE '%' UNION SELECT username, password FROM users-- %'
[('admin', 'S3cret!'), ('anna', 'qwerty'), ('ivan', '12345'), ('Книга', 500), ('Кружка', 300)]
Оператор UNION приклеивает к результату строки второго SELECT, если число столбцов совпадает. Логины и пароли из таблицы users пришли в ту же выдачу, где приложение ждёт названия и цены товаров.
Точка с запятой и DROP TABLE
Ввод '; DROP TABLE users-- пытается дописать к поиску вторую команду. Метод cursor.execute в sqlite3 выполняет ровно один оператор:
try:
search_vulnerable(conn, "'; DROP TABLE users-- ")
except sqlite3.ProgrammingError as e:
print("ProgrammingError:", e)
print(conn.execute("SELECT count(*) FROM users").fetchone())
SQL: SELECT name, price FROM products WHERE name LIKE '%'; DROP TABLE users-- %'
ProgrammingError: You can only execute one statement at a time.
(3,)
Таблица цела, в ней три строки. Метод executescript того же модуля выполняет весь скрипт:
conn.executescript("SELECT 1; DROP TABLE users;")
print(conn.execute("SELECT name FROM sqlite_master WHERE name = 'users'").fetchall())
conn = make_db() # дальше примеры идут на свежей базе
[]
Пустой список значит, что таблицы users в базе больше нет. Последняя строка блока создаёт базу заново для следующих примеров. Другие драйверы ведут себя по-своему: у драйвера MySQL для Go, например, есть параметр multiStatements, по умолчанию выключенный, с ним несколько команд в одном запросе разрешены. Чтение через OR и UNION от этих настроек не зависит.
Чем опасна SQL-инъекция
Ущерб зависит от того, что хранит база данных и какие права у учётной записи, от которой работает приложение.
| Что получает атакующий | Как это выглядело в примерах |
|---|---|
| Вход в чужой аккаунт или в админку | admin'-- в поле логина |
| Чтение конфиденциальной информации: логины, пароли, личные данные | UNION SELECT в поиске товаров |
| Изменение и удаление данных | DROP TABLE через executescript |
| Сведения о структуре базы | текст ошибки СУБД, разобранный в следующем разделе |
Если учётная запись приложения может удалять таблицы, а драйвер пропускает несколько команд, инъекция сможет их удалить. Если у неё есть только чтение нужных таблиц, атакующему достанется меньше.

Виды SQL-инъекций
Виды SQL-инъекций различают по тому, как атакующий получает ответ базы.
| Тип | Как получает данные |
|---|---|
Подмена условия (OR '1'='1), in-band | результат виден прямо на странице |
| UNION-based, разновидность in-band | строки из других таблиц приходят вместе с обычной выдачей |
| Error-based, разновидность in-band | сведения о базе берут из текста сообщения об ошибке |
| Слепая (blind) | данных на странице нет, вывод делают по ответу «да» или «нет» |
| По времени (time-based) | вывод делают по тому, насколько дольше сервер отвечает |
Слепая SQL-инъекция нужна атакующему, когда приложение не показывает ни данные, ни ошибки. Он задаёт условие и смотрит, изменилась ли страница или время ответа, и так подбирает данные по одному символу.
Error-based атака опирается на текст ошибки. Обычный логин с апострофом ломает уязвимый запрос:
try:
login_vulnerable(conn, "O'Brien", "x")
except sqlite3.OperationalError as e:
print("OperationalError:", e)
SQL: SELECT id, username, role FROM users WHERE username = 'O'Brien' AND password = 'x'
OperationalError: near "Brien": syntax error
Сообщение показывает кусок SQL, на котором споткнулся разбор. Если приложение выводит такой текст пользователю, атакующий по нему узнаёт, как устроен запрос. На проде пользователю показывают общее сообщение, а подробности пишут в лог. Как перехватывать ошибки на стороне самой СУБД, разобрано в статье про TRY CATCH в SQL Server.
Защита: параметризованные запросы
В параметризованном запросе текст SQL и значения передаются в драйвер раздельно. В SQL на месте значения стоит плейсхолдер (в sqlite3 знак вопроса), сами значения идут вторым аргументом execute. Драйвер подставляет их как данные, поэтому кавычки, дефисы и слова OR внутри значения остаются обычными символами строки. В PHP и Java для этого служат подготовленные запросы (prepared statements).
def login_safe(conn, username, password):
cur = conn.cursor()
query = "SELECT id, username, role FROM users WHERE username = ? AND password = ?"
cur.execute(query, (username, password))
return cur.fetchall()
print(login_safe(conn, "anna", "' OR '1'='1"))
print(login_safe(conn, "admin'--", "что угодно"))
print(login_safe(conn, "anna", "qwerty"))
[]
[]
[(3, 'anna', 'user')]
Оба ввода, которые ломали уязвимую версию, вернули пустой список. База искала пользователя anna с паролем ' OR '1'='1 и пользователя по имени admin'--, таких строк нет, вход отклонён. Честный вход работает как раньше.
Через плейсхолдер идёт любое значение, пришедшее снаружи: поле формы, параметр URL, заголовок, cookie, поле JSON. Числа и «внутренние» поля тоже.
Плейсхолдеры бывают именованными, так удобнее, когда значений много:
cur = conn.cursor()
cur.execute("SELECT id, username FROM users WHERE username = :name", {"name": "' OR '1'='1"})
print(cur.fetchall())
cur.execute("SELECT id, username FROM users WHERE username = :name", {"name": "ivan"})
print(cur.fetchall())
[]
[(2, 'ivan')]

Почему экранирование кавычек не заменяет параметры
Частая самодельная защита удваивает кавычки во вводе. Для строк в кавычках этого хватает, но в числовом поле кавычек нет:
def escape(value):
return value.replace("'", "''") # удваиваем кавычку, как требует SQL
def find_user(conn, user_id):
query = "SELECT id, username FROM users WHERE id = " + escape(user_id)
print("SQL:", query)
return conn.execute(query).fetchall()
print(find_user(conn, "2"))
print(find_user(conn, "2 OR 1=1"))
SQL: SELECT id, username FROM users WHERE id = 2
[(2, 'ivan')]
SQL: SELECT id, username FROM users WHERE id = 2 OR 1=1
[(1, 'admin'), (2, 'ivan'), (3, 'anna')]
Кавычек во втором вводе нет, экранировать нечего, и OR 1=1 вернул всех пользователей. У mysqli_real_escape_string в PHP свои условия: функция не ставит кавычки вокруг результата, зависит от кодировки соединения, а в режиме MySQL NO_BACKSLASH_ESCAPES экранирует только одинарную кавычку. Вместо неё берут подготовленные запросы. Мы бы ручное экранирование в новом коде не применяли. Чем в PHP отличаются одинарные и двойные кавычки в самих строках, разобрано в статье про строки в PHP.
Имена столбцов: whitelist
Плейсхолдер заменяет только значение. Имя таблицы или столбца через него не передать:
cur.execute("SELECT username FROM users ORDER BY ?", ("username",))
print(cur.fetchall())
[('admin',), ('ivan',), ('anna',)]
Здесь строки пришли в порядке вставки, хотя без рабочего ORDER BY порядок вообще не гарантирован. База получила строку 'username' как значение и сортировала по одинаковой для всех строк константе.
Когда пользователь выбирает столбец для сортировки, имя сверяют со списком разрешённых, то есть с whitelist:
def order_by(conn, column):
allowed = {"id", "username"}
if column not in allowed:
raise ValueError("Недопустимый столбец сортировки: " + column)
cur = conn.cursor()
cur.execute("SELECT username FROM users ORDER BY " + column)
return cur.fetchall()
print(order_by(conn, "username"))
try:
order_by(conn, "username; DROP TABLE users")
except ValueError as e:
print("ValueError:", e)
[('admin',), ('anna',), ('ivan',)]
ValueError: Недопустимый столбец сортировки: username; DROP TABLE users
В SQL попадает только имя из набора, который задал разработчик. Посторонняя строка останавливается исключением ещё до обращения к базе.
ORM и права учётной записи
ORM строит параметризованные запросы сам. В Django User.objects.filter(username=name) защищён от инъекции. Риск возвращается вместе с сырым SQL: raw(), extra(), RawSQL и прямой cursor.execute со склеенной строкой. В них значения тоже передают параметрами.
Учётной записи, под которой приложение подключается к базе, дают только нужные права: чтение и запись своих таблиц, без DROP и без доступа к чужим схемам. В SQLite учётных записей нет, но в PostgreSQL или MySQL такой DROP TABLE упрётся в нехватку прав, даже если где-то осталась инъекция.
Как найти SQL-инъекцию в своём коде

На ревью ищут место, где значение снаружи попадает в текст SQL через +, f-строку, .format() или %. Сюда же относятся сырые запросы внутри ORM и имена столбцов без whitelist.
Для Python такие места находит статический анализатор Bandit. Ниже фрагмент его вывода по файлу со всеми примерами статьи (Bandit 1.9.4), одна находка из четырёх:
>> Issue: [B608:hardcoded_sql_expressions] Possible SQL injection vector through string-based query construction.
Severity: Medium Confidence: Low
Правило B608 сработало четыре раза: на сборке запроса в login_vulnerable, search_vulnerable, find_user с самодельным экранированием и order_by. Последнее срабатывание ложное, столбец там уже прошёл whitelist. Его проверяют глазами и помечают комментарием # nosec B608. Функцию login_safe анализатор не отметил.
Ручная проверка на своём стенде: одинарная кавычка в поле ввода. Ошибка базы или вход с ' OR '1'='1 значат, что запрос собирается из строк. Сканеры уязвимостей вроде SQLMap запускают только на своём стенде или в системе, на проверку которой есть письменное разрешение владельца.
Найденное место исправляют так: значения переводят на плейсхолдеры, имена столбцов и таблиц сверяют с whitelist.
Частые вопросы
Достаточно ли экранировать кавычки, если поле строковое?
Для одного строкового поля с правильной кодировкой экранирование может сработать, но каждое новое поле приходится проверять заново: числовое, поле в двойных кавычках, другой режим СУБД. Подготовленный запрос закрывает все эти случаи одной схемой, поэтому в новом коде берут его.
Защищает ли ORM сам по себе?
Да, пока запросы строятся методами ORM вроде filter и get. Как только в коде появляется сырой SQL, собранный из строк, защита на этот участок не распространяется, и значения нужно передать параметрами.
Можно ли закрыться только межсетевым экраном (WAF)?
WAF фильтрует запросы по шаблонам и отсекает типовые атаки, но шаблоны обходят. Он годится как дополнительный слой, а не как замена исправленного кода.








