Отравление памяти LLM-агента: как защитить базу знаний
Узнайте, чем отравление памяти отличается от prompt injection. Разберитесь, как вредоносный текст становится фактом в базе данных и как изолировать данные для защиты.
Что такое отравление памяти агента
Отравление памяти агента — это атака, при которой вредоносный текст записывается в долговременную память агента, чтобы в последующих сессиях он считывался как достоверный факт. Prompt injection существует в рамках одного контекстного окна и завершается вместе с ним. Отравление памяти не прекращается, так как агент сохранил текст злоумышленника и прочитает его снова в будущем.
Это различие меняет объект защиты. Prompt injection — это инцидент, который устраняется перезапуском. Отравленная память — это изменение состояния системы. Она ведет себя как поврежденная запись в базе данных: остается после перезагрузки, выдается любому, кто делает запрос, и последующая сессия не выглядит подозрительной для пользователя.
OWASP классифицирует это как ASI06, Memory and Context Poisoning, в Top 10 для агентных приложений, опубликованном в декабре 2025 года. Наиболее четким публичным исследованием является MINJA, Атаки внедрения в память на LLM-агентов через взаимодействие только с запросами, опубликованное в марте 2025 года и представленное на NeurIPS 2025. Утверждения об атаке ниже основаны на этих двух источниках. Описанные далее методы защиты являются операционными и работают с любым хранилищем, которое вы размещаете самостоятельно.
Почему prompt injection заканчивается, а отравление памяти — нет
Контекстное окно — это состояние, существующее только в рамках одного запуска. У модели нет памяти между вызовами, поэтому всё, что она «знает» во время работы, было помещено туда вашим кодом: системный промпт, вывод инструментов, история диалога. Когда запуск завершается, это состояние удаляется. Внедренная инструкция, которая попала в систему вместе с содержимым загруженной веб-страницы, исчезает вместе с ним. Prompt injection против агента для написания кода опасна во время выполнения, но безвредна после него, поэтому «начать новую сессию» является реальным способом защиты в данном случае.
Долговременная память существует специально для того, чтобы нарушить это свойство. Вы хотите, чтобы агент запомнил, что этот пользователь работает в Debian или что база данных production доступна с этого хоста только для чтения. Поэтому шаг записи в память создает постоянную запись, а шаг извлечения загружает подходящие записи в следующий запуск. Если вы никогда не реализовывали этот цикл «сохранение-извлечение» самостоятельно, создание его с нуля — самый быстрый способ понять, почему это граница доверия, а не просто функция. Поэтапный путь через основы работы агентов доходит до этапа работы с памятью после того, как вы вручную напишете цикл обработки.
Злоумышленник преследует ту же цель по той же причине. Одна запись — множество чтений. Отравленная запись будет извлекаться каждый раз, когда вопрос достаточно похож на неё, в каждой сессии, для каждого пользователя, которого обслуживает это хранилище, пока кто-нибудь её не удалит.
Как враждебный текст становится сохраненным фактом
Путь записи состоит из четырех этапов. Назовите каждый из них, так как механизмы защиты применяются на разных стадиях.
- Агент считывает недоверенный контент: веб-страницу, тикет в службу поддержки, тело электронного письма или файл README в клонированном репозитории.
- На этапе работы с памятью принимается решение о том, что стоит сохранить. В большинстве архитектур это второй вызов модели, который суммирует сессию в виде коротких фактических предложений.
- Извлеченное предложение сохраняется. Типичная запись содержит текст, вектор эмбеддинга, временную метку и часто идентификатор пользователя.
- В последующей сессии выполняется поиск по сходству, и лучшие совпадения вставляются в промпт, обычно перед сообщением пользователя.
Этап 1 — это обычный рабочий день для агента сканирования кода: самостоятельно развернутый сканер open-kritt считывает любой репозиторий, на который вы его направите, поэтому каждый результат, который он может запомнить, был сформирован текстом, написанным кем-то другим. Поиск еще больше расширяет этот этап, поскольку предоставление агенту поискового бэкенда SearXNG означает, что страницы, попадающие в ваш экстрактор, выбираются алгоритмами ранжирования поискового запроса, а не вами.
Этап 2 — это место, где пересекается граница доверия, поскольку входные данные экстрактора содержат недоверенный контент, а его выходные данные рассматриваются как собственное заключение агента. Этап 3 — это момент, когда ущерб становится необратимым, поскольку при извлечении теряется информация о происхождении текста. Предложение, написанное пользователем, и предложение, взятое с враждебной страницы, хранятся в одном формате: текст, вектор, временная метка. После этой записи ни одно поле в строке не позволяет их различить.
Затем поиск выполняет именно то, для чего он был создан. Он ранжирует результаты по сходству, а не по уровню доверия, и передает победителей модели в область промпта, зарезервированную для установленного контекста. У модели нет способа узнать, что одна из этих строк была написана посторонним лицом.
Что измерялось в статье MINJA, а что нет
Вклад MINJA заключается в предложенной модели угроз. Злоумышленник никогда не обращается к базе данных напрямую. Он отправляет запросы агенту и считывает ответы — это тот же уровень доступа, который есть у обычного пользователя общего агента. Вредоносная запись создается самим агентом на этапе работы с памятью.
The data behind this chart
[
{
"config": "EHRAgent GPT-4 MIMIC-III",
"injection_success_pct": 95.6,
"attack_success_pct": 57.0
},
{
"config": "EHRAgent GPT-4 eICU",
"injection_success_pct": 98.5,
"attack_success_pct": 90.0
},
{
"config": "RAP GPT-4 Webshop",
"injection_success_pct": 96.3,
"attack_success_pct": 77.4
},
{
"config": "RAP GPT-4o Webshop",
"injection_success_pct": 99.3,
"attack_success_pct": 98.9
},
{
"config": "QA GPT-4 MMLU",
"injection_success_pct": 100.0,
"attack_success_pct": 68.9
},
{
"config": "QA GPT-4o MMLU",
"injection_success_pct": 100.0,
"attack_success_pct": 68.9
},
{
"config": "Paper average",
"injection_success_pct": 98.2,
"attack_success_pct": 76.8
}
]Рассматривайте эти две метрики раздельно. Успешность инъекции — это факт попадания вредоносной записи в банк памяти. Успешность атаки — это факт того, что данная запись повлияла на ответ агента при последующем, другом запросе. В статье указаны средние значения: 98.2 процентов для первого показателя и 76.8 процентов для второго. Внедрение текста в память оказалось достаточно надежным процессом. Однако добиться изменения последующего ответа удалось не всегда, и результаты сильно варьировались в зависимости от агента: агент для покупок с RAG на базе GPT-4o достиг 98.9 процентов, в то время как агент для работы с медицинскими картами на наборе данных MIMIC-III — 57.0 процентов.
Что означают эти опубликованные цифры, а что нет
Это средние значения, полученные в ходе экспериментов одной конкретной статьи, проведенных на моделях GPT-4 и GPT-4o с использованием трех архитектур агентов и четырех наборов данных. Каждая ячейка в исходной таблице содержит стандартное отклонение, и разброс показателей успешности атаки велик. Эти данные характеризуют указанные системы, а не вашу. Рассматривайте их как доказательство того, что инъекция в память через запросы работает на реальных архитектурах агентов, а не как вероятность успеха для вашего развертывания.
Почему ввод одного пользователя попадает в память другого
В многопользовательских системах (multi-tenant) эта проблема превращается из досадной ошибки в нарушение безопасности. Многие self-hosted решения используют один агент с единым хранилищем памяти, разделяя пользователей с помощью поля метаданных: tenant_id или user_id в каждой записи, которые фильтруются во время выполнения запроса.
Такая архитектура дает сбой двумя типичными способами.
Первый — отсутствие фильтра при чтении. Функция извлечения данных (retrieval) вызывается из разных частей кода: обработчика чата, ночного задания по созданию сводок или скрипта оценки, написанного в спешке. Если хотя бы в одном месте забыть применить фильтр, система не выдаст ошибку. Она просто вернет больше строк, отсортированных по сходству, и эти лишние строки будут принадлежать другим арендаторам. Отсутствие фильтра приводит к открытому доступу.
Второй — запись данных. Текст тикета арендатора A проходит через экстрактор, и полученный «факт» сохраняется с тем идентификатором арендатора, который был установлен в коде на тот момент. Если суммаризатор работает в общем задании или если запись сохраняется как глобальная настройка (потому что она выглядит как общее знание), то текст арендатора A становится контекстом, доступным для арендатора B. Фильтр при чтении здесь ни при чем. Строка была записана с нарушением границ доступа.
Таким образом, злоумышленник, имеющий доступ к агенту любого арендатора, может получить доступ ко всем арендаторам, которых обслуживает это хранилище. В процессе извлечения данных нет проверки того, кто именно создал запись.
Защита 1: одно хранилище памяти на каждую границу доверия
Определите границы доверия и выделите для каждой из них собственное хранилище. Не используйте одну коллекцию с фильтрами. Используйте отдельную базу данных или, как минимум, отдельную схему, доступную через отдельные учетные данные.
Причина заключается в характере возможных сбоев. Забытый фильтр возвращает чужие строки и не выдает себя. Неверная строка подключения не возвращает ничего, и вы обнаружите это в первую же минуту. Изоляция, которая при сбое переходит в закрытое состояние, оправдывает затраты на дополнительный контейнер.
Границы, которые стоит разделять в большинстве развертываний:
- Каждый клиент или команда, если агент является мультиарендным.
- Любые данные, полученные из публичного контента, должны храниться отдельно от данных, введенных вашими пользователями.
- Каждая роль агента, если привилегированный агент и публичный агент используют один хост.
- Разработка и продакшн, чтобы тестовый запуск не мог записать строку, которую прочитает реальная сессия.
Если общая таблица неизбежна, перенесите проверку на уровень базы данных, вместо того чтобы доверять каждому месту вызова. PostgreSQL row level security реализует это тремя инструкциями:
ALTER TABLE agent_memory ENABLE ROW LEVEL SECURITY;
ALTER TABLE agent_memory FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON agent_memory
USING (tenant_id = current_setting('app.tenant_id', true));Затем каждый запрос перед чтением указывает своего арендатора в соединении:
BEGIN;
SELECT set_config('app.tenant_id', 'acme', true);
SELECT content FROM agent_memory ORDER BY created_at DESC LIMIT 20;
COMMIT;Третий аргумент true делает настройку локальной для транзакции, что важно, так как после выполнения запроса пул соединений передает его следующему процессу. Путь выполнения кода, который не устанавливает это значение, возвращает ноль строк, так как current_setting('app.tenant_id', true) равно NULL при отсутствии настройки, а tenant_id = NULL никогда не будет истинным. Это именно то поведение «отказ в закрытом состоянии», которое вам нужно, и вы можете убедиться в этом, выполнив второй блок без строки set_config.
На практике это нарушается по двум причинам. FORCE ROW LEVEL SECURITY — это не декорация: без него владелец таблицы обходит все политики, поэтому приложение, подключающееся от имени владельца, читает всех арендаторов, и кажется, что политика не работает. Роль с BYPASSRLS, которая есть у суперпользователей, игнорирует политики по той же причине. Подключайтесь от имени обычной роли, которая не является владельцем объектов.
Защита 2: запись происхождения данных и отношение к любой памяти как к недоверенному вводу
Добавьте в строку поля, которые отбрасываются при извлечении.
CREATE TABLE agent_memory (
id bigserial PRIMARY KEY,
tenant_id text NOT NULL,
content text NOT NULL,
source text NOT NULL, -- user_message, tool_output, web_page, operator
source_ref text, -- url, ticket id, session id
written_by text NOT NULL, -- which agent or job wrote the row
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz,
reviewed boolean NOT NULL DEFAULT false
);
CREATE INDEX agent_memory_tenant_idx ON agent_memory (tenant_id, created_at DESC);source — это поле, которое окупает себя. С его помощью можно ответить на единственный важный вопрос после предполагаемого инцидента: какие сохраненные факты поступили из контента, который не был написан пользователем. Без этого поля каждая строка выглядит одинаково легитимной, поэтому единственным вариантом остается удаление всего хранилища, что приводит к потере как корректных, так и скомпрометированных данных.
Происхождение данных также позволяет сформулировать политику извлечения одной строкой. Строки, у которых source имеет значение user_message или operator, могут быть извлечены в процесс, имеющий доступ к инструментам. Для всего остального требуется, чтобы человек сначала установил reviewed. Это переносимое решение: оно представляет собой условие WHERE и работает одинаково независимо от того, является ли хранилище Postgres, SQLite или векторной базой данных с фильтрами метаданных.
Затем исключите сохраненный текст из системного промпта. Извлеченные воспоминания должны находиться в собственном четко ограниченном блоке, помеченном как полученные данные. Маркировка не мешает модели следовать инструкциям, найденным в этих данных, и попытки убедить себя в обратном приводят к тому, что люди доверяют неработающим средствам контроля. Маркировка выполняет две надежные функции: область промпта с наивысшим уровнем доверия остается свободной от текста, доступного злоумышленнику, а граница данных становится видна в ваших логах, когда вы анализируете, что именно было показано модели.
Защита 3: удаляйте устаревшее, проверяйте важное
Память, которая никогда не очищается, растёт до тех пор, пока её содержимое не становится невозможно прочитать. Установите для каждой извлечённой строки TTL (время жизни) и настройте удаление по расписанию:
DELETE FROM agent_memory
WHERE expires_at IS NOT NULL
AND expires_at < now();Для запуска этой задачи достаточно таймера systemd:
[Unit]
Description=Delete expired agent memory rows
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.targetСоответствующий юнит .service состоит из одной строки ExecStart, вызывающей psql -d agentmem -f /etc/agent-memory/expire.sql, и выполняется от имени вашей роли обслуживания. Проверьте его с помощью systemctl list-timers agent-memory-expire.timer: команда должна показать время следующего и последнего запуска. Если таймер не срабатывает, скорее всего, вы создали его, но не выполнили enable --now.
TTL определяет максимальный срок, в течение которого можно получить скомпрометированную строку, поэтому значение по умолчанию в 30 дней ограничивает время воздействия сессиями за 30 дней. Используйте короткие значения по умолчанию для извлечённых данных и более длительные только для строк, которые были намеренно помечены пользователем как важные. В этой же задаче по очистке выполняется удаление устаревших данных памяти агента, так как в данном случае требования корректности и безопасности совпадают.
Проверяйте только те строки, которые могут изменить поведение системы, а не все подряд, так как очередь, которую никто не читает, бесполезна. Внимания человека заслуживают строки, содержащие учётные данные, названия инструментов, URL-адреса или слова вроде "always" (всегда) и "never" (никогда). Храните лог записи отдельно от таблицы памяти в режиме только добавления (append-only), чтобы удаление скомпрометированной строки не приводило к потере данных о том, когда она появилась и какая сессия её создала.
Защита 4: вынос хранилища памяти за пределы зоны поражения агента
Хранилище — это база данных, с которой взаимодействует агент, поэтому обеспечьте ей те же условия, что и любой другой базе данных. Используйте отдельный контейнер или хост, не открывайте порты наружу и используйте учетные данные, которые не применяются агентом ни для чего другого:
docker network create agent-memПодключите базу данных и агента к одной сети и не публикуйте порты. Хранилище, доступное только внутри сети Docker, невозможно прочитать из Интернета, даже если процесс агента полностью скомпрометирован.
Затем ограничьте права роли самого агента:
CREATE ROLE agent_rw LOGIN PASSWORD 'generate-this-do-not-type-it';
GRANT SELECT, INSERT ON agent_memory TO agent_rw;
GRANT USAGE ON SEQUENCE agent_memory_id_seq TO agent_rw;Никаких UPDATE, никаких DELETE. Агент, которого убедили «исправить память об этом», не сможет переписать историю, так как у него нет соответствующих прав, а таймер истечения срока действия работает под другой ролью. Ошибка, с которой столкнется читатель при тестировании — это permission denied for table agent_memory во время операции обновления; это означает, что механизм контроля работает. Эти отдельные пароли от базы данных должны где-то храниться, и если это место — self-hosted хранилище, то оно заслуживает отдельной процедуры укрепления безопасности, поскольку реальная уязвимость Vaultwarden заключается в токене администратора и файле резервной копии, а не в самих зашифрованных данных.
Последний этап — тот, который часто пропускают. Если процесс агента хранит облачные ключи или токены развертывания в той же среде, что и пароль от базы данных, то отравленная память, направляющая один вызов инструмента, дает доступ ко всему. Разделяйте их, как описано в изоляции секретов от среды AI-агента, и выносите важные действия за этап явного подтверждения. Если вы используете выделенный сервис памяти, например self-hosted сервер Mem0 на VPS, принципы остаются прежними: это база данных с моделью перед ней, и обе части требуют одинакового подхода к защите.
Как понять, что память уже отравлена?
Сначала честный ответ. Вы не поймете этого, просто прочитав текст. Авторы MINJA подчеркивают это в отношении своих собственных записей, утверждая, что внедренный контент выглядит правдоподобно для систем модерации входных и выходных данных, поэтому они ожидают, что фильтрация контента его пропустит. Фильтр, который сканирует сохраненные предложения на предмет чего-либо подозрительного, здесь является слабым средством контроля.
Учет работает лучше, чем проверка:
- Запрос по происхождению. Выведите список строк, где
sourceне равноuser_message, начиная с самых новых. В исправном хранилище этот список достаточно короток для чтения. - Следите за темпом роста. Хранилище, которое получает пять строк в день и внезапно получает двести, заслуживает внимания, независимо от того, что написано в этих строках.
- Поддерживайте фиксированный набор оценочных промптов и запускайте их после изменений в памяти. Изменение поведения без изменения кода указывает на проблемы в хранилище.
- Делайте ежедневные снимки хранилища и сравнивайте их. Текстовый diff читается гораздо проще, чем векторный поиск.
Ни один из этих методов не является детектором. Это разница между тем, чтобы найти отравленную строку самостоятельно, или узнать о ней от клиента.
Чего это не исправляет
Изоляция, отслеживание происхождения и истечение срока действия сокращают время жизни отравленной записи и ограничивают круг её получателей. Однако это не мешает модели доверять такой записи, пока она находится в хранилище. Реальное ограничение ущерба зависит от прав агента: агент, который не может выполнять развертывание или тратить средства без одобрения человека, не сможет нанести значительный вред, основываясь на ложных данных.
Если ваш агент считывает недоверенный контент, а вы пока не можете фиксировать его происхождение, запускайте его без долговременной памяти. Агент без сохранения состояния «забывает» об атаке после завершения сессии. Это снижает практическую пользу, но остается верным решением до тех пор, пока хранилище не будет изолировано, а путь записи — изучен.
FAQ
Является ли отравление памяти (memory poisoning) просто инъекцией промпта под другим названием?
Нет. Точка входа одна и та же, но время жизни различается. Инъекция промпта существует в рамках одного контекстного окна, поэтому завершение сессии её удаляет. Отравление памяти приводит к записи вредоносного текста в постоянное хранилище, поэтому следующая сессия извлекает его как установленный факт, и перезапуск ничего не меняет. По этой причине OWASP разделяет эти понятия: инъекция промпта входит в LLM Top 10, тогда как персистентное повреждение памяти относится к ASI06 (Memory and Context Poisoning) в списке Top 10 для агентных приложений.
Может ли кто-то отравить память моего агента без доступа к базе данных?
Да, именно к такому выводу пришли авторы статьи MINJA. Злоумышленник только отправляет запросы и читает ответы, не имея доступа к банку памяти, а запись выполняет сам агент в процессе работы с памятью. Согласно результатам исследования для различных конфигураций, средний показатель успешности инъекции составил 98.2 процентов, а средний показатель успешности атаки — 76.8 процентов. Любой интерфейс, через который текст от недоверенной стороны может попасть на этап извлечения, является каналом записи, включая входящие сообщения службы поддержки или содержимое загруженной веб-страницы.
Решает ли проблему утечки данных между арендаторами создание отдельной коллекции для каждого из них?
Это решает проблему чтения только в том случае, если каждый путь выполнения кода применяет фильтр, что является слабым местом: забытый фильтр вернет строки других арендаторов вместо ошибки, поэтому вы не получите уведомления. Это никак не помогает с записью, когда текст одного арендатора извлекается в запись и сохраняется как глобальный или под неверным идентификатором. Использование отдельных хранилищ с отдельными учетными данными обеспечивает безопасность при сбоях (fail closed), так как неверная строка подключения просто не вернет никаких данных.
Как долго должна храниться память агента?
Меньше, чем кажется комфортным. TTL — это верхний предел того, как долго отравленная строка может извлекаться, поэтому срок действия в 30 дней ограничивает воздействие 30 днями сессий. Используйте короткий срок действия по умолчанию для всего, что извлекла модель, и требуйте проверки строки человеком, прежде чем она станет постоянной. Храните expires_at в самой строке, чтобы задача по удалению по истечении срока была простой операцией DELETE, а не скриптом, который должен угадывать параметры.