SQL-инъекция: или как ваш код превращается в SQL-калькулятор для злоумышленников

Введение: почему SQL-инъекция — это не просто "баг в коде"

SQL-инъекция — это не академический термин из учебников. Это проблема, которая конкретно бьёт по бизнесу: утечка данных клиентов, кража денег, взлом аккаунтов, а иногда и полный контроль над сервером. В 2023 году уязвимости этого типа всё ещё занимают топ-3 в рейтинге самых опасных багов (по данным OWASP и CVE-базы). Причём речь не только о стартапах — даже крупные компании регулярно становятся жертвами из-за банальных ошибок в обработке пользовательского ввода.

Главная особенность SQL-инъекции в том, что она не требует сложных инструментов — достаточно текстового поля в форме, некорректного запроса и базы данных, которая доверяет пользователю. В этой статье разберём:

  1. Как именно выглядит эксплойт на практике (с реальными примерами).
  2. Какие ошибки в коде приводят к уязвимостям (и как их не допускать).
  3. Как защищаться эффективно, без перегибов вроде полного отказа от ORM.
  4. Что делать, если уязвимость уже есть (пошаговый алгоритм для DevOps и разработчиков).

Проблема: как злоумышленник превращает ваш запрос в свой скрипт

Представьте, что у вас есть форма авторизации. Пользователь вводит логин и пароль, а ваш код формирует SQL-запрос вроде такого:

SELECT * FROM users WHERE login = '$login' AND password = '$password';

Если $login и $password не экранированы, то злоумышленник может подставить вместо них SQL-код. Например, вместо логина admin он введёт:

admin' --

Результат запроса:

SELECT * FROM users WHERE login = 'admin' --' AND password = '$password';

Знаком -- в SQL начинается однострочный комментарий. Всё после него игнорируется. Получается, запрос сводится к:

SELECT * FROM users WHERE login = 'admin';

То есть пользователь авторизовался без пароля.

Пример 2: кража данных через UNION

Допустим, у вас есть поисковая форма, которая выполняет запрос:

SELECT * FROM products WHERE name LIKE '%$search_query%';

Злоумышленник вводит:

' UNION SELECT username, password FROM users --

Если таблица products имеет те же количество колонок, что и users, запрос вернёт все логины и пароли из таблицы пользователей. В реальных системах это работает ещё проще — достаточно знать структуру базы.

Почему это работает в продакшене

  1. Доверчивые библиотеки: Многие ORM (например, старые версии Django ORM или SQLAlchemy) позволяют формировать "сырые" запросы, если разработчик не следит за параметризацией.
  2. Ленивая валидация: Проверка на "только буквы" или "только цифры" не спасает — хакеры используют Unicode, HTML-сущности, многобайтовые символы.
  3. Логирование и отладка: В продакшене часто встречаются запросы вроде:
    EXECUTE('SELECT * FROM ' || table_name || ' WHERE id = ' || id);
    
    Здесь table_name и id берутся из пользовательского ввода без экранирования.

Практика: как SQL-инъекция выглядит в реальных проектах

Пример 1: Уязвимость в API на Node.js (Express + Sequelize)

Допустим, у вас есть эндпоинт для получения пользователя по ID:

// Уязвимый код (не используйте так!)
app.get('/user/:id', async (req, res) => {
  const user = await User.findOne({
    where: {
      id: req.params.id // <-- Прямое внедрение в SQL!
    }
  });
  res.json(user);
});

Как это эксплуатируется: Запрос GET /user/1; DROP TABLE users-- приведёт к удалению таблицы пользователей.

Исправление:

// Безопасный вариант с параметризацией
app.get('/user/:id', async (req, res) => {
  const user = await User.findOne({
    where: { id: req.params.id } // Sequelize автоматически экранирует
  });
  res.json(user);
});

Но! Если вы используете sequelize.literal, то риск возвращается:

// Опасно!
const user = await User.findOne({
  where: sequelize.literal(`id = ${req.params.id}`) // <-- SQL-инъекция!
});

Пример 2: PHP + PDO — как не надо делать

Многие считают PDO защищённым от SQL-инъекций, но только если использовать подготовленные выражения (prepare + execute). Вот как не работает:

// Уязвимый код
$stmt = $pdo->query("SELECT * FROM users WHERE username = '$username'");
$user = $stmt->fetch();

Рабочий вариант:

// Безопасно
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username");
$stmt->execute(['username' => $username]);
$user = $stmt->fetch();

Но даже тут есть подводные камни:

  • Если вы используете sprintf для вставки параметров:
    // ОПАСНО! sprintf обходит prepare
    $stmt = $pdo->prepare("SELECT * FROM users WHERE username = %s");
    $stmt->execute([sprintf($username)]);
    
  • Если базу конфигурируют через переменные окружения, а не хардкодят, риск снижается, но не исчезает полностью.

