Две предыдущие части серии были про знание: что на самом деле лежит внутри вашего образа и как заметить, что парсер уже сломали. Обе упираются в неприятный вывод: инвентаризация неполна, детект дорог, а патч приходит позже атаки.
Остаётся третье — сделать так, чтобы успешная эксплуатация парсера дала атакующему как можно меньше. Не «не допустить уязвимость», а «сделать её последствия локальными».
Я собрал рабочий конвейер приёма файлов и проверил каждую границу изоляции экспериментом. Заодно поймал полдюжины собственных дефектов — в защите, в пробах и в самом отчёте, — и один раз пришлось переделать саму архитектуру, потому что измерение опровергло утверждение, которым я эту архитектуру описывал.
В статье
- Ошибка по умолчанию: парсинг в основном процессе
- Разделение: кто открывает файл
- Почему ролей оказалось три, а не две
- Границы и доказательство, что работает именно эта
- Когда защита ломает работу
- Когда проба измеряет не то
- Что происходит при падении
- Что остаётся возможным
- Итог
- Источники
Ошибка по умолчанию: парсинг в основном процессе
Типичный обработчик загрузки выглядит так: приняли файл, тут же определили формат, сняли метаданные, сделали превью — всё в том же процессе, что обслуживает HTTP.
Проблема не в производительности. Проблема в том, какими правами обладает этот процесс. У него есть подключение к базе, учётные данные S3, доступ во внутреннюю сеть, токены сервис-аккаунтов — потому что это его обычная работа. И в нём же исполняется код, разбирающий байты, которые прислал незнакомый человек.
Уязвимость в парсере при такой схеме означает выполнение кода в контексте, где лежит всё ценное. Как показал разбор PixelSmash, для этого достаточно файла в 50 КБ, который никто не открывал вручную.
Отсюда простая мысль, вокруг которой строится вся статья: место, где разбирается недоверенный файл, должно быть самым бедным местом системы.
Разделение: кто открывает файл
Конвейер стенда состоит из трёх сервисов и очереди:
клиент → api ──→ Redis ──→ supervisor ──файлы──→ parser (ffprobe)
↑ ↑ ↑
принимает байты, очередь, разбирает содержимое;
НЕ открывает их файлы не открывает ни внешней сети, ни соседей
Линия разделения проходит по одному признаку — кто интерпретирует содержимое.
api принимает поток байт, сохраняет его и кладёт идентификатор задачи в очередь. Он не определяет формат, не читает заголовки, не строит превью. В коде это буквально копирование потока в файл с ограничением размера — и всё.
supervisor держит очередь и координирует работу. Содержимое файлов он тоже не открывает: его дело — выдать задание и дождаться ответа.
parser разбирает файл через ffprobe. Это и есть недоверенная зона, и потому он лишён почти всего: доступа и к внешней сети, и к соседним сервисам, прав, возможности писать во входной том.
Разделение даёт ещё одно свойство, менее очевидное: парсер одноразовый по смыслу. Он не хранит состояние между задачами, на каждый файл запускается отдельный процесс ffprobe, и перезапуск ничего не стоит. Для повреждений памяти это существенно: испорченная куча уходит вместе с процессом, а не переезжает на следующий файл.
Полный конвейер, который можно поднять одной командой: security/untrusted-input/pipeline.
Почему ролей оказалось три, а не две
Изначально их было две: API и воркер, который и очередь читал, и файлы разбирал. Воркер сидел в Docker-сети с пометкой internal — без выхода наружу, но с доступом к Redis. Я описывал это словами «у парсера нет сети».
Проба показала, что это неправда. Кроме внешнего соединения, стенд спрашивает у окружения и про соседей по конвейеру:
network_external заблокировано dial tcp 1.1.1.1:53: socket: operation not permitted
network_internal доступны: [redis api]
internal убирает выход наружу, но участники такой сети продолжают видеть друг друга. Скомпрометированный парсер дотягивался и до Redis, и до API: мог читать чужие задачи, подменять результаты, чистить очередь. Формулировка «сети нет» описывала не ту границу, которая действовала.
Чинится это не флагом, а перестановкой ролей. Сеть нужна очереди — значит, работать с очередью должен тот, кто не открывает файлы. Разбор уехал в отдельный контейнер с network_mode: none: у него не остаётся ни одного сетевого интерфейса, кроме loopback, — то есть ни выхода наружу, ни соседей по конвейеру. Связь с миром — файлы в общем томе, которые кладёт и забирает супервизор.
Это ровно тот случай, ради которого стоит городить проверки: измерение опровергло не строчку конфига, а утверждение, вокруг которого была построена схема.
Границы и доказательство, что работает именно эта
Список ограничений парсера выглядит убедительно: network_mode: none, read_only, cap_drop: ALL, no-new-privileges, непривилегированный пользователь, pids_limit, лимит памяти, seccomp-профиль, вход смонтирован только на чтение.
Но, как выяснилось во второй части серии, между «записано в конфиге» и «действует» может лежать пропасть: переменные MALLOC_CHECK_ годами стоят в окружении, ничего не включая. Конфигурация, которая ничего не ограничивает, выглядит в файле точно так же, как работающая.
Мало и того, что проверка идёт парой «без границ / с границами». При включённом полном наборе отказ chmod одинаково хорошо объясняется и seccomp, и cap_drop, и непривилегированным uid, и read_only. Пока не выключено всё, кроме одного, вывод «это seccomp» остаётся догадкой.
Поэтому каждая проба выполняется трижды: контроль без ограничений, запуск с одним изучаемым ограничением и запуск с полным набором.
| Проба | Граница | Без границ | Только эта | Полный набор |
|---|---|---|---|---|
разбор файла (normal_work) |
все | — | — | разрешено |
| соединение наружу | network_mode: none |
разрешено | запрещено | запрещено |
| соседи по конвейеру | network_mode: none |
разрешено | запрещено | запрещено |
| запись вне рабочих каталогов | read_only |
разрешено | запрещено | запрещено |
| запись во входной том | монтирование ro |
разрешено | запрещено | запрещено |
смена прав (chmod) |
seccomp | разрешено | запрещено | запрещено |
смена владельца (chown) |
cap_drop: ALL |
разрешено | запрещено | запрещено |
| повышение привилегий | no-new-privileges |
разрешено | запрещено | запрещено |
| удержание процессов | pids_limit: 64 |
разрешено | запрещено | запрещено |
| память сверх лимита | mem_limit: 256m |
разрешено | запрещено | запрещено |
Числа, а не только вердикты: удержать удалось 58–60 одновременных процессов при лимите 64 — по пяти прогонам разброс именно такой, потому что часть слотов занимает сам процесс парсера со своими потоками, и их число меняется от запуска к запуску. Попытка занять 512 МиБ при лимите 256 МиБ завершается кодом 137: процесс убит ядром.
Контроль здесь не формальность. Если операция не проходит и без ограничения, то её отказ при ограничении не доказывает ничего — стенд в таком случае останавливается и не рисует красивую матрицу.
Первая строка не менее важна остальных. Она проверяет обратное: при всех включённых границах штатный разбор файла проходит. Без неё «всё запрещено» неотличимо от сломанного сервиса — а это, как оказалось, не теоретическая опасность.
Когда защита ломает работу
Первая версия seccomp-профиля запрещала воркеру socket и connect. Логика казалась безупречной: парсеру медиафайла сеть не нужна, значит запретим её на уровне системных вызовов.
Конвейер перестал работать целиком. Воркер не мог подключиться к очереди и не брал ни одной задачи:
очередь: dial tcp: lookup redis: socket: operation not permitted
Это важнее, чем кажется. Правило, ломающее штатную работу, не защищает — его снимут при первом же инциденте, и обычно вместе со всем профилем, потому что разбираться, какая именно строка мешает, никто не будет в три часа ночи.
Причина ошибки в том, что я смешал два разных требования. «Разбору файлов не нужны сокеты» — верно. «Воркеру не нужны сокеты» — неверно: очередь тоже работает через сокет. Ограничение стояло на правильном уровне, но было наложено не на ту роль.
Именно отсюда выросло разделение на супервизор и парсер. Когда роли разъехались, запрет socket в seccomp стал не компромиссом, а простой констатацией: соединяться парсеру всё равно не с кем.
Когда проба измеряет не то
Раздельная проверка границ дала неожиданный результат:
capabilities_chown без границ: разрешено только cap_drop ALL: разрешено полный набор: запрещено
Соблазнительный вывод — «chown закрывает не cap_drop, а что-то другое из набора». Он неверен.
Проба делала chown(path, 0, 0), а контейнер без --user работает под root. Менять владельца с 0:0 на 0:0 — не изменение, а пустая операция, и ядро разрешает её без CAP_CHOWN. В полном же наборе процесс работает под uid 65534, тот же вызов становится настоящей сменой владельца — и отказывает.
То есть проба показывала не состояние границы, а совпадение uid. Верный вывод был не «работает другая граница», а «измеряется не то». После замены цели на чужой uid столбцы сошлись: cap_drop: ALL запрещает chown и в одиночку.
Это третий по счёту случай в серии, когда проверка выглядела осмысленной и давала стабильный воспроизводимый результат, будучи при этом бессмысленной. Ни один из них не поймался бы, если бы я смотрел только на итоговую колонку.
Что происходит при падении
Изоляция имеет смысл, только если сбой в изолированной части остаётся локальным. Стенд проверяет это тремя отдельными сценариями.
Приём, пока парсер не работает. Парсер остановлен, состояние контейнера — exited, проверено до загрузки. API отвечает 200 и принимает файл: клиент получает идентификатор задачи, а не ошибку. Задача при этом обязана где-то лежать — стенд смотрит напрямую в Redis и находит её в processing. После запуска парсера именно эта задача доходит до результата.
Сценарий выделен в отдельный намеренно. В первой версии загрузка шла сразу после аварийного завершения парсера, но функция, роняющая контейнер, возвращала управление, лишь дождавшись перезапуска: к моменту загрузки парсер был уже жив. Проверка называлась «приём при мёртвом парсере», а измеряла приём при живом. Ошибка того же рода, что и с chown: результат стабильный, воспроизводимый и не о том.
Авария парсера — недоверенной зоны. API продолжает отвечать 200, супервизор остаётся в состоянии running: сбой не перешёл через границу контейнера. Парсер поднимается политикой restart, и обработка продолжается.
Падение супервизора посреди взятой задачи. Задача в этот момент лежит в processing — проверено напрямую, до аварии. Супервизор поднимается, обнаруживает задачу без отметки «в работе» и возвращает её в очередь. Результат приходит.
После всех сценариев все три списка — tasks, processing, poison — обязаны быть пустыми. Не только poison: непустой processing означал бы зависшую задачу, и отчёт, использующий пустые очереди как доказательство, обязан на этом останавливаться.
Здесь стоит остановиться на семантике очереди, потому что от неё зависит, что вообще значит «задача не потеряна».
Наивный вариант — BRPop: задача удаляется из очереди в момент чтения. Падение между чтением и записью результата уничтожает её безвозвратно. В стенде используется BLMove в отдельный список processing: задача остаётся там до явного подтверждения, а незавершённые возвращаются обратно.
Возврат делается одной атомарной операцией — Lua-скриптом в Redis. Пара LREM + LPUSH имеет окно, в котором задача не лежит ни в одной из очередей, и падение ровно в этот момент теряет её так же надёжно, как BRPop.
Плата за это названа прямо: доставка получается не меньше одного раза. Задача, не подтверждённая до падения, будет обработана повторно. Для генерации превью это безобидно — результат зависит только от входного файла. Для записи в биллинг, отправки уведомления или инкремента счётчика — нет: обработчик обязан быть идемпотентным, и это требование к вашему коду, а не свойство, которое даёт очередь.
Отдельный дефект стенд поймал в самой модели аварии, и чинить его пришлось дважды. Сначала падение вызывалось внешним docker kill — и контейнер не перезапускался: Docker считает такую остановку намеренной и политику restart не применяет. Проверка выглядела осмысленной, но проверяла не тот сценарий; реальный крах моделируется сигналом главному процессу изнутри контейнера.
После этого отчёт всё равно врал: он печатал состояние контейнера сразу после аварии, а к этому моменту тот был уже поднят и показывал running с кодом выхода 0 — будто ничего не случилось. Факт аварии пришлось фиксировать счётчиком перезапусков: было 0, стало 1.
Что остаётся возможным
Изоляция — не неприступность, и статья была бы нечестной без списка того, что атакующему в парсере всё-таки доступно.
Запись в тома обмена. Каталоги заданий и результатов смонтированы на запись — иначе парсер не смог бы отдать ответ. Значит, он может написать ответ на чужое задание, положить вместо файла символическую ссылку или отдать ответ размером в гигабайт. Ответ из недоверенной зоны — такой же недоверенный ввод, как исходный файл, и супервизор его не «разбирает», а проверяет: совпадение идентификатора задачи с выданным, известный статус, запрет неизвестных полей.
С размером тонкость, на которой я сам оступился. Первая версия читала файл целиком и потом сверяла размер с пределом — то есть предел не ограничивал ничего: гигабайтный ответ сначала оказывался в памяти супервизора и лишь затем отвергался. Это ровно та беда, от которой предел и заводят. Сейчас размер проверяется до чтения, но и само чтение ограничено: файл можно дописать после проверки, поэтому проверенный размер описывает прошлое, а ограничение при чтении — настоящее.
Со способом проверки тонкость ещё интереснее, и она общая для любого обмена файлами с недоверенной стороной. Проверять по имени — значит проверять не то, что потом откроешь: между проверкой и открытием объект по этому пути подменяется. Поэтому порядок обратный: сначала открыть, потом расспрашивать открытый дескриптор, а не путь. Имя используется лишь для того, чтобы понять, появился ли ответ вообще.
Флаги открытия закрывают два разных обхода, и второй неочевиден:
O_NOFOLLOW— символическая ссылка, ведущая в файловую систему супервизора;O_NONBLOCK— именованный канал. От FIFOO_NOFOLLOWне спасает, а открытие канала, в который никто не пишет, блокируется навсегда. Супервизор не «отвергнет подделку с ошибкой», а просто встанет в системном вызове — и никакой таймаут снаружи не поможет, потому что таймауту неоткуда сработать.
Это не гипотеза из документации: тест кладёт по пути ответа FIFO и требует, чтобы чтение завершилось. Если убрать O_NONBLOCK, тест зависает и падает по сроку — проверено намеренной поломкой.
И последнее по этой части: после первого JSON-объекта в ответе не должно быть ничего. Иначе файл вида «правильный объект, а следом чужой» принимается по первому, а всё остальное просто не попадает никому на глаза.
Чтение чужих загрузок. Входной том смонтирован только на чтение, но целиком: там лежат файлы других пользователей. Разделение входа по задачам — следующий шаг, которого в стенде нет.
Исчерпание ресурсов в пределах лимитов. 256 МиБ, одно ядро, 64 процесса — на это парсер имеет полное право, и при одном экземпляре это означает остановку обработки очереди.
Накопление файлов. Обработанные загрузки в стенде не удаляются, тома растут без ограничений — очистки, квот на пользователя и срока хранения здесь нет. В рабочем сервисе это отдельная и обязательная часть: без неё входной том со временем превращается в архив чужих файлов, доступный скомпрометированному парсеру на чтение, а jobs и done — в место, куда он может писать сколько угодно.
Чего не остаётся: сети в любую сторону, записи во входной том, изменения прав и владельца, повышения привилегий, выхода за лимиты памяти и числа процессов.
Стоит назвать и границы самой проверки. Это не пентест: пробы спрашивают у окружения «можно ли», а не пытаются обойти защиту. Стойкость seccomp к обходам, побеги из контейнера и уязвимости ядра здесь не проверялись. Версия ffmpeg в стенде пришла с базовым образом и рекомендацией не является: стенд показывает, что даёт изоляция, а не что даёт обновление. В рабочем сервисе нужны оба.
Итог
- Место, где разбирается недоверенный файл, должно быть самым бедным местом системы. Разделение проходит по признаку «кто интерпретирует содержимое».
- Ролей понадобилось три. Сеть с пометкой
internalубирает выход наружу, но её участники видят друг друга: воркер с очередью и парсером в одном контейнере дотягивался до Redis и API. Разбор вынесен в контейнер, где не осталось интерфейсов, кроме loopback, связь — через файлы. - Границы надо доказывать поодиночке. Пары «без границы / с границей» мало: при полном наборе отказ объясняется чем угодно из набора. Каждая проверена трижды — контроль, одна граница, полный набор.
- Проба
normal_workобязательна. Защита, ломающая работу, не защищает: её снимут при первом инциденте вместе со всем профилем. - Сбой должен оставаться локальным. Ни остановка парсера, ни его авария, ни падение супервизора не затронули API и не потеряли принятые задачи.
- Ответ из недоверенной зоны проверяется, а не разбирается. Предел размера, применённый после чтения файла в память, не ограничивает ничего. Проверять надо открытый дескриптор, а не путь, и учитывать не только символические ссылки, но и именованные каналы: на них наивное открытие зависает намертво.
- Семантика очереди — «не меньше одного раза». Задача, не подтверждённая до падения, будет обработана повторно; идемпотентность обработчика — требование, а не подарок.
- Пять дефектов стенд нашёл сам — сломавший работу seccomp-профиль, проба с мгновенно завершающимися процессами вместо одновременно живущих, проба
chown, проходившая из-за совпадения uid, неверная модель аварии и отчёт, показывавший уже перезапущенный контейнер как невредимый. Шестым можно считать сценарий «приём при мёртвом парсере», который измерял приём при живом. Все выглядели как рабочие проверки.
Это и есть главный сквозной урок серии: правдоподобная проверка и работающая проверка — разные вещи. Знать, что внутри; замечать, когда сломалось; сдерживать, когда всё-таки прорвалось — три части одной задачи, и ни одна не заменяет остальные.
Смежное на сайте: Bubblewrap: песочница для недоверенного кода — изоляция отдельной команды без контейнеров; Моделирование угроз (STRIDE)Скоро — как находить такие границы доверия на этапе проектирования.
Источники
- Стенд статьи с конвейером и матрицей границ:
security/untrusted-input/pipeline - Docker, seccomp-профили: https://docs.docker.com/engine/security/seccomp/
- Docker, сети и режим
none: https://docs.docker.com/engine/network/ - Linux capabilities: https://man7.org/linux/man-pages/man7/capabilities.7.html
- Redis, команда
BLMOVEи надёжные очереди: https://redis.io/docs/latest/commands/blmove/ - JFrog Security Research, разбор PixelSmash: https://jfrog.com/blog/pixelsmash-critical-ffmpeg-vulnerability-turns-media-files-into-weapons/
Комментарии