Безопасность приложений Инъекции: SQL, NoSQL, команды, шаблоны — механика и защита
0%

Инъекции: SQL, NoSQL, команды, шаблоны — механика и защита

Инъекции: SQL, NoSQL, команды, шаблоны — механика и защита

Первая публичная заметка о SQL-инъекции — статья Rain Forest Puppy в Phrack 54 (1998). С тех пор сменились языки, фреймворки, СУБД и целые парадигмы разработки, а класс дефекта живёт: в OWASP Top 10 2021 инъекции стоят на третьем месте, и 94% проверенных приложений содержали хотя бы один дефект этого класса. Дело не в том, что разработчики не знают про ? в запросе. Дело в том, что инъекция — не одна уязвимость, а свойство любого места, где программа строит текст для другого интерпретатора, а таких мест в современном сервисе десятки: SQL, поисковый DSL, shell, шаблон письма, XPath, LDAP-фильтр, HTTP-заголовок, строка лога, промпт к языковой модели.

Статья — про защиту. Мы разбираем, как уязвимость работает, ровно настолько, чтобы вы могли увидеть её в своём коде, починить и закрыть тестом. Каждое семейство идёт по одной схеме: уязвимый код → почему это работает → как чинить → как проверить. Предполагается, что вы прошли моделирование угроз и знаете границы доверия своей системы, а также читали разбор OWASP Top 10, где A03 упомянут обзорно.

Правила игры: где можно экспериментировать

Всё, что ниже, применимо только к системам, которыми вы владеете, или к тем, на тестирование которых есть письменное разрешение владельца — договор с подписанным scope и rules of engagement, окнами проведения, перечнем методов и контактами на случай инцидента. Проверка чужого сервиса «просто посмотреть, уязвим ли он» — не исследование, а несанкционированный доступ, и намерения тут ничего не меняют. Внутри своей компании согласование тоже обязательно: продакшн-база, на которой «случайно» сработал DROP, — это инцидент независимо от того, кто его вызвал.

Для практики есть легальные полигоны, которые поднимаются локально: OWASP Juice Shop, OWASP WebGoat, PortSwigger Web Security Academy и DVWA.

Анатомия инъекции: source, sink и граница разбора

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

Разбор конкатенированной и параметризованной строки запроса

Отсюда словарь, который дальше используется постоянно:

  • source — точка входа недоверенных данных: тело запроса, query-параметр, заголовок, cookie, имя загруженного файла, сообщение из очереди, ответ чужого API, строка из вашей же БД, если её туда записал пользователь;
  • sink — вызов, передающий строку интерпретатору: cursor.execute, exec.Command("sh", "-c", …), render_template_string, new Function;
  • taint — свойство значения «пришло из source и не прошло безопасного преобразования»;
  • безопасное преобразование — не «убрали плохие символы», а перевод значения в канал, который грамматикой не разбирается: параметр запроса, элемент argv, аргумент функции шаблона.

Обратите внимание на источник A3: значение, лежащее в вашей БД, недоверенное, если его туда положил пользователь. Это основа second-order injection, к которой мы вернёмся.

SQL-инъекция

Как выглядит уязвимый код

# УЯЗВИМО. Пример для разбора механики, не для копирования.
def find_orders(cursor, user_id: str, status: str):
    query = f"SELECT id, total FROM orders WHERE user_id = '{user_id}' AND status = '{status}'"
    cursor.execute(query)          # строка уже склеена — СУБД получит её как есть
    return cursor.fetchall()

Менее очевидные варианты того же дефекта, которые ревью пропускает чаще:

