Разрушительные допущения: как скрытые уязвимости живут в SaaS

«Эта история — о мёртвом файле, который внезапно ожил. Не потому что код сломался, а потому что две команды разработчиков, работая в одном проекте, построили для себя разные миры, которые никто не свёл воедино. Похожие разрывы живут в любом сложном сервисе, и стандартные проверки ФСТЭК их не находят — они смотрят на кирпичи, а не на трещины между ними.»

Неверный ответ сервера и возникшая цепочка

Во время работы с API облачного сервиса в ответе на некорректный запрос о загрузке аватара система показала полный путь к файлу на диске. Примерно так: /tmp/upload_staging/avatar_65e8a3f21a7c.bin. Такие данные в рабочем окружении раскрывать нельзя — это сигнал о том, что обработка ошибок настроена некорректно.

Далее мы отправили на этот же эндпоинт файл, не являющийся изображением. Сервер ответил ошибкой валидации, но из текста стало ясно: файл сначала полностью сохраняется в файловой системе и только потом проверяется. Если проверка не проходит — файл остаётся висеть в директории. Возник главный вопрос: кто отвечает за очистку этих «отбракованных» файлов и как долго они там живут?

[ИЗОБРАЖЕНИЕ: Схема потока загрузки файла до исправления. Блоки: 1) Клиент отправляет файл. 2) Балансировщик. 3) Backend-сервис: «Сохранить в /tmp/upload_staging/». 4) Модуль валидации: «Файл не прошёл проверку». Стрелка ведёт от 3 к 4, но обратной стрелки «Удалить файл» нет. Файл остаётся в /tmp/.]

Сборка пазла: от мёртвого файла до выполнения команды

Записать произвольный файл в известное место — это только половина пути. Нужен второй компонент, который этот файл неожиданно использует. В документации API мы нашли другой эндпоинт — для запуска фоновых задач по импорту данных из CSV. Эта задача использовала внешнюю системную утилиту для обработки.

Ключевым был параметр, в котором можно было задать путь к файлу для импорта. Логика предполагала, что джоб работает только с файлами в своей рабочей директории. Однако проверка строилась на простой конкатенации базового пути и пользовательского ввода. Это позволяло выйти за её пределы, используя последовательности вида ../../../ (Path Traversal).

Таким образом, сложилась цепочка:

  1. Через эндпоинт загрузки аватара можно поместить файл с нужным содержимым в известную временную директорию (/tmp/upload_staging/).
  2. Через эндпоинт запуска импорта можно указать путь, который заставит фоновый джоб найти и обработать этот самый файл.
  3. Джоб, не проверяя содержимое файла, передаёт его путь в системную команду для обработки.

Оставалось два технических препятствия. Первое — эндпоинт загрузки переименовывал файлы, добавляя к ним расширение. Второе — требовалось превратить загруженные данные в исполняемый код.

Как обходили защиту: расширение и шебанг

Изучение логики обработчика загрузки (через вариацию запросов и анализ ошибок) показало: расширение файла определяется исключительно по HTTP-заголовку Content-Type. Если отправить Content-Type: application/octet-stream, сервер сохранял файл с расширением .bin.

