Назад к блогу

MXC от Microsoft: как песочница выбирает уровень изоляции и доводит процесс до teardown

MXC от Microsoft: как песочница выбирает уровень изоляции и доводит процесс до teardown

Microsoft MXC запускает недоверенный код на Windows, но уровень изоляции не фиксирован: библиотека сначала зондирует хост, собирает набор фактов о доступных API и только потом выбирает бэкенд — от BaseContainer до AppContainer с файловой политикой или расширением DACL. В статье разбирается эта логика выбора с фолбэками и то, как процесс доходит до teardown с очисткой следов.

MXC запускает недоверенный код на Windows, опираясь на разные механизмы изоляции. Выбор механизма — не константа: он зависит от того, какие API доступны на конкретной машине, какие возможности запросила политика и как собрана сама библиотека. Разберём, как устроена эта механика: от сбора фактов о хосте до завершения процесса и очистки следов.

Сбор фактов о хосте и выбор уровня

Перед выбором уровня изоляции библиотека собирает факты о хосте. Функция run_probe заполняет структуру ProbeFacts — набор сведений о том, что умеет хост: доступен ли BaseContainer API, работает ли native capture, загружается ли LearningModeApi, поддерживаются ли запреты путей через PSEC, доступен ли guarded capture и guarded WPR. Поля этой структуры отражают перечисленные способности хоста: доступность BaseContainer API, доступность native capture, доступность guarded capture, наличие bfscfg.exe, признак компиляции BFS, поддержку запретов путей через PSEC. Факты собираются опросом статических методов раннера и детектора запасного пути. Доступность BaseContainer API определяется через проверку, реализован ли нужный набор API. Наличие native capture требует сразу двух условий: доступности BaseContainer API и успешной загрузки LearningModeApi. Поддержка PSEC deny-paths — это доступность BaseContainer API плюс подтверждённая поддержка файловой политики deny. Наличие bfscfg.exe при выключенной фиче tier2_bfs всегда даёт Ok(None), а при включённой путь строится от системного каталога Windows плюс System32 плюс имя исполняемого файла. Флаг компиляции tier2_bfs читается напрямую.

Собранные факты вместе с решением о уровне передаются дальше. Если в политике задан capture_denials, выбранный уровень не BaseContainer и guarded capture недоступен, возвращается ошибка о недоступности запасного пути для guarded WPR.

Сам выбор делает choose_backend_tier:

  • Tier 1 — запуск в BaseContainer, когда BaseContainer способен обслужить запрос.
  • Tier 2 — запуск в AppContainer с файловой политикой BFS; рассматривается только при включённой фиче tier2_bfs и выбирается, когда файловой политики нет либо найден bfscfg.exe.
  • Иначе происходит переход к Tier 3 — AppContainer с расширением DACL.

Как работает select_backend_with_fallback и dispatch_with_fallback

Выбор бэкенда начинается с вызова choose_backend_tier. Затем, если запрошен capture_denials и выбранный уровень не BaseContainer, требуется фабрика захвата: без неё возвращается ошибка о неподдерживаемом захвате, а с ней вызывается проверка доступности фабрики, чья ошибка превращается в отдельную ошибку о недоступности захвата.

Дальше по выбранному уровню строится бэкенд. Для BaseContainer создаётся раннер без DACL. Для AppContainer с BFS при пустом списке запрещённых путей создаётся раннер в BFS-режиме без DACL; при непустом — выводится SID, строится DACL только с запретами, и раннер помечается как работающий с внешними запрещёнными путями лишь тогда, когда требуется guarded capture.

Порядок проверок уровней задан жёстко. Сначала проверяются два условия, каждое из которых блокирует переход на более высокий уровень при неспособности BaseContainer обслужить запрос: запрос перечисления путей даёт ошибку о неподдерживаемом перечислении, запрос host loopback — ошибку о неподдерживаемом ingress. Затем, если BaseContainer способен обслужить запрос, возвращается Tier 1. Иначе, если причина — неподдерживаемые запрещённые пути, к списку причин деградации добавляется соответствующая причина, а если BaseContainer просто недоступен — другая.

Tier 2 доступен только при включённой фиче. При отсутствии файловой политики или наличии bfscfg.exe выбирается AppContainer с BFS, причём если есть запрещённые пути, добавляется причина о необходимости расширения DACL. Если bfscfg.exe нет — добавляется причина о его отсутствии. Без фичи добавляется причина о выключенной фиче Tier 2 и происходит переход к Tier 3.

