Конвейер для недоверенных файлов: архитектура, а не флаг

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

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

Остаётся третье — сделать так, чтобы успешная эксплуатация парсера дала атакующему как можно меньше. Не «не допустить уязвимость», а «сделать её последствия локальными».

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

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

В статье

Ошибка по умолчанию: парсинг в основном процессе

Типичный обработчик загрузки выглядит так: приняли файл, тут же определили формат, сняли метаданные, сделали превью — всё в том же процессе, что обслуживает 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именованный канал. От FIFO O_NOFOLLOW не спасает, а открытие канала, в который никто не пишет, блокируется навсегда. Супервизор не «отвергнет подделку с ошибкой», а просто встанет в системном вызове — и никакой таймаут снаружи не поможет, потому что таймауту неоткуда сработать.

Это не гипотеза из документации: тест кладёт по пути ответа FIFO и требует, чтобы чтение завершилось. Если убрать O_NONBLOCK, тест зависает и падает по сроку — проверено намеренной поломкой.

И последнее по этой части: после первого JSON-объекта в ответе не должно быть ничего. Иначе файл вида «правильный объект, а следом чужой» принимается по первому, а всё остальное просто не попадает никому на глаза.

Чтение чужих загрузок. Входной том смонтирован только на чтение, но целиком: там лежат файлы других пользователей. Разделение входа по задачам — следующий шаг, которого в стенде нет.

Исчерпание ресурсов в пределах лимитов. 256 МиБ, одно ядро, 64 процесса — на это парсер имеет полное право, и при одном экземпляре это означает остановку обработки очереди.

Накопление файлов. Обработанные загрузки в стенде не удаляются, тома растут без ограничений — очистки, квот на пользователя и срока хранения здесь нет. В рабочем сервисе это отдельная и обязательная часть: без неё входной том со временем превращается в архив чужих файлов, доступный скомпрометированному парсеру на чтение, а jobs и done — в место, куда он может писать сколько угодно.

Чего не остаётся: сети в любую сторону, записи во входной том, изменения прав и владельца, повышения привилегий, выхода за лимиты памяти и числа процессов.

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

Итог

  • Место, где разбирается недоверенный файл, должно быть самым бедным местом системы. Разделение проходит по признаку «кто интерпретирует содержимое».
  • Ролей понадобилось три. Сеть с пометкой internal убирает выход наружу, но её участники видят друг друга: воркер с очередью и парсером в одном контейнере дотягивался до Redis и API. Разбор вынесен в контейнер, где не осталось интерфейсов, кроме loopback, связь — через файлы.
  • Границы надо доказывать поодиночке. Пары «без границы / с границей» мало: при полном наборе отказ объясняется чем угодно из набора. Каждая проверена трижды — контроль, одна граница, полный набор.
  • Проба normal_work обязательна. Защита, ломающая работу, не защищает: её снимут при первом инциденте вместе со всем профилем.
  • Сбой должен оставаться локальным. Ни остановка парсера, ни его авария, ни падение супервизора не затронули API и не потеряли принятые задачи.
  • Ответ из недоверенной зоны проверяется, а не разбирается. Предел размера, применённый после чтения файла в память, не ограничивает ничего. Проверять надо открытый дескриптор, а не путь, и учитывать не только символические ссылки, но и именованные каналы: на них наивное открытие зависает намертво.
  • Семантика очереди — «не меньше одного раза». Задача, не подтверждённая до падения, будет обработана повторно; идемпотентность обработчика — требование, а не подарок.
  • Пять дефектов стенд нашёл сам — сломавший работу seccomp-профиль, проба с мгновенно завершающимися процессами вместо одновременно живущих, проба chown, проходившая из-за совпадения uid, неверная модель аварии и отчёт, показывавший уже перезапущенный контейнер как невредимый. Шестым можно считать сценарий «приём при мёртвом парсере», который измерял приём при живом. Все выглядели как рабочие проверки.

Это и есть главный сквозной урок серии: правдоподобная проверка и работающая проверка — разные вещи. Знать, что внутри; замечать, когда сломалось; сдерживать, когда всё-таки прорвалось — три части одной задачи, и ни одна не заменяет остальные.

Смежное на сайте: Bubblewrap: песочница для недоверенного кода — изоляция отдельной команды без контейнеров; Моделирование угроз (STRIDE)Скоро — как находить такие границы доверия на этапе проектирования.

Источники

Обсуждение в Telegram

Присоединиться →

Комментарии