ЯдроКодаподготовка к экзаменам
Научная библиотека

Загружаем научный разбор

Подготавливаем текст, источники и редакционные примечания без изменения разметки страницы.

Каталог статейМатериал и источники

Confine: как найти нужные системные вызовы и не сломать редкий сценарий

Автор: · Обновлено

Автоматическое построение seccomp-политик в Confine: статический анализ, неполнота трассировки и собственная модель достижимости с проверкой сценария восстановления.

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

Эта разница лежит в центре задачи автоматического ограничения системных вызовов. Хотелось бы уменьшить доступный интерфейс ядра и сохранить необходимые пути выполнения, включая обработку ошибок. Мы рассмотрим подход Confine и на собственном графе покажем, почему полнота входного описания важнее короткого списка разрешений.

Исследовательский вопрос и метод Confine

Ghavamnia и соавторы, RAID 2020 предложили получать ограничивающие политики для контейнеров с помощью статического анализа приложения и его зависимостей. Анализ ищет множество потенциально необходимых системных вызовов, после чего формируется политика Seccomp. В отличие от одного наблюдаемого исполнения, такой подход стремится учитывать пути, которые ещё не выполнялись.

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

Откуда берётся поверхность доступа

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

Seccomp ограничивает доступные системные вызовы и может учитывать их аргументы. В Docker стандартный профиль применяется по умолчанию, если его не переопределить; формат и способы подключения описаны в официальной документации. В этом разборе мы не создаём профиль для реальной системы: учебный Python-граф не знает загрузчик, libc, потоки и фактическое поведение интерпретатора.

Для рассуждения полезно различать три множества. Первое — вызовы, увиденные в конкретной трассе. Второе — вызовы, которые потенциально нужны всем допустимым сценариям. Третье — вызовы, разрешённые политикой. Для работоспособности второе должно помещаться в третье. Наблюдаемая трасса обычно даёт лишь часть второго; из отсутствия события в журнале нельзя вывести невозможность этого события.

Собственный граф: обычный запрос и восстановление

Построим вымышленный сервис с обычной обработкой, восстановлением файла и недостижимым административным модулем. В графе вершинами служат условные функции, а специальные вершины syscall обозначают обращения к ядру. Названия вызовов выбраны для иллюстрации множеств, а не как полный профиль какого-либо сервера. Код запускается на Python 3 без контейнера и ничего не запрещает системе.

graph = {
    'main': {'serve', 'recover'},
    'serve': {'syscall:read', 'syscall:write', 'serve'},
    'recover': {'syscall:fsync', 'syscall:renameat2'},
    'admin': {'syscall:mount'},
}

def reachable_syscalls(roots):
    pending = list(roots)
    visited = set()
    calls = set()
    while pending:
        node = pending.pop()
        if node in visited:
            continue
        visited.add(node)
        if node.startswith('syscall:'):
            calls.add(node.split(':', 1)[1])
        else:
            pending.extend(graph[node])
    return calls

observed = {'read', 'write'}
recovery = {'fsync', 'renameat2'}
policy = reachable_syscalls({'main'})
assert recovery - observed == {'fsync', 'renameat2'}
assert recovery <= policy
assert 'mount' not in policy
assert 'mount' in reachable_syscalls({'main', 'admin'})
print('missed by trace:', sorted(recovery - observed))
print('allowed by graph:', sorted(policy))

Первая строка вывода перечисляет fsync и renameat2, которые нужны нашему восстановлению, но отсутствуют в обычной трассе. Вторая перечисляет четыре вызова: fsync, read, renameat2, write. Самоссылка serve проверяет, что обход останавливается на цикле. Административный mount не включён, пока admin не объявлен самостоятельной точкой входа.

Последнее утверждение особенно важно. Если оператор добавляет периодический запуск admin, прежний анализ становится неполным без единого изменения main. Анализатор не может вывести существование неизвестной ему точки входа из пустоты. В настоящем приложении аналогичный вопрос возникает для подключаемых модулей, дочерних программ и команд обслуживания.

Полнота графа и точность графа — разные свойства

Наша процедура точно обходит переданный граф. Но точность обхода не доказывает, что граф верно описывает программу. Мы вручную добавили ребро main → recover. Если забыть его, алгоритм честно вернёт неполный ответ. Если добавить лишнее ребро main → admin, он разрешит лишний вызов. Это две разные ошибки: пропуск необходимого поведения и избыточное разрешение.

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

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

Почему меньше вызовов не означает известный процент безопасности

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

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

Здесь полезно формулировать вывод узко: «В выбранной конфигурации политика запрещает такой-то путь обращения». Утверждение «контейнер безопасен» требует намного более полной модели угроз. Даже если известные уязвимости, связанные с некоторыми запрещёнными вызовами, становятся недостижимыми, из этого не следует отсутствие других путей и ещё неизвестных дефектов.

Как проверять изменение собственного сервиса

При изменении базового образа или runtime заново проверьте начальную загрузку, штатные запросы, ротацию журналов, DNS, ошибки сети, нехватку места и корректную остановку. Набор зависит от приложения: для базы важны восстановление и резервное копирование, для обработчика файлов — неподдерживаемый формат и частичный ввод. Идея состоит в покрытии жизненного цикла, а не в произвольном увеличении времени одного теста.

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

Упражнение: новая точка входа

Добавьте в граф задачу backup, которая вызывает read и fsync, и включите её в корни анализа. Проверьте, изменился ли список разрешений. Затем добавьте восстановление через новую функцию, недостижимую из main, но запускаемую по расписанию. Покажите утверждением, что прежняя политика неполна. Объясните, какая информация должна попасть в описание контейнера, чтобы анализ увидел этот сценарий.

Продолжение темы находится в [треке Docker](/lessons/without-university/docker-containerization) и [уроке Linux о правах доступа](/lessons/without-university/linux-developer-foundations/linux-developer-08). После выполнения упражнения читатель должен уметь отличать наблюдавшийся вызов от потенциально необходимого и проверять границы вывода анализатора. Это более надёжная основа политики, чем идея «не встретилось в логе — значит, можно запретить».

Источники

Формат и права

Формат
Авторский разбор

Атрибуция

Самостоятельный русскоязычный разбор ЯдроКода по указанным первичным источникам. Учебные модели, примеры и выводы редакции отделены от результатов исследований. Материал не является переводом или перепечаткой.

Код, данные и иллюстрации

Текст, учебные данные и Python-примеры созданы для этой публикации. Чужие программные реализации, таблицы, схемы и иллюстрации не воспроизводятся.