Disclaimer: Всё описанное — результат санкционированного аудита безопасности по договору. Уязвимости ответственно раскрыты, ключи ротированы, домены и IP изменены. Статья — для понимания, не для воспроизведения.
Мы искали уязвимости в RAG-платформе с десятками тысяч пользователей — а нашли доступ ко всей инфраструктуре и API-ключам с бюджетом в сотни тысяч долларов. Две недели мы строили сложные цепочки: SSRF через LangChain, инъекции в промпты, HTTP smuggling, CVE в десериализации. Ни одна не дала результата. А потом мы сделали один curl к открытому порту — и получили все ключи за 5 минут.
Эта статья — не гайд по взлому. Это разбор того, почему LLM-инфраструктура создаёт принципиально новые риски, какие ошибки мы раз за разом видим в AI-стартапах, и на что стоит обратить внимание, если вы строите что-то похожее.
Почему LLM-платформы — особый класс целей
Прежде чем переходить к конкретике — важно понять, чем аудит AI-платформы отличается от обычного SaaS.
Обычное веб-приложение:
Пользователь → API → База данных
LLM-платформа:
Пользователь → API → Прокси → Evaluate (ключи) → Worker → LLM-провайдер ↕ ↕ PostgreSQL api.anthropic.com (пул ключей) (x-api-key: sk-ant-...)
Разница принципиальная:
-
Дорогие секреты в обороте. API-ключи от Anthropic/OpenAI — это не пароли от тестовой БД. Это прямой доступ к биллингу на десятки тысяч долларов в месяц.
-
User-controlled routing. В классическом SaaS пользователь отправляет данные. В LLM-платформе пользователь может косвенно влиять на то, куда сервер отправит HTTP-запрос — через выбор модели, хоста, параметров.
-
Прокси-архитектура с передачей секретов. Ключ извлекается из базы, передаётся между сервисами, оседает в памяти процесса. Каждый этап — потенциальная точка утечки.
-
Быстрый MVP → безопасность потом. AI-стартапы торопятся. Docker-compose в продакшене, дефолтные секреты, отсутствие сегментации — не исключение, а правило.
Держа это в голове, посмотрим, как это выглядит на практике.
Визуально: где ломается LLM-платформа
╔══════════════════════════╗ Пользователь ║ Точки компрометации: ║ │ ╚══════════════════════════╝ ▼ ┌─────────┐ │ API │◄──── JWT forgery (дефолтный секрет) └────┬────┘ ▼ ┌─────────┐ │ RAG │◄──── SSRF (user-controlled model host) │ Proxy │ └────┬────┘ ▼ ┌─────────┐ │Evaluate │ Ключ извлекается из БД (plain text) └────┬────┘ и передаётся дальше по цепочке ▼ ┌─────────┐ │ Worker │──── canary injection (подмена ответов) └────┬────┘ ▼ api.anthropic.com x-api-key: sk-ant-... Docker API (порт 2375) │ Все ENV контейнеров = все ключи сразу ◄────── один curl
Каждая стрелка — потенциальный вектор. Но критичнее всего оказался самый простой: открытый Docker, в обход всей цепочки.
Содержание
-
Охота за API-ключами: что мы пробовали и почему не получилось
-
Открытый Docker API: как одна ошибка обесценивает всё остальное
1. Инфраструктура и разведка
Что мы аудировали
RAG-as-a-Service на стеке LangChain + Flask + Next.js + Docker Swarm. Пользователи загружают документы, выбирают модель (Claude, GPT, Grok, DeepSeek, Gemini — всего 10+), получают ответы через единый API.
Карта инфраструктуры
|
Компонент |
Стек |
Роль |
|---|---|---|
|
API Backend |
Flask + Nginx |
Аутентификация, бизнес-логика |
|
Admin Panel |
Next.js App Router |
Управление кластером (IP-restricted) |
|
RAG Proxy |
Flask + Celery + Redis |
Обработка запросов, маршрутизация к LLM |
|
Worker |
Python |
Непосредственный вызов LLM-провайдеров |
|
Analytics |
PostHog (self-hosted, Hobby tier) |
Аналитика |
Первое наблюдение: admin-панель, RAG-прокси и аналитика живут на одном сервере. Один IP, один Nginx, общие сетевые правила. Компрометация одного сервиса расширяет поверхность атаки на все остальные.
Что выявило сканирование
Помимо ожидаемых портов (80/443, прокси на 5556), сканирование обнаружило порт 2375 — Docker Remote API. По умолчанию он работает без аутентификации. Мы зафиксировали это и продолжили систематический аудит — к Docker вернёмся в главе 6.
DNS-записи добавили штрих: DMARC p=none — нулевая защита от email-спуфинга. Для платформы с тысячами пользователей это прямой путь к фишингу от имени admin@platform.
2. Аутентификация: JWT с дефолтным секретом
Проблема
В рамках white-box аудита (с доступом к исходникам — стандартная практика) мы обнаружили типичный антипаттерн:
JWT_SECRET = os.getenv("JWT_SECRET", "your-secret-key-change-in-production")
Разработчик оставил «напоминание себе» в дефолтном значении — и оно уехало в продакшен. Переменная окружения не была установлена.
Даже без исходников такие секреты подбираются за минуты: hashcat -m 16500 перебирает стандартные словари, куда входят строки именно такого вида.
Последствия
С известным JWT-секретом мы подделали admin-токен и через легитимные API-вызовы извлекли:
-
API-токен для RAG-прокси (используется в цепочке получения LLM-ключей)
-
Полный кластер: 10 LLM-нод, 3 SD-ноды на RunPods GPU, внутренние IP-адреса, SSH-порты
-
Профили пользователей: балансы, email’ы, ключи интеграций (Tavily API)
-
Конфигурации всех нод: хосты, порты, параметры моделей
Всё через штатные API-эндпоинты. Ни одного SQL injection — зачем, если JWT forgery делает их ненужными?
Урок:
os.getenv("KEY", "default-value")— антипаттерн для секретов. ЕслиKEYне установлен, приложение должно падать при старте, а не работать с дефолтом. CI/CD должен проверять наличие обязательных переменных окружения до деплоя.
3. SSRF через LLM-провайдер: новый класс уязвимости
Самая интересная часть аудита с точки зрения AI-специфики.
Суть проблемы
Платформа поддерживает self-hosted модели через Ollama. При обработке запроса worker делает HTTP-вызов к хосту, который приходит из пользовательских данных:
# Упрощённая логика маршрутизацииif host.startswith("ollama."): # Worker делает POST http://{host}:{port}/api/chat return OllamaHelper(host=host, port=port)
Хост не валидируется. Нет whitelist’а, нет проверки на приватные IP. Подставляя ollama.attacker-ip.nip.io (nip.io — wildcard DNS, резолвящий *.1.2.3.4.nip.io в 1.2.3.4), мы заставили worker отправить HTTP-запрос на контролируемый нами сервер.
Что это даёт
На внешнем сервере — HTTP-обработчик, логирующий входящие запросы. Результат: worker отправляет POST с промптом пользователя на произвольный хост. Классический blind SSRF, но с LLM-спецификой.
Более того: если сервер возвращает валидный JSON в формате Ollama, платформа принимает его как настоящий ответ модели. Мы можем вернуть любой текст от имени «Claude» или «GPT» — это canary injection, подмена ответов на уровне инфраструктуры.
Для RAG-платформы, где пользователи доверяют ответам ИИ и загружают конфиденциальные документы, это серьёзный вектор: от фишинга до кражи данных через подмену контекста.
Границы SSRF
|
Цель |
Доступность |
Причина |
|---|---|---|
|
Внешние хосты |
Да |
Нет egress-фильтрации |
|
Другие ноды кластера |
Да |
Одна сеть |
|
Внутренняя сеть (10.0.0.x) |
Нет |
Firewall |
|
Cloud metadata |
Нет |
Не cloud-инфраструктура |
SSRF ограничен форматом Ollama (POST /api/chat с JSON), что лимитирует pivoting. Но для эксфильтрации промптов и подмены ответов — достаточно.
Урок: Любой параметр, превращающийся в URL для HTTP-запроса — потенциальный SSRF. В LLM-платформах таких параметров больше обычного: хосты моделей, эндпоинты embeddings, URL источников для RAG. Валидируйте хосты через whitelist, проверяйте DNS resolution на приватные диапазоны.
4. Охота за API-ключами: что мы пробовали и почему не получилось
Четыре дня, 15+ техник, ноль перехваченных ключей. Разбор «почему не получилось» не менее поучителен, чем успешные находки.
Как устроена передача ключей
┌─────────────┐ ┌───────────┐ ┌──────────┐│ PostgreSQL │ HTTP │ RAG │ invoke │ Worker ││ aikeys_pool │────────→│ Proxy │────────→│ ││ (plain text)│ [key] │ │ │ │──→ api.anthropic.com└─────────────┘ └───────────┘ └──────────┘ x-api-key: sk-ant-...
Ключ путешествует: БД → API → Proxy → Worker → провайдер. Каждый этап — потенциальная точка перехвата. Но на практике каждый оказался защищён — где-то осознанно, где-то случайно.
Три категории протестированных атак
Template-инъекции
LangChain PromptTemplate использует str.format(). Мы проверили, доступны ли секреты:
-
{api_key},{ANTHROPIC_API_KEY}→KeyError(нет в контексте) -
{{ config }}→ литеральная строка (это не Jinja2) -
Attribute traversal через format → ограничен Python’ом
-
LangChain deserialization CVE (
{"lc":1, "type":"secret"}) → данные не проходят черезloads()
Вывод: PromptTemplate по умолчанию безопасен. Но если бы разработчик включил template_format="jinja2" — SSTI был бы реален.
Сетевые атаки
|
Техника |
Почему не сработало |
|---|---|
|
Перенаправление base_url провайдера |
URL захардкожен в helper’е |
|
VLLMOpenAI endpoint hijack |
Ошибка evaluate до создания клиента |
|
HTTP request smuggling (CL.TE) |
Nginx и Gunicorn парсят одинаково |
|
CRLF injection в имени хоста |
httpx строго валидирует URL |
|
Redis injection через SSRF |
Redis в Docker-сети, не на localhost |
|
IP-spoofing (X-Forwarded-For) |
Nginx перезаписывает заголовок |
Вывод: Современные HTTP-библиотеки и правильно настроенный reverse proxy эффективно блокируют инъекции на транспортном уровне.
Логические атаки
|
Техника |
Почему не сработало |
|---|---|
|
Race condition (50 параллельных) |
Ошибка детерминированная |
|
Перебор 2000 node_id |
Forbidden или та же ошибка |
|
Error oracle (traceback) |
Flask production — трейсбеки скрыты |
|
DNS rebinding |
Один DNS-запрос, нет re-resolve |
Вывод: Production-конфигурация Flask без debugger’а — критически важна. С FLASK_DEBUG=1 error oracle мог бы сработать.
Почему 15 техник провалились
Ключевая причина оказалась неожиданной: функция evaluate содержала баг — возвращала 1 элемент вместо 2. Ошибка not enough values to unpack происходила до создания LLM-клиента. Ключ извлекался из базы, но не доходил до стадии, где его можно перехватить.
Ирония ситуации: баг в коде, который мы пытались эксплуатировать, защищал ключи лучше любого Vault’а.
5. Admin-панель: что расскажут JS-бандлы
Публичная карта приватного API
Admin-панель на Next.js отдаёт минифицированные JS-бандлы. Минификация — не защита. Анализ раскрыл:
Модель аутентификации — cookie auth_token, установка через POST /v1/admin/login с username/password. Не Bearer, как в основном API — отдельная сессия.
Полная карта маршрутов — 18 admin-страниц: управление нодами кластера, GPU-пулом RunPods, кредитами, подписками, email-шаблонами, промокодами, релизами.
CORS-конфигурация:
access-control-allow-origin: http://127.0.0.1:3000access-control-allow-credentials: true
API доверяет localhost:3000 — внутреннему Next.js dev-серверу. Если получить SSRF с этого хоста — можно обойти IP-whitelist.
Раскрытие внутреннего URL бэкенда:
GET /api/config → {"apiHost": "https://api.internal.example.com"}
Почему это проблема
JS-бандлы доступны без аутентификации. Атакующий получает полную структуру admin API: все маршруты, параметры, ролевую модель, имена cookie — не отправив ни одного запроса к защищённым эндпоинтам. Это значительно ускоряет планирование атаки.
Урок: Разделяйте admin-бандлы. Не включайте маршруты dashboard в публичный JS. Используйте server components Next.js для чувствительной логики. И помните: минификация ≠ безопасность.
6. Открытый Docker API: как одна ошибка обесценивает всё остальное
После двух недель сложных техник — SSRF через wildcard DNS, CVE в LangChain, HTTP smuggling — мы вернулись к порту, обнаруженному в первый день.
Порт 2375
Docker Remote API. Один HTTP-запрос:
GET /info → {"Containers": 12, "Swarm": {"LocalNodeState": "active"}, ...}
Без аутентификации. Без TLS. Без какой-либо защиты.
Что это означает на практике
Через Docker API доступно чтение метаданных контейнеров, включая переменные окружения:
ANTHROPIC_API_KEY=sk-ant-api03-...OPENAI_API_KEY=sk-...JWT_SECRET=your-secret-key-change-in-productionDATABASE_URL=postgresql://user:pass@10.0.0.13/dbREDIS_URL=redis://10.0.0.18:6379/0
Все секреты платформы. В одном запросе.
Помимо чтения, Docker API позволяет создавать контейнеры с монтированием хостовой файловой системы, деплоить Swarm-сервисы, выполнять команды внутри работающих контейнеров. По сути это неаутентифицированный root-доступ к серверу.
Контекст
Это классическая, хорошо задокументированная ошибка — Docker daemon по умолчанию слушает на TCP без TLS. Docker documentation прямо предупреждает об этом. И тем не менее мы встречаем её снова и снова.
Особенно иронично то, что admin API был закрыт IP-whitelist’ом в Nginx (и мы не смогли его обойти за две недели), evaluate защищён от утечек благодаря случайному багу, SSRF ограничен форматом Ollama — а Docker API стоял открытым и обесценивал все эти меры разом.
Главный парадокс аудита
Мы потратили 4 дня на SSRF-цепочки, CVE в LangChain, HTTP smuggling и race conditions — и получили ноль ключей.
А затем сделали один HTTP-запрос к Docker API — и получили все ключи разом.
Две недели сложных техник. Пять минут простого скана портов. Результат — один и тот же.
Урок: Безопасность определяется самым слабым звеном. Можно выстроить сложную защиту API-ключей на уровне приложения — и потерять всё из-за открытого порта оркестратора. Регулярный аудит портов и сетевых политик — не менее важен, чем код.
7. Итоги, уроки и рекомендации
Полная картина
За 14 дней аудита мы обнаружили 27 уязвимостей. Вот как они складываются в цепочку:
JWT forgery (дефолтный секрет) ├── Профили и данные пользователей ├── Кластер: все ноды, IP, порты, GPU └── API-токен └── Ollama SSRF (nip.io) ├── Подмена ответов LLM (canary injection) └── Эксфильтрация промптов пользователейDocker 2375 (без аутентификации) ├── ENV всех контейнеров → ВСЕ API-ключи ├── Произвольные контейнеры → RCE └── Монтирование файловой системы → чтение любых файлов
Сводка
|
Уязвимость |
Severity |
AI-специфично? |
|---|---|---|
|
Docker API без auth |
Critical |
Нет — классическая инфра-ошибка |
|
JWT дефолтный секрет |
Critical |
Нет — классическая ошибка |
|
Ollama SSRF (nip.io) |
High |
Да — user-controlled model host |
|
Canary injection |
High |
Да — подмена ответов LLM |
|
Ключи plain text в БД |
High |
Частично — дорогие LLM-ключи |
|
DMARC p=none |
Medium |
Нет |
|
PostHog Hobby в prod |
Medium |
Нет |
|
CORS localhost + info disclosure |
Low |
Нет |
Характерно: самые критичные уязвимости — не AI-специфичные. Это базовые ошибки инфраструктуры. А AI-специфичные находки (SSRF через model host, canary injection) — серьёзны, но secondary.
Что это говорит об отрасли
AI не добавляет безопасность — он добавляет поверхность атаки. LLM — это просто ещё один HTTP-клиент с дорогими ключами. И если базовая инфраструктура не защищена, никакие AI-специфичные меры не помогут.
Паттерн, который мы видим в AI-стартапах снова и снова:
-
Быстрый MVP на docker-compose — переезжает в прод без ревизии
-
Дефолтные секреты — «поменяем позже» (не поменяют)
-
Плоская сеть — все сервисы видят друг друга, egress не ограничен
-
Ключи как строки — plain text в ENV, в БД, в HTTP между сервисами
-
User-controlled routing — хосты моделей, URL источников для RAG
Рекомендации
Немедленные действия
|
Проблема |
Решение |
|---|---|
|
Открытый Docker API |
Закрыть порт, TLS mutual auth, Docker contexts |
|
Дефолтный JWT-секрет |
Генерировать ≥256 бит, fail-fast при отсутствии ENV |
|
Ollama SSRF |
Whitelist хостов, DNS-валидация на приватные диапазоны |
|
Ключи plain text |
HashiCorp Vault / AWS Secrets Manager |
|
DMARC p=none |
|
Архитектурные
-
Сегментация: admin, proxy, worker — разные серверы и VPC
-
Egress firewall: worker ходит только на whitelisted LLM-провайдеров
-
Proxy pattern для ключей: ключ никогда не покидает secure enclave; прокси сам делает вызов к провайдеру
-
Secret rotation: автоматическая ротация через Vault с TTL
-
Мониторинг: алерты на аномальный DNS, новые Docker-сервисы, исходящие соединения worker’ов
-
CI/CD:
gitleaks/trufflehogв пайплайне, проверка обязательных ENV перед деплоем
Чеклист для LLM-платформ
Если вы строите что-то похожее — пройдитесь по списку:
-
[ ] JWT-секрет сгенерирован криптографически, не дефолтный
-
[ ] Docker API закрыт или за TLS mutual auth
-
[ ] Хосты моделей валидируются через whitelist
-
[ ] API-ключи в secret manager, не в ENV и не в БД plain text
-
[ ] Egress-трафик worker’ов ограничен
-
[ ] Admin-панель на отдельном хосте с отдельной аутентификацией
-
[ ] DMARC p=reject, SPF/DKIM настроены
-
[ ] Flask/Django не в debug-режиме в production
-
[ ] JS-бандлы не содержат admin-маршруты
-
[ ] Регулярный аудит открытых портов
Таймлайн аудита
|
Период |
Фокус |
Ключевые находки |
|---|---|---|
|
День 1-2 |
Разведка, code review |
Открытый Docker 2375, JWT дефолтный секрет, SSRF в Ollama |
|
День 3-4 |
Аутентификация |
JWT forgery → полный дамп кластера, профилей, кластерных данных |
|
День 5-6 |
SSRF |
Ollama SSRF через nip.io, canary injection, зондирование сети |
|
День 7-10 |
Попытки перехвата ключей |
15+ техник (template injection, smuggling, CVE, race) — все неуспешны |
|
День 11-12 |
Admin-панель |
Реверс JS-бандлов, cookie auth, CORS, полная карта admin API |
|
День 13 |
Docker API |
Чтение ENV — все API-ключи извлечены |
|
День 14 |
Отчёт |
Responsible disclosure, ротация ключей |
Если прямо сейчас у вас:
-
docker-composeв проде без ревизии сетевых политик -
API-ключи в переменных окружения или plain text в базе
-
Нет egress-фильтрации на worker’ах
-
JWT-секрет, который «поменяем потом»
— ваша LLM-платформа уже потенциально уязвима, даже без сложных атак. Не нужны ни SSRF, ни CVE. Достаточно одного открытого порта.
Все уязвимости закрыты. Ключи ротированы. Если строите LLM-платформу — используйте чеклист выше.
ссылка на оригинал статьи https://habr.com/ru/articles/1029822/