Признак деградации истинен, если уровень не BaseContainer, либо нужна DACL-аугментация, либо список причин непуст.

Отдельные проверки возможностей устроены единообразно: поддержка запрещённых путей, перечисления путей и host loopback ingress каждая требует доступности BaseContainer API плюс поддержки соответствующей возможности — файловой политики deny, файлового перечисления и сетевого ingress соответственно.

Переменная MXC_FORCE_TIER

Переменная окружения MXC_FORCE_TIER читается только в тестовых сборках или при включённой Cargo-фиче force-tier-testing — обычные debug и release сборки эту ветку не компилируют. Значение парсится как уровень изоляции, и при успехе возвращается принудительное решение, минуя реальную цепочку проб. Допустимы строки трёх уровней: base-container, appcontainer-bfs и appcontainer-dacl.

Невалидное значение молча игнорируется, и выполняется настоящая цепочка проб — тест подтверждает, что при таком значении проверка политики не возвращает ошибку. При валидном значении принудительное решение всё равно применяет тот же DACL-барьер, что и реальный алгоритм: для уровня DACL расширение нужно всегда, для остальных — только при наличии запрещённых путей, и если расширение нужно при запрете мутации DACL, возвращается ошибка.

Разница в поведении существенная: при невалидном значении управление доходит до реальных проверок способности BaseContainer обслужить запрос и поиска bfscfg.exe, а при валидном эти пробы не выполняются вовсе.

Tier 1: выбор версии PSEC

PSEC

Функция выбора минимальной требуемой версии PSEC начинает с версии 1.0 и повышает её до максимума, требуемого запрошенными нативными возможностями. Непустой список запрещённых путей поднимает версию до требуемой для файловой политики deny. Непустой список перечисляемых путей — до требуемой для файлового перечисления, что тест подтверждает как 1.1. Для host loopback ingress версия поднимается до требуемой для сетевого ingress только при разрешённом неограниченном host loopback и выключенном обходном пути.

Обходной путь psec_1_0_proxy_loopback_workaround активен только тогда, когда политика указывает runtime-прокси, прокси включён, разрешённый peer прокси не задан и host loopback разрешён. В этом случае он срабатывает, если хост поддерживает только версию 1.0: при поддержке 1.1 обходной путь не применяется.

При активном обходном пути host loopback не поднимает версию до сетевого ingress, но перечисление путей всё равно требует 1.1 — тест это подтверждает.

Tier 1: сериализация сетевых правил

Построение спецификации сетевой политики выбирает версию PSEC и вызывает построитель спецификации. Смешанные ICMP-правила разделяются по семейству адресов: allow-правило с портом TCP 443 и ICMP на выходе разворачивается в три записи — tcp, icmpv4 и icmpv6. ICMP ограничивается семейством адреса назначения: для CIDR 10.0.0.0/8 эмитится icmpv4, для 2001:db8::/32 — icmpv6, ровно одна запись на CIDR.

Направленный default-deny кодируется как egress с действием deny при отсутствии capabilities. Направленный allow — как egress с действием allow и capabilities internetClient. Ingress host-loopback в PSEC 1.1 кодируется как ingress с действием deny, host_loopback = allow и разрешённым peer-контейнером, равным loopback-сети.

Proxy и direct egress взаимоисключающие: при заданном прокси заполняется URL прокси и разрешённый peer-контейнер, а egress отсутствует; без прокси прокси отсутствует, а egress присутствует.

Tier 1: блок окружения дочернего процесса

Формирование блока окружения различает три состояния поля env у запроса. Если env не задано, дочерний процесс получает чистый блок профиля пользователя по умолчанию, а не переменные самого процесса-запускателя. Если env задано явно пустым, получается пустой блок, а не блок по умолчанию. При включённом наследовании окружения по умолчанию переданные записи накладываются поверх блока по умолчанию, заменяя одноимённые переменные, а не добавляясь рядом.

При обычной дословной передаче окружение используется как есть, и библиотека ничего не добавляет — в том числе переменные, обязательные для Windows. Разреженное дословное окружение отвергается до проб хоста: проверка возвращает ошибку с фазой отклонения и сообщением, содержащим LOCALAPPDATA.