# 1. Параметризация есть, но не везде: sort пришёл из query-параметра
cursor.execute("SELECT * FROM orders WHERE user_id = %s ORDER BY " + sort, (user_id,))
# 2. "Безопасный" ORM с сырым куском
User.objects.raw(f"SELECT * FROM users WHERE email = '{email}'")        # Django
session.execute(text(f"SELECT * FROM users WHERE email = '{email}'"))   # SQLAlchemy
# 3. Динамический фильтр, собранный из словаря
where = " AND ".join(f"{k} = '{v}'" for k, v in filters.items())
# 4. В .NET: FromSqlInterpolated параметризует интерполяцию,
#    а FromSqlRaw($"...{email}...") получает уже склеенную строку — это уязвимо

Почему это работает

СУБД получает одну строку и разбирает её по грамматике SQL. Если в данных встречается символ, закрывающий строковый литерал, лексер честно закрывает литерал, и всё, что идёт дальше, становится синтаксисом: предикатом, выражением, комментарием --, обрезающим хвост запроса. Классическая иллюстрация — значение ' OR '1'='1: предикат WHERE user_id = '' OR '1'='1' истинен для каждой строки таблицы.

Важное следствие, которое часто недооценивают: уязвимость есть и там, где результат запроса пользователю не возвращается. Кроме прямого варианта (UNION, текст ошибки) существуют слепые: boolean-based различает ответы по признаку страницы, time-based обходится единственным битом — задержкой ответа, out-of-band использует исходящий DNS/HTTP-запрос самой СУБД. Поэтому «скрыть подробности ошибок» и «не показывать данные» — полезные меры второго эшелона, но дефект они не устраняют; устраняет только разделение каналов, а против out-of-band дополнительно работает запрет исходящего трафика от БД.

Что происходит на проводе

Разница между конкатенацией и параметризацией — это разница между simple и extended query protocol (документация PostgreSQL).

После ParseComplete дерево запроса построено. Значение, пришедшее в Bind, кладётся в узел-параметр и уже не может изменить структуру: это гарантия по построению, а не проверка на «плохие символы». Нюанс: часть драйверов эмулирует подготовленные выражения на клиенте (MySQL Connector при prepared=False, PDO при ATTR_EMULATE_PREPARES=true) — тогда экранирование делает библиотека, и корректность зависит от совпадения кодировок клиента и сервера. Исторические обходы через multibyte-кодировки (GBK, SJIS) жили именно здесь. Правило: соединение в UTF-8 и серверные prepared statements, если драйвер умеет.

Как чинить

Параметризация обязана быть везде, а не там, где данные «выглядят опасными».

# Python + psycopg 3: значения уходят отдельным сообщением Bind
with conn.cursor() as cur:
    cur.execute("SELECT id, total FROM orders WHERE user_id = %s AND status = %s",
                (user_id, status))
    rows = cur.fetchall()
// Go: database/sql — плейсхолдеры зависят от драйвера ($1 у pgx, ? у MySQL)
rows, err := db.QueryContext(ctx,
    `SELECT id, total FROM orders WHERE user_id = $1 AND status = $2`, userID, status)
if err != nil {
    return nil, fmt.Errorf("запрос заказов: %w", err)
}
defer rows.Close()

В Node.js это второй аргумент-массив pool.query(sql, [userId, status]), а не шаблонная строка; в Java — PreparedStatement с setLong/setString вместо Statement и конкатенации.

Чего параметризовать нельзя

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

SORTABLE = {"created": "created_at", "total": "total_amount", "status": "status"}
DIRECTIONS = {"asc": "ASC", "desc": "DESC"}

def orders_query(sort: str, direction: str) -> str:
    column = SORTABLE.get(sort)
    if column is None:
        raise ValueError("недопустимое поле сортировки")   # 400, а не «подставим как есть»
    order = DIRECTIONS.get(direction, "ASC")
    return f"SELECT id, total FROM orders WHERE user_id = %s ORDER BY {column} {order}"

Если структура действительно должна быть динамической (админский конструктор отчётов), берите API, умеющее квотировать идентификаторы с учётом диалекта: psycopg.sql.Identifier(column), pgx.Identifier{…}.Sanitize() в Go, встроенные билдеры в .NET. Ручное '"' + name + '"' не считается — имя может содержать кавычку. LIMIT/OFFSET параметризуются в большинстве СУБД; если драйвер не позволяет, приводите к int явно и ограничивайте сверху (min(limit, 200)), а не подставляйте строку.

