«Эта история — о мёртвом файле, который внезапно ожил. Не потому что код сломался, а потому что две команды разработчиков, работая в одном проекте, построили для себя разные миры, которые никто не свёл воедино. Похожие разрывы живут в любом сложном сервисе, и стандартные проверки ФСТЭК их не находят — они смотрят на кирпичи, а не на трещины между ними.»
Неверный ответ сервера и возникшая цепочка
Во время работы с API облачного сервиса в ответе на некорректный запрос о загрузке аватара система показала полный путь к файлу на диске. Примерно так: /tmp/upload_staging/avatar_65e8a3f21a7c.bin. Такие данные в рабочем окружении раскрывать нельзя — это сигнал о том, что обработка ошибок настроена некорректно.
Далее мы отправили на этот же эндпоинт файл, не являющийся изображением. Сервер ответил ошибкой валидации, но из текста стало ясно: файл сначала полностью сохраняется в файловой системе и только потом проверяется. Если проверка не проходит — файл остаётся висеть в директории. Возник главный вопрос: кто отвечает за очистку этих «отбракованных» файлов и как долго они там живут?
[ИЗОБРАЖЕНИЕ: Схема потока загрузки файла до исправления. Блоки: 1) Клиент отправляет файл. 2) Балансировщик. 3) Backend-сервис: «Сохранить в /tmp/upload_staging/». 4) Модуль валидации: «Файл не прошёл проверку». Стрелка ведёт от 3 к 4, но обратной стрелки «Удалить файл» нет. Файл остаётся в /tmp/.]
Сборка пазла: от мёртвого файла до выполнения команды
Записать произвольный файл в известное место — это только половина пути. Нужен второй компонент, который этот файл неожиданно использует. В документации API мы нашли другой эндпоинт — для запуска фоновых задач по импорту данных из CSV. Эта задача использовала внешнюю системную утилиту для обработки.
Ключевым был параметр, в котором можно было задать путь к файлу для импорта. Логика предполагала, что джоб работает только с файлами в своей рабочей директории. Однако проверка строилась на простой конкатенации базового пути и пользовательского ввода. Это позволяло выйти за её пределы, используя последовательности вида ../../../ (Path Traversal).
Таким образом, сложилась цепочка:
- Через эндпоинт загрузки аватара можно поместить файл с нужным содержимым в известную временную директорию (
/tmp/upload_staging/). - Через эндпоинт запуска импорта можно указать путь, который заставит фоновый джоб найти и обработать этот самый файл.
- Джоб, не проверяя содержимое файла, передаёт его путь в системную команду для обработки.
Оставалось два технических препятствия. Первое — эндпоинт загрузки переименовывал файлы, добавляя к ним расширение. Второе — требовалось превратить загруженные данные в исполняемый код.
Как обходили защиту: расширение и шебанг
Изучение логики обработчика загрузки (через вариацию запросов и анализ ошибок) показало: расширение файла определяется исключительно по HTTP-заголовку Content-Type. Если отправить Content-Type: application/octet-stream, сервер сохранял файл с расширением .bin.
Фоновый джоб при передаче файла утилите не смотрел на его расширение. Для исполнения важны были два фактора: наличие бита исполнения (x) и корректный шебанг (#!/bin/bash) в начале файла. Выставить бит исполнения удалённо нельзя, но шебанг можно записать прямо в содержимое загружаемого файла. Утилита, будучи вызванной из скрипта на Python через subprocess, могла исполнить такой файл, если он интерпретировался оболочкой.
Итоговая последовательность атаки выглядела так:
- Создаётся файл с полезной нагрузкой. Первая строка —
#!/bin/bash, далее идут команды для установки обратного соединения или чтения системных файлов. - Этот файл загружается как «аватар» с заголовком
Content-Type: application/octet-stream. Сервер сохраняет его какrandom_hash.binв/tmp/upload_staging/. - Вызывается API импорта с параметром пути, указывающим на этот файл:
import_path=../../../tmp/upload_staging/random_hash.bin. - Фоновый джоб, получив задание, выполняет команду вида
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 и архитекторов. Он напрямую связан с построением защиты, которая работает против реальных угроз, а не просто закрывает строчки в отчёте о соответствии.