Tier 2: пошаговое создание AppContainer

Создание приостановленного процесса начинается со сборки списка возможностей: копируются возможности из политики, добавляется строка AgenticAppContainer и вызывается добавление сетевых возможностей по умолчанию. Для каждой возможности выводится SID через DeriveCapabilitySidsFromName, берётся первый capability SID, остальные освобождаются. Полученные SID складываются в массив с атрибутом SE_GROUP_ENABLED, а владеющие ими объекты сохраняются, чтобы жить до вызова создания процесса. Затем формируется структура возможностей безопасности с SID контейнера и указателем на массив.

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

Атрибуты добавляются строго по порядку:

  1. Возможности безопасности.
  2. Политика всех пакетов приложений со значением отказа — только в режиме наименьших привилегий.
  3. Политика митигаций со значением отключения Win32k — только при отключённом UI.
  4. Список хэндлов — в режиме пайпов.

В режиме пайпов хэндлы stdio либо создаются, либо берутся через GetStdHandle и помечаются наследуемыми. Затем заполняется структура запуска с рабочим столом winsta0\default и флагом использования стандартных хэндлов в режиме пайпов, строится командная строка, рабочая директория и блок окружения, и вызывается создание процесса с флагами расширенной структуры запуска, приостановленного создания и юникодного окружения, с наследованием хэндлов только в режиме пайпов.

После создания процесса дочерние концы пайпов закрываются, иначе читающие концы никогда не увидят конец файла. Поскольку процесс создан приостановленным, к нему присоединяется Job Object с UI-ограничениями; при ошибке процесс завершается.

Tier 2: режимы файловой системы

Режим файловой системы выбирает, как применяется файловая политика. Вариант BFS (по умолчанию) настраивает политику через bfscfg.exe. Вариант DACL пропускает настройку BFS, потому что вызывающая сторона уже применила файловую политику через расширение DACL хоста — это путь Tier 3.

Метод определения уровня изоляции переводит BFS в уровень AppContainer с BFS, а DACL — в уровень AppContainer с DACL. Конструктор с явным режимом используется диспетчером, чтобы отключить настройку BFS внутри раннера для пути Tier 3. Конструктор с режимом и строкой SID делает то же, но дополнительно сохраняет заранее выведенную строку SID, чтобы избежать повторного преобразования SID в строку, когда диспетчер уже вывел её для нацеливания ACE.

При подготовке путь к bfscfg.exe разрешается только в режиме BFS, и только в этом режиме вызывается настройка BFS.

Tier 2: что отвергает проверка политики

В BFS-режиме проверка отвергает непустой список запрещённых путей, если режим не DACL и пути не обеспечиваются извне. В DACL-режиме то же условие ложно, поэтому запрещённые пути принимаются. В варианте с внешним обеспечением путей условие тоже ложно, поэтому BFS-режим их принимает.

Над сетевыми правилами выполняются отдельные проверки. Идентичность peer прокси отвергается как неподдерживаемая. Host loopback со значением allow отвергается с сообщением про network.ingress.hostLoopback. Комбинация ingress по умолчанию allow вместе с отсутствием разрешённого egress отвергается из-за того, что разрешение private-network доступа двунаправленно. Egress-правила allow и deny с непустыми списками отвергаются с сообщением про allow/deny rules. При этом направленный default-deny (ingress и egress по умолчанию deny) и обоюдный allow (ingress и egress по умолчанию allow) принимаются.

Tier 3: построение DACL

Построение DACL только с запретами возвращает отсутствие результата, если список запретов пуст; иначе создаётся менеджер DACL и штампуются только deny-ACE. Построение DACL для Tier 3 всегда создаёт менеджер, сначала штампует grant-ACE, затем, если список запретов непуст, добавляет deny-ACE. Если grant прошёл, а deny упал, деструктор менеджера откатывает grant.

Фильтрация путей, нуждающихся в grant, отсеивает пути, для которых well-known SID AppContainer уже дают нужную маску. В Tier 3 запрещённые пути не фильтруются, потому что deny-ACE вычитают доступ, чего well-known grants сделать не могут.

При ошибке DACL менеджер оборачивается вместе с его предупреждениями в ошибку диспетчеризации — так захватываются предупреждения на момент применения, а не накопленные при откате.

Tier 3: можно ли добавить ACE на путь