ORM — не иммунитет

ORM защищает ровно там, где вы пользуетесь его выразительными средствами. Регулярные способы пробить защиту: session.execute(text(...)) с конкатенацией, Django .extra() и RawSQL, шаблонная строка в query builder.

repo.createQueryBuilder("u").where(`u.email = '${email}'`).getMany();        // УЯЗВИМО
repo.createQueryBuilder("u").where("u.email = :email", { email }).getMany(); // корректно

Хранимые процедуры тоже не панацея: если внутри собирается динамический SQL через EXEC(@sql) или EXECUTE IMMEDIATE, дефект просто переехал в БД, где его хуже видит SAST. Внутри процедуры правильный шаблон — sp_executesql с параметрами (MS SQL) или EXECUTE … USING плюс quote_ident/quote_literal (PostgreSQL, Oracle).

Второй эшелон: сузить последствия

Параметризация закрывает вход. Зрелая система проектируется так, чтобы даже успешная инъекция где-то в углу не стала компрометацией всей БД, — это прямое приложение принципа минимальных привилегий (NIST SP 800-53, AC-6).

-- Отдельная роль приложения: только нужные права, без DDL и суперпользователя
CREATE ROLE app_runtime LOGIN PASSWORD :'pw';
REVOKE ALL ON SCHEMA public FROM app_runtime;
GRANT USAGE ON SCHEMA app TO app_runtime;
GRANT SELECT, INSERT, UPDATE ON app.orders TO app_runtime;   -- без DELETE при мягком удалении

ALTER TABLE app.orders ENABLE ROW LEVEL SECURITY;            -- даже "OR 1=1" не выйдет
CREATE POLICY tenant_isolation ON app.orders                 -- за пределы арендатора
    USING (tenant_id = current_setting('app.tenant_id')::uuid);

ALTER ROLE app_runtime SET statement_timeout = '5s';         -- обрезает time-based

Дополняют картину: запрет multi-statement в драйвере (в расширенном протоколе PostgreSQL он невозможен, в MySQL — не включать CLIENT_MULTI_STATEMENTS) и общий обработчик ошибок, отдающий наружу идентификатор инцидента вместо текста исключения. Изоляция арендаторов разбирается в статье Авторизация: RBAC, ABAC, ACL, хранение пароля роли — в Секретах и ключах, устройство самой СУБД — в Реляционной модели.

Как проверить, что починено

1. Unit-тест с «канареечными» значениями. Берём строки, ломающие конкатенацию, но безобидные для параметра. Тест не «ищет уязвимость» — он фиксирует, что такие значения корректно сохраняются и находятся.

CANARIES = ["O'Brien", '"; --', "\\", "100%", "a_b", "1' OR '1'='1", "ЁЖ"]

@pytest.mark.parametrize("name", CANARIES)
def test_customer_name_roundtrip(repo, name):
    """Значение должно сохраниться байт в байт и найтись поиском."""
    created = repo.create_customer(name=name)
    assert repo.get(created.id).name == name
    assert created.id in {c.id for c in repo.search_by_name(name)}

2. Property-based тест на репозиторий: любая строка Unicode после записи и чтения возвращается неизменной, а число строк в таблице растёт ровно на единицу. Ловит и инъекцию, и потерю данных на экранировании.

3. SAST-правило в CI, запрещающее сам паттерн, а не «ищущее уязвимость»:

rules:                                    # .semgrep/no-string-sql.yml
  - id: no-fstring-sql
    languages: [python]
    severity: ERROR
    message: SQL собирается из строки. Используйте параметры или psycopg.sql.Identifier.
    patterns:
      - pattern-either:
          - pattern: $CUR.execute(f"...")
          - pattern: $CUR.execute("..." + $X)
          - pattern: sqlalchemy.text(f"...")