Типичные ошибки, которые ведут к SQL-инъекциям

  1. Конкатенация строк в SQL-запросах

    # Python + psycopg2 (опасно)
    cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")
    
  2. Использование eval или динамического выполнения SQL

    // Node.js (катастрофа)
    const query = `SELECT * FROM ${table} WHERE ${condition}`;
    connection.query(query, callback);
    
  3. Неправильная параметризация в ORM

    // Hibernate (опасно, если использовать CriteriaQuery неверно)
    CriteriaBuilder cb = entityManager.getCriteriaBuilder();
    CriteriaQuery<User> query = cb.createQuery(User.class);
    Root<User> root = query.from(User.class);
    query.where(cb.equal(root.get("id"), userId)); // <-- Если userId не экранирован
    
  4. Забывчивость при работе с JSON или XML

    -- Уязвимость через JSON_EXTRACT (MySQL)
    SELECT * FROM orders WHERE JSON_EXTRACT(data, '$.user_id') = '$user_id';
    

    Здесь $user_id должен экранироваться, но часто этого не делают.

  5. Ошибки в логировании и отладке

    # Логирование полного запроса (утечка данных!)
    logger.error(f"Failed query: SELECT * FROM users WHERE id = {user_id}")
    
  6. Использование устаревших библиотек

    • mysql_* функции в PHP (устарели в 2012 году).
    • java.sql.DriverManager без параметризации.
  7. Предположение о "безопасных" данных

    -- Допущение, что IP-адрес безопасен
    SELECT * FROM logs WHERE ip = '$ip';
    

    На самом деле $ip может быть 127.0.0.1' OR '1'='1.


Как защищаться: практические шаги

1. Параметризация запросов ( Prepared Statements )

Это первое правило. Все современные библиотеки поддерживают подготовленные выражения:

  • Python: psycopg2, sqlite3 (используйте %s или ?).
  • Java: PreparedStatement.
  • Node.js: mysql2/promise или pg (не mysql).
  • PHP: PDO::prepare или mysqli_prepare.

Пример на Python:

import psycopg2

conn = psycopg2.connect("dbname=test user=postgres")
cursor = conn.cursor()

# Безопасно
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))

2. Используйте ORM с умом

ORM (Django ORM, SQLAlchemy, Hibernate) частично защищают от SQL-инъекций, но только если не использовать "сырые" запросы. Например:

# Безопасно (Django ORM)
User.objects.filter(id=user_id)

Но:

# Опасно (сырой запрос в Django)
User.objects.raw(f"SELECT * FROM auth_user WHERE id = {user_id}")

3. Экранирование на уровне приложения

Если параметризация невозможна (например, в legacy-коде), используйте экранирование:

  • MySQL: mysql_real_escape_string() (но лучше перейти на подготовленные выражения).
  • PostgreSQL: pg_escape_string().
  • Для Unicode: используйте binascii.b2a_hex() для ручного экранирования.

Пример экранирования в Python:

import binascii

def escape_sql(input_str):
    return input_str.replace("'", "''").replace('"', '""')

# Или для Unicode:
def escape_unicode(input_str):
    return binascii.b2a_hex(input_str.encode('utf-8')).decode('ascii')

4. Принцип наименьших привилегий

База данных должна работать под непривилегированной учёткой:

  • Учётка для приложения не должна иметь прав DROP TABLE, ALTER DATABASE и т.д.
  • Используйте роли с минимальными правами (например, только SELECT для чтения данных).

Пример конфигурации PostgreSQL:

CREATE USER app_user WITH PASSWORD 'secure_password';
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_user;

5. Веб-приложение: валидация + фильтрация

  • Валидируйте входные данные на уровне приложения (например, с помощью Pydantic, Zod или Joi).
  • Фильтруйте опасные символы (но не полагайтесь только на это!):
    import re
    
    def filter_suspicious(input_str):
        return re.sub(r"[\'\";\\-]", "", input_str)
    
    Но: это не замена параметризации!

6. Обновление зависимостей

Регулярно проверяйте уязвимости в используемых библиотеках:

  • npm: npm audit.
  • Python: pip list --outdated.
  • Java: dependency-check.

Пример команды для поиска уязвимостей в Maven:

mvn dependency:check-versions org.owasp:dependency-check-maven:check

Вывод: как не стать жертвой SQL-инъекции

  1. Параметризуйте все запросы — это единственный надёжный способ. Никакие фильтры или валидаторы не заменят подготовленные выражения.
  2. Используйте ORM правильно — избегайте сырых SQL-запросов, если это возможно.
  3. Ограничьте права базы данных — учётка приложения не должна иметь административных прав.
  4. Автоматизируйте проверки:
    • Используйте статические анализаторы (например, SQLMap, Bandit для Python, Checkmarx).
    • Настройте динамический анализ (например, OWASP ZAP или Burp Suite).
  5. Обновляйте зависимости — многие уязвимости появляются из-за старых версий библиотек.
  6. Логируйте подозрительную активность — если в запросе появляются символы ';--, UNION, DROP, это повод для алерта.

Последний практический совет

Если вы наследуете проект с потенциальными уязвимостями:

  1. Сначала проверьте все места, где пользовательский ввод попадает в SQL-запрос (формы, API, логи, отчёты).
  2. Начните с самых критичных (авторизация, финансовые операции, данные клиентов).
  3. Не полагайтесь на "это никогда не произойдёт" — SQL-инъекции эксплуатируются автоматически сканерами, даже если у вас нет публичного API.

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