Фоновый джоб при передаче файла утилите не смотрел на его расширение. Для исполнения важны были два фактора: наличие бита исполнения (x) и корректный шебанг (#!/bin/bash) в начале файла. Выставить бит исполнения удалённо нельзя, но шебанг можно записать прямо в содержимое загружаемого файла. Утилита, будучи вызванной из скрипта на Python через subprocess, могла исполнить такой файл, если он интерпретировался оболочкой.

Итоговая последовательность атаки выглядела так:

  1. Создаётся файл с полезной нагрузкой. Первая строка — #!/bin/bash, далее идут команды для установки обратного соединения или чтения системных файлов.
  2. Этот файл загружается как «аватар» с заголовком Content-Type: application/octet-stream. Сервер сохраняет его как random_hash.bin в /tmp/upload_staging/.
  3. Вызывается API импорта с параметром пути, указывающим на этот файл: import_path=../../../tmp/upload_staging/random_hash.bin.
  4. Фоновый джоб, получив задание, выполняет команду вида system_util process ../../../tmp/upload_staging/random_hash.bin, что приводит к запуску нашего скрипта.

[ИЗОБРАЖЕНИЕ: Диаграмма последовательности атаки. Три вертикальных дорожки: Клиент, Frontend-сервис, Worker (фоновый джоб). Пронумерованные стрелки: 1. POST /upload (файл с shebang). 2. Файл сохраняется в /tmp/upload_staging/file.bin. 3. POST /job/start (с параметром path=../../../tmp/…). 4. Worker читает файл по пути. 5. Worker выполняет команду ‘system_util process [путь]’. 6. Исполняется шебанг в файле.]

Причина глубже кода: архитектурный разрыв доверия

На поверхности это выглядит как две классические уязвимости: неограниченная загрузка файлов и инъекция команд. Но их корень — в архитектурном разрыве. Две разные команды разработчиков создавали модуль загрузки файлов и систему фоновых задач, исходя из противоречащих друг другу негласных правил.

Команда модуля загрузки считала: «Все файлы после валидации либо принимаются, либо сразу удаляются». Команда фоновых задач была уверена: «Мы обрабатываем только внутренне сгенерированные файлы из своей изолированной директории». Общего механизма, который отслеживал бы происхождение файла и контекст его использования между этими контурами, не было. Это прямое нарушение принципа минимальных привилегий на уровне взаимодействия компонентов.

Непосредственной технической причиной инъекции стал устаревший шаблон вызова команд через оболочку с конкатенацией строк, вместо безопасной передачи аргументов массивом.

Disclosure и исправления: от мониторинга до архитектуры

Отчёт был составлен с акцентом на демонстрацию цепочки и её архитектурную причину, а не только на шаги эксплойта. После отправки через программу Bug Bounty ответ команды безопасности пришёл быстро. Их действия были нестандартны: вместо немедленного исправления они сначала развернули усиленный мониторинг попыток доступа фоновых задач к файлам вне рабочей директории. Это позволило убедиться в отсутствии активных атак в реальном времени.

Финальные исправления затронули несколько уровней:

  • Валидация на входе: Модуль загрузки стал проверять не только MIME-тип по заголовку, но и сигнатуру файла (magic numbers). Временные файлы, не прошедшие проверку, удаляются до отправки ответа клиенту.
  • Изоляция контуров: Фоновые джобы лишились прямого доступа к файловой системе по путям. Вместо пути в параметре они теперь принимают только внутренний UUID файла, который резолвится в безопасном хранилище (например, объектном).
  • Безопасное выполнение команд: Все вызовы системных утилит переписаны с использованием массивов аргументов, исключающих интерпретацию оболочкой.
  • Информационная гигиена: Из всех ответов продакшен-сервера удалена любая отладочная информация, включая серверные пути.

Вознаграждение было выплачено. Более ценным стало последующее взаимодействие с архитекторами сервиса, которые использовали этот кейс для внутреннего аудита похожих сценариев взаимодействия между другими микросервисами.

Последствия для российского ИБ-контекста

Этот случай актуален не только для зарубежных облачных сервисов. В российских разработках, особенно в быстрорастущих проектах и госзаказных платформах, аналогичные проблемы возникают по тем же причинам: изолированная работа команд, наследие монолитной архитектуры, фокус на функциональность в ущерб кросс-модульной безопасности.

Требования регуляторов, таких как ФСТЭК и 152-ФЗ, всё чаще смещаются с формального соответствия чек-листам к необходимости обеспечения целостности и безопасности системы в целом. Эта история показывает, как формальное выполнение требований (валидация типа файла) не защищает от атак, которые эксплуатируют разрывы между компонентами.

Участие в Bug Bounty-программах даёт практический опыт в поиске именно таких, архитектурных, а не синтаксических уязвимостей. Умение видеть систему как совокупность контуров доверия и находить точки их несогласованности — критически важный навык для специалистов по AppSec и архитекторов. Он напрямую связан с построением защиты, которая работает против реальных угроз, а не просто закрывает строчки в отчёте о соответствии.

Оставьте комментарий