Почему ваша LLM-платформа — следующая цель: аудит безопасности AI-сервиса изнутри

от автора

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, в обход всей цепочки.


Содержание

  1. Инфраструктура и разведка

  2. Аутентификация: JWT с дефолтным секретом

  3. SSRF через LLM-провайдер: новый класс уязвимости

  4. Охота за API-ключами: что мы пробовали и почему не получилось

  5. Admin-панель: что расскажут JS-бандлы

  6. Открытый Docker API: как одна ошибка обесценивает всё остальное

  7. Итоги, уроки и рекомендации


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-стартапах снова и снова:

  1. Быстрый MVP на docker-compose — переезжает в прод без ревизии

  2. Дефолтные секреты — «поменяем позже» (не поменяют)

  3. Плоская сеть — все сервисы видят друг друга, egress не ограничен

  4. Ключи как строки — plain text в ENV, в БД, в HTTP между сервисами

  5. 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

p=reject + строгий SPF/DKIM

Архитектурные

  • Сегментация: 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/