Проверка пригодности пути для AppContainer сначала пробует проверить наличие права записи DACL: при успехе путь сразу пригоден. При ошибке путь пропускается, если существующий DACL уже даёт нужную маску well-known SID AppContainer; иначе ошибка возвращается вызывающему. Отказ в праве записи DACL превращается в ошибку с причиной ERROR_ACCESS_DENIED.

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

Для путей с правом записи проверяется маска чтения-записи, для путей только для чтения — маска чтения. Для запрещённых путей всегда требуется право записи DACL, поскольку well-known grants не помогают вычитать доступ.

Затем добавляются предупреждения о подготовке хоста. Если корень системного диска не подготовлен, добавляется предупреждение о том, что процессы AppContainer могут не прочитать метаданные корня системного диска — например, при запуске cmd.exe, pwsh.exe, node.exe. Если устройство NUL не подготовлено, добавляется предупреждение о том, что процессы AppContainer могут не открыть его. Подготовка корня системного диска проверяет наличие разрешающего ACE с маской статистики и нулевыми флагами наследования для каждого из SID S-1-15-2-1 и S-1-15-2-2; SID Everyone исключён. Подготовка устройства NUL возвращает результат проверки его доступности для AppContainer, при ошибке — ложь. Проверка наличия grants у AppContainer возвращает истину, только если эффективный доступ покрывает нужную маску.

Жизненный цикл: от приостановленного создания до возобновления

Инициализация создаёт SID AppContainer по имени контейнера (или CLI, если идентификатор контейнера пуст) и сохраняет SID и имя. Получение principal id возвращает строку SID для настройки прокси и диагностики: сначала отдаётся заранее заданная строка, иначе SID конвертируется, а при нулевом SID или ошибке возвращается unknown-sid.

Создание приостановленного процесса возвращает объект, владеющий хэндлами процесса, потока, job и родительскими концами пайпов; возобновление вызывающий выполняет сам.

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

Дочерний процесс остаётся приостановленным, потому что guarded WPR-сессия стартует только после того, как job создан, UI-ограничения применены и всё ещё приостановленный ребёнок назначен в job, но до вызова возобновления. Такое упорядочение гарантирует, что сбой старта guardian может завершить всё ещё приостановленного ребёнка, не оставив активной host-wide WPR-трассировки.

Завершение процесса и teardown

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

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

Ожидание закрывает stdin и параллельно запускает отбрасывание потоков stdout и stderr, чтобы ребёнок не заблокировался на полном буфере пайпа, затем ждёт с заданным таймаутом. При завершении объекта читается код выхода: если запрошен таймаут, возвращается результат таймаута, иначе логируется событие выхода и при аудите пишется запись с кодом выхода. Неблокирующая проверка использует нулевой таймаут: при завершении объекта читает код и при запрошенном таймауте возвращает ошибку таймаута, иначе код; при таймауте ожидания возвращает отсутствие результата.

При неудачном завершении в деструкторе вызывается освобождение guarded capture после сбоя завершения: сессия забирается и передаётся в функцию освобождения, которая блокируется до освобождения дубликата хэндла job elevated guardian даже при сбое отбрасывания, а ошибка пишется в stderr.

Очистка сначала останавливает per-run прокси, затем решает, нужно ли удалять файловую политику BFS: она удаляется только если режим BFS, менеджер сконфигурирован и сохранение политики выключено.

Статус очистки вычисляется так: при сохранении политики — пропуск с причиной сохранения политики; иначе, если BFS не запрашивался или был успешно удалён — успех, а если запрашивался, но не удалён — сбой. При наличии диагностического приёмника пишется аудит-событие о разборке песочницы с полями об удалении BFS, остановке прокси, сохранении политики, освобождении контейнера и, при наличии, причиной пропуска.

Строка освобождённых ресурсов формируется в фиксированном порядке: число удалённых правил брандмауэра, удаление BFS, остановка прокси, освобождение контейнера. Тест подтверждает стабильность формата.

Захват отказов и работа с ETL

Метод установки фабрики guarded capture сохраняет её в поле, тем самым включая guarded-WPR legacy-tier fallback для захвата отказов. Без этой фабрики проверка отвергает захват отказов, возвращая ошибку с фазой недоступности бэкенда.

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

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

Удаление управляемого пути захвата удаляет сам файл, а затем пытается удалить каталог, считая отсутствие файла успехом. При провале seal вызывается финализация сбоя seal, которая комбинирует ошибку захвата с удалением управляемого пути и не добавляет пометку о сохранённом ETL.

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

Реестровый учёт песочниц

Учёт ведётся в реестре под базовым путём для маппингов песочниц процессов, где на каждый SID создаётся подключ со значениями идентичности, SID контейнера, флага уничтожения при выходе, времени создания и волатильным подразделом Active.

Генерация идентичности песочницы возвращает строку вида sandbox- плюс 16 шестнадцатеричных цифр, получая 8 байт случайности и форматируя их как 64-битное число в шестнадцатеричном виде.

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

Удаление учётного ключа удаляет подключ целиком. Создание подключа используется при отметке отложенной очистки для открытия или создания подключа и записи значения с причиной отложенной очистки.

Регистрация очистки по Ctrl+C кладёт в глобальный слот структуру с идентичностью, строкой SID и флагом прокси и вызывает установку обработчика. Снятие регистрации просто обнуляет слот, если он уже инициализирован. Установка обработчика однократно через OnceLock вызывает SetConsoleCtrlHandler, а сам обработчик при событиях Ctrl+C или закрытия консоли забирает значение из слота и передаёт управление обработчику по умолчанию.

Пустой SID никогда не адресует общий tracking root, потому что и отметка отложенной очистки, и удаление учётного ключа явно проверяют пустоту строки SID и возвращаются, не формируя путь: в первом случае пишется предупреждение о том, что очистку не удалось отследить из-за отсутствия SID AppContainer, во втором — предупреждение об отказе удалять учёт песочницы.

Наблюдаемость и диагностика

Классификация возможности learning mode сопоставляет имя без учёта регистра, потому что Windows выводит SID capability без учёта регистра, и потому неверно набранное имя всё равно вступает в силу и должно классифицироваться так же.

Диагностика эффективного learning mode проходит по всем возможностям, выставляя флаги learning и permissive. При наличии permissive возвращается security warning — либо о том, что permissive переопределяет learning, если learning тоже задан, либо о permissive learning mode. При одном лишь learning возвращается информационное сообщение, иначе — ничего.

Различие deny/record и permissive задано явно: deny-and-record безопасен и даёт информационное сообщение, а permissive логирует и разрешает все проверки доступа, ослабляя deny-by-default, поэтому даёт security warning. Соответственно security warning пишется через канал предупреждений, а информационное сообщение — через обычный лог.

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

Что из этого следует на практике

  • Уровень изоляции определяется не только политикой, но и состоянием хоста: доступностью BaseContainer API, поддержкой файловых и сетевых возможностей, наличием bfscfg.exe и тем, как собрана библиотека. Один и тот же запрос на разных машинах может получить разный уровень.
  • Переход на более высокий уровень не всегда возможен: запросы перечисления путей и host loopback при неспособности BaseContainer обслужить запрос дают ошибку, а не деградацию.
  • Признак деградации срабатывает не только при уходе с BaseContainer, но и при необходимости расширения DACL или при любой накопленной причине — то есть даже успешный запуск может считаться деградировавшим.
  • Принудительный выбор уровня через переменную окружения доступен только в тестовых сборках или при специальной фиче; в обычных сборках эта ветка не компилируется, а невалидное значение молча приводит к обычной цепочке проб.
  • При активном обходном пути для прокси host loopback не требует версии PSEC 1.1, но перечисление путей всё равно её требует — эти два требования независимы.
  • Блок окружения различает отсутствие env, явно пустое env и наследование по умолчанию: пустое env даёт пустой блок, а не блок по умолчанию, а дословная передача не добавляет даже обязательных для Windows переменных, и разреженное дословное окружение отвергается до проб хоста.
  • Приостановленное создание процесса и старт guarded WPR-сессии упорядочены так, чтобы сбой guardian мог завершить ещё приостановленного ребёнка, не оставив активной host-wide трассировки.
  • Завершение убивает всё дерево процессов через job object; при наличии сессии захвата опустошение job обязательно, иначе ошибка жёсткая.
  • Сохранение ETL возможно только если провайдер guarded capture умеет передавать трассировку; иначе проверка отвергает запрос.
  • Пустой SID AppContainer никогда не приводит к обращению к общему tracking root — учёт и удаление просто пропускаются с предупреждением.

Где смотреть в коде

Источники

Похожее