Готовые аналоги: CodeQL (java/sql-injection, go/sql-injection), gosec (G201/G202), Roslyn CA2100.

4. DAST на стенде (OWASP ZAP, sqlmap) — только против своей тестовой среды с тестовыми данными и с разрешения владельца; находка считается сигналом к анализу кода, а не доказательством отсутствия других дефектов. Методика — в статье Тестирование безопасности.

5. Ревью-инвариант: в кодовой базе нет ни одного места, где строка SQL получается конкатенацией с переменной. Проверяется грепом за секунды и держится годами.

Отложенная (second-order) инъекция

Самый неприятный подвид: значение сохранено абсолютно корректно, через параметр, байт в байт. А потом другой код читает его из БД и подставляет в строку, считая «это же наши данные».

Практическое правило: taint не снимается записью в хранилище. Значение становится безопасным в момент попадания в безопасный sink, и это свойство места использования, а не места хранения. Отсюда следует, что «отфильтруем на входе один раз» ломается при первом же новом потребителе данных. Типичные места, где всплывает second-order: генераторы отчётов, ETL-задания, письма с подстановкой имени, административные скрипты, миграции, экспорт в CSV — там же живёт CSV formula injection, когда значение, начинающееся с =, +, -, @, исполняется как формула в Excel (лечится префиксом ' и корректным Content-Type).

NoSQL-инъекция: когда запрос — не строка

Как выглядит уязвимый код

В MongoDB запрос — это документ. Конкатенации нет, поэтому кажется, что и инъекции нет. Но структура документа определяется типом пришедшего значения:

// УЯЗВИМО: req.body уходит в запрос как есть
const user = await users.findOne({
  email: req.body.email,
  password: req.body.password,   // ожидали строку
});

Если тело запроса — JSON, клиент может прислать не строку, а объект. Тогда { password: { $ne: null } } превращает сравнение на равенство в оператор «не равно», и условие перестаёт проверять то, что должно; то же делают $gt, $in, $regex. Ни один символ при этом не экранируется — проблема не в символах, а в том, что клиент управляет формой запроса. Отдельный уровень — $where и $function, принимающие JavaScript, который исполняется сервером БД: это уже не обход условия, а исполнение кода.

Почему это работает

Query-язык MongoDB — данные, а не текст, и это его сильная сторона. Уязвимость возникает, когда граница «внешний JSON → внутренняя структура запроса» отсутствует и тело запроса напрямую становится фильтром (CWE-943). Тот же дефект есть везде, где внешние данные определяют форму запроса: query_string в Elasticsearch с пользовательской строкой, резолвер GraphQL, кладущий args в фильтр целиком, вычисление выражений SpEL/OGNL/JEXL из ввода, ручная сборка команды Redis со вставкой CRLF в аргумент.

Как чинить

Три слоя, каждый обязателен: типизация на границе, запрет $-ключей, отключение серверного JS.

import { z } from "zod";

const LoginSchema = z.object({
  email: z.string().email().max(254),
  password: z.string().min(8).max(200),   // именно строка: объект не пройдёт
}).strict();                              // лишние поля — ошибка, а не «проигнорируем»

app.post("/login", async (req, res) => {
  const parsed = LoginSchema.safeParse(req.body);
  if (!parsed.success) return res.sendStatus(400);
  const { email, password } = parsed.data;
  const user = await users.findOne({ email });          // фильтр собран в коде
  if (!user) return res.sendStatus(401);
  if (!(await argon2.verify(user.passwordHash, password))) return res.sendStatus(401);
});

Обратите внимание: поиск пользователя по паре «логин + пароль» одним запросом — антипаттерн сам по себе, независимо от инъекции; правильная схема разбирается в статье Аутентификация. Дополнительный барьер — отсечение ключей с префиксом $ и точкой на входе (express-mongo-sanitize, mongoose со strictQuery); это защита в глубину, а не замена схеме. И наконец, $where, $accumulator, $function, mapReduce подавляющему большинству приложений не нужны: MongoDB запускается с security.javascriptEnabled: false, роль пользователя БД — минимальная, по той же логике, что и SQL-роль выше. Про модель данных — в статье MongoDB.

Как проверить

Самый ценный тест в этой категории отправляет объект вместо строки и ожидает 400, а не 401 и не 200: тела {email: {$ne: null}, password: "…"}, {email: "a@b.c", password: {$gt: ""}}, {…, $where: "1"}. Плюс контрактный тест на схему и запрет в CI на паттерны findOne(req.body) / find(req.query) — такое же правило semgrep, как для SQL.

Инъекция команд ОС

Как выглядит уязвимый код

subprocess.run(f"convert {path} -resize 200x200 thumb.png", shell=True)   # УЯЗВИМО
exec(`ffmpeg -i ${filename} -vf scale=320:-1 out.mp4`);   // exec всегда запускает шелл

В Go тот же дефект даёт явный exec.Command("sh", "-c", "tar xzf "+archive), в Java — Runtime.getRuntime().exec(String) с одной склеенной строкой.

Почему это работает

Между вашим кодом и программой встаёт ещё один интерпретатор — шелл. Он разбирает строку по своей грамматике: ;, |, &&, $(…), обратные кавычки, перенос строки, >, *, ~. Всё это управляющие конструкции, и шелл не знает, какие байты пришли от пользователя. Это CWE-78.

Запуск программы через шелл и напрямую через execve

Как чинить

Правило первое: не запускать шелл. Передавайте программу и аргументы массивом — ядро получит argv без всякого разбора.

def make_thumbnail(src: pathlib.Path, dst: pathlib.Path):
    convert = shutil.which("convert")          # абсолютный путь, не полагаемся на PATH
    if convert is None:
        raise RuntimeError("ImageMagick не установлен")
    subprocess.run(
        [convert, "--", str(src), "-resize", "200x200", str(dst)],
        shell=False, check=True, timeout=20,   # процесс не должен висеть вечно
        env={"PATH": "/usr/bin", "LC_ALL": "C"}, cwd="/var/tmp/thumbs",
    )

В Node.js аналог — execFile("/usr/bin/ffmpeg", [args], {timeout}): он не запускает шелл, в отличие от exec, и вся разница спрятана в имени функции. В Go exec.CommandContext(ctx, "/bin/tar", "xzf", archive) шелл не использует — опасен только явный "sh", "-c".

Правило второе: сначала библиотека, потом процесс. Изменение размера картинки — Pillow или libvips, архив — tarfile/archive/tar, HTTP-запрос — HTTP-клиент, а не curl. Каждый устранённый вызов внешней программы убирает целый класс дефектов и заодно ускоряет код.

Правило третье: argument injection. Даже без шелла значение, начинающееся с -, может быть прочитано программой как опция (--output=… у curl, --upload-pack у git, -o у многих утилит) — метасимволы для этого не нужны. Меры: валидация по allowlist-регулярке с явным запретом ведущего дефиса, разделитель -- перед позиционными аргументами, а для путей — резолв с проверкой, что результат внутри разрешённого каталога; последнее заодно закрывает path traversal (CWE-22).

NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
BASE = pathlib.Path("/srv/uploads").resolve()

def safe_upload_path(name: str) -> pathlib.Path:
    if not NAME_RE.fullmatch(name) or name.startswith("-") or name in {".", ".."}:
        raise ValueError("недопустимое имя файла")
    p = (BASE / name).resolve()
    if not p.is_relative_to(BASE):        # Python 3.9+
        raise ValueError("выход за пределы каталога")
    return p

Правило четвёртое: если шелл всё-таки нужен (сложный конвейер, переписывать дороже), данные передаются через переменные окружения и цитируются внутри скрипта, а не склеиваются в текст команды: subprocess.run(["/bin/sh", "-c", 'convert -- "$SRC" "$DST"'], env={"SRC": src, "DST": dst, "PATH": "/usr/bin"}). Функции shlex.quote и %q в Go работают, но это ручное экранирование: его легко забыть при следующей правке, а переменные окружения дисциплины не требуют.

Второй эшелон: процесс, запускающий внешние утилиты, работает под непривилегированным пользователем, с read-only корнем контейнера, no-new-privileges, seccomp-профилем, без сетевого доступа и с лимитами CPU/RAM/времени. Это не заменяет исправление, но превращает «полную компрометацию хоста» в «падение одного пода» — см. Контейнеры и реестры.

Как проверить

Параметризованный тест на функцию нормализации: значения "a; id", "a && id", "$(id)", "`id`", "a\nid", "-o/tmp/x", "../../etc/passwd" должны приводить к ValueError, а не к 500 и не к успешному запуску. Дополнительно — SAST-правило на shell=True, child_process.exec(, "sh", "-c", Runtime.getRuntime().exec(String), и в рантайме аудит запуска процессов (auditd, Falco): «сервис приложения породил /bin/sh» почти всегда аномалия.

Инъекция в шаблоны (SSTI)

Как выглядит уязвимый код

@app.route("/greet")
def greet():
    name = request.args.get("name", "")
    return render_template_string(f"<h1>Привет, {name}!</h1>")   # УЯЗВИМО

Тот же дефект в других экосистемах: new Function(userInput) и Handlebars.compile(userInput) в Node, TemplateEngine.process(userString) в Thymeleaf, new Template(new StringReader(...)) во FreeMarker, Velocity.evaluate(...), eval в любом виде.

Почему это RCE, а не «просто XSS»

Движок шаблонов — полноценный интерпретатор с доступом к объектной модели языка: он обращается к атрибутам, вызывает методы, разыменовывает классы. Поэтому выражение, попавшее в шаблон от пользователя, исполняется в процессе приложения со всеми его правами — это удалённое исполнение кода (CWE-1336), а не отражение в HTML. Разница принципиальная: XSS исполняется в браузере жертвы (см. XSS, CSRF и клиентские атаки), SSTI — на вашем сервере.

Как чинить

Шаблон — это код, данные — это параметры. Шаблон приходит из репозитория, пользовательское значение попадает в него только как значение переменной: render_template("greet.html", name=...) вместо render_template_string(...).

Если продукт обязан давать пользователям редактировать шаблоны (письма, уведомления, отчёты), то: берите logic-less движок — Mustache или Liquid, где нет доступа к объектной модели хоста; передавайте в контекст только плоские DTO без доменных объектов, ORM-сущностей и функций; рендерите в изолированном процессе с таймаутом и лимитом памяти (защита от бесконечных циклов и «шаблонных бомб»); песочницу движка (jinja2.sandbox.SandboxedEnvironment) считайте дополнительным барьером, а не единственной защитой — обходы из песочниц находили не раз.

Отдельная тема — контекстное экранирование. В Go это видно прямо в стандартной библиотеке: text/template не экранирует ничего и годится для конфигов и plain-text писем, а html/template понимает контекст (тело документа, атрибут, URL, JS) и экранирует под него. Перепутать импорт — типичная ошибка, которую ловит линтер и не ловит ревью.

Как проверить

Грепом и SAST-правилом: render_template_string, Template(, compile(, new Function(, eval( с непостоянным аргументом должны либо отсутствовать, либо иметь явное обоснование в коде. Плюс тест: пробное значение попадает в вывод буквально, а не вычисляется.

@pytest.mark.parametrize("probe", ["{{7*7}}", "${7*7}", "<%= 7*7 %>", "#{7*7}"])
def test_template_probe_is_literal(client, probe):
    body = client.get("/greet", query_string={"name": probe}).text
    assert probe in body or html.escape(probe) in body
    assert "49" not in body

Остальное семейство: та же механика, другие грамматики

  • LDAP-инъекция (CWE-90): фильтр (&(uid=USER)(password=PASS)) собирается конкатенацией, а символы ( ) * \ меняют его структуру. Лечится экранированием по RFC 4515 средствами библиотеки (Rdn.escapeValue, ldap3.utils.conv.escape_filter_chars).
  • XPath-инъекция (CWE-643): то же, что SQL, но в XML. Решение — переменные XPath (XPathVariableResolver в Java, lxml с именованными параметрами), а не сборка строки.
  • XXE (CWE-611): парсер XML разворачивает внешние сущности и читает файлы или ходит по сети. Лечится конфигурацией парсера — отключением DTD и внешних сущностей (OWASP XXE Prevention Cheat Sheet).
  • CRLF-инъекция в HTTP (CWE-113): \r\n в значении заголовка (например, в Location) разрывает ответ. Современные HTTP-библиотеки это запрещают — проверьте, что вы не строите заголовки вручную и не отключили валидацию.
  • Log injection (CWE-117): перенос строки в значении подделывает записи в логе, а интерполяция в шаблон сообщения — это ровно то, что дало Log4Shell (CVE-2021-44228), где значение попадало в ${jndi:…}-lookup. Приём защиты — структурированное логирование: значения передаются полями, а не склеиваются в текст.
  • Небезопасная десериализация (CWE-502): формат сам по себе является программой (Java-сериализация, pickle, YAML с тегами). Лечится выбором формата без исполнения (JSON, protobuf) и запретом pickle.loads/yaml.load на недоверенных данных.

Сводная таблица стоит того, чтобы висеть в вашем гайдлайне:

Интерпретатор Опасный канал Безопасный канал
SQL конкатенация строки плейсхолдер + allowlist для идентификаторов
MongoDB внешний JSON как фильтр схема + сборка фильтра в коде
Shell sh -c "строка" execve(argv[]), значения через env
Шаблонизатор шаблон из ввода шаблон из репозитория, данные — контекст
XML DTD и внешние сущности парсер с отключёнными сущностями
LDAP / XPath строка фильтра экранирование библиотекой, переменные
Логи интерполяция в сообщение структурированные поля
HTML innerHTML текстовые узлы, санитайзер, CSP

Промпт-инъекция: тот же класс без решения «параметризацией»

Если приложение отправляет языковой модели строку, собранную из системной инструкции и пользовательского текста, — это инъекция в чистом виде: канал управления и канал данных снова совпали. Разница в том, что эквивалента prepared statement здесь нет: модель не разделяет инструкции и данные жёсткой грамматикой, разделение остаётся вероятностным. Практический вывод для архитектуры: считать вывод модели недоверенным входом для всех последующих компонентов, ограничивать права инструментов агента, требовать подтверждения человеком на необратимые действия и не давать модели прямого доступа к чувствительным sink-ам. Материал по теме есть в треке AI-инжиниринга портала; отправной документ — OWASP Top 10 for LLM Applications.

Иерархия защиты: что работает, а что только выглядит защитой

Порядок выбора меры всегда один и тот же. Если для контекста существует структурный API (параметр, argv, поле документа) — используем его: это гарантия по построению. Если нет, но множество допустимых значений конечно — allowlist-отображение, где ввод лишь выбирает элемент из кода. Если и это невозможно — библиотечное квотирование под конкретный контекст плюс тесты на граничные значения. Если не подходит ничего из трёх, честный вывод: дизайн надо пересматривать, такой ввод, скорее всего, не нужен. Поверх любого из вариантов — минимальные привилегии, таймауты, лимиты, аудит и изоляция.

Три вещи, которые регулярно принимают за защиту. Санитизация чёрным списком — проигрышная стратегия: вы перечисляете бесконечное множество, а классический контрпример прост — удаление подстроки без цикла превращает SELSELECTECT обратно в SELECT. Ручной эскейпинг работает, пока совпадают контекст, кодировка и версия драйвера; как дополнение к параметризации нормально, как основная мера — источник тонких дефектов. WAF полезен как компенсирующий контроль на время выката патча и как источник телеметрии, но «виртуальный патч» ничего не меняет в коде.

Полезная ментальная модель — taint tracking: пометить все source и sink и потребовать, чтобы между ними стоял валидатор или структурный API. В некоторых экосистемах это поддержано типами: аннотации @Tainted/@Untainted в Checker Framework, а в браузере — Trusted Types, превращающие «строку в DOM sink» в ошибку типа.

Встраиваем в процесс разработки

Одна найденная инъекция ничего не стоит, если через месяц появится вторая. Класс дефектов удерживают закрытым: правило в CI, а не рекомендация в вики (SAST блокирует мёрж, исключения оформляются комментарием nosemgrep с обоснованием — так они видны и считаемы); единая точка доступа к данным, когда весь SQL живёт в слое репозиториев; тесты-канарейки в скелете нового сервиса; ревью-чеклист из трёх вопросов — откуда пришло значение, в какой интерпретатор оно уходит, какой структурный API отделяет данные от команды. Практика ревью и роль security-чемпионов разобраны в Безопасной разработке, риск получить инъекцию вместе с библиотекой — в Безопасности цепочки поставок. Пентест и bug bounty — только с письменным разрешением, оформленным scope и каналом раскрытия; методическая база — OWASP WSTG.

Типичные ошибки

  • Параметризовать «важные» запросы и оставить конкатенацию в отчёте, миграции или админке.
  • Считать ORM гарантией: raw, text, extra, шаблонные строки в query builder.
  • Подставлять имя колонки для сортировки прямо из query-параметра.
  • Валидировать на фронтенде и не валидировать на сервере (см. Формы и валидация).
  • Использовать exec/shell=True там, где хватило бы библиотеки, и забывать про ведущий дефис в аргументе.
  • Забыть, что значение из своей БД тоже недоверенное (second-order).
  • Рендерить письма шаблонизатором общего назначения из пользовательских строк.
  • Полагаться на escape-функции драйвера при рассинхроне кодировок соединения.
  • Логировать пользовательский ввод интерполяцией в текст сообщения.
  • Принимать «сканер ничего не нашёл» за доказательство отсутствия дефекта.

Мини-итог

Инъекция — это не «плохие символы», а смешение канала данных с каналом управления. Разновидности отличаются только грамматикой интерпретатора, а инженерное решение одно: передать данные структурным API, который эту грамматику не трогает. Рабочий чек-лист на ревью:

  1. Все SQL-запросы — с плейсхолдерами; идентификаторы — только через allowlist или библиотечное квотирование.
  2. Фильтры к NoSQL собираются в коде из провалидированных типизированных полей; серверный JS выключен.
  3. Внешние программы запускаются массивом argv без шелла, с таймаутом, чистым окружением и проверкой аргументов на ведущий дефис.
  4. Шаблоны берутся из репозитория; пользовательские шаблоны — только на logic-less движке в изолированном процессе.
  5. Парсеры XML — с отключёнными внешними сущностями; логи — структурированные.
  6. Роль БД и процесс приложения ограничены по правам; есть таймауты и лимиты.
  7. На каждый исправленный дефект есть тест-канарейка и правило SAST в CI.

Источники

Что дальше

Мы разобрали инъекции на стороне сервера — там, где интерпретатор наш. Следующая статья про интерпретатор, который нам не принадлежит: браузер пользователя. XSS работает по той же механике смешения кода и данных, но защита строится иначе — контекстное экранирование, Content Security Policy, Trusted Types, а рядом CSRF и атрибут SameSite.

XSS, CSRF и клиентские атаки: типы, CSP, SameSite, защита

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов