MAATRIX / Блог / Как seccomp отфильтровывает системные вызовы и что от этого внезапно ломается

Как seccomp отфильтровывает системные вызовы и что от этого внезапно ломается

MAATRIX

Контейнер падает с непонятным кодом возврата, а в логах — тишина или одна строка вроде «Operation not permitted» там, где раньше всё работало на голом сервере. Первая мысль — сломался сам образ. На деле часто виноват не код приложения, а seccomp: механизм ядра Linux, который решает, какие системные вызовы процессу вообще разрешено делать. Docker включает его по умолчанию для каждого контейнера, и большую часть времени вы этого не замечаете — пока не наткнётесь на программу, которой понадобился syscall, не попавший в разрешённый список.

Что такое seccomp и что именно он ограничивает

Любое действие процесса за пределами его собственной памяти — открыть файл, отправить пакет по сети, создать дочерний процесс, смонтировать файловую систему — идёт через системный вызов (syscall): процесс просит ядро сделать что-то от его имени. Ядро Linux поддерживает несколько сотен таких вызовов, от базовых read/write/open до экзотических вроде kexec_load (загрузить новое ядро прямо поверх работающего) или mount (примонтировать файловую систему).

seccomp (secure computing mode) — это фильтр на уровне ядра, который стоит между процессом и таблицей системных вызовов. Он не проверяет, что вы делаете *внутри* разрешённого вызова — современный режим seccomp-bpf умеет фильтровать и по номеру вызова, и по значениям некоторых аргументов, но базовая идея проще: у процесса есть список номеров syscall, которые ему разрешено вызывать вообще. Всё, чего нет в списке, обрывается на входе в ядро, до того как код самого вызова успеет выполниться.

Важно понимать разницу между seccomp и другими механизмами изоляции контейнера. Namespaces (подробнее — в отдельной статье) меняют то, что процесс *видит*: свой PID 1, свою сеть, свою файловую систему. Cgroups ограничивают, сколько ресурсов процессу *достанется* — отдельный разбор. seccomp — из другой категории: он ограничивает не то, что процесс видит и сколько ему выделено, а то, что он вправе *сделать* через ядро, независимо от прав пользователя внутри контейнера. Даже root внутри контейнера не обойдёт seccomp-фильтр — ограничение стоит на уровне ядра хоста, а не на уровне прав пользователя.

Профиль по умолчанию в Docker: разрешено не всё

Когда вы запускаете docker run без дополнительных флагов, движок подставляет встроенный seccomp-профиль (в коде Docker он называется default.json). Профиль устроен как список исключений: разрешено почти всё, чем пользуются обычные программы, и явно запрещены несколько десятков вызовов, которые нужны либо для администрирования ядра, либо почти никогда не требуются рядовому сервисному процессу, зато интересны для эскалации привилегий и побега из контейнера.

В эту категорию попадают, в частности:

  • mount / umount2 — монтирование и размонтирование файловых систем;
  • reboot, kexec_load, kexec_file_load — перезагрузка и загрузка нового ядра;
  • swapon / swapoff — управление разделами подкачки;
  • init_module, delete_module, finit_module — загрузка и выгрузка модулей ядра;
  • iopl, ioperm — прямой доступ к портам ввода-вывода;
  • ptrace — трассировка чужого процесса (разрешена в части случаев, если контейнеру явно выданы соответствующие capabilities);
  • personality — смена домена исполнения (используется, например, некоторыми старыми 32-битными окружениями);
  • acct — включение учёта процессов;
  • add_key, request_key, keyctl — работа с внутренним keyring ядра;
  • unshare и clone с флагами создания новых пространств имён — если процессу не нужно порождать свои собственные вложенные контейнеры, эти флаги ему просто не выдают;
  • bpf — загрузка eBPF-программ в ядро;
  • perf_event_open — низкоуровневое профилирование через счётчики производительности.

Список версионируется вместе с движком и от релиза к релизу немного меняется — не воспринимайте перечисление выше как исчерпывающую спецификацию, актуальный default.json стоит смотреть в исходниках moby/moby под вашу версию Docker. Принцип стабилен: под запретом — вызовы, которые трогают ядро, оборудование или границы изоляции сильнее, чем нужно обычному сетевому сервису, базе данных или воркеру.

--privileged в Docker одним из своих эффектов снимает seccomp-фильтрацию полностью (наравне с capabilities и AppArmor/SELinux) — это одна из причин, почему привилегированный режим так резко расширяет то, что скомпрометированный процесс сможет сделать с хостом.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Почему это резко сужает поверхность атаки

Смысл фильтра — не в том, чтобы помешать вашему коду работать, а в том, чтобы ограничить, что сможет сделать *чужой* код, если он окажется внутри процесса. Представьте типичный сценарий: уязвимость в парсере или зависимости позволяет злоумышленнику выполнить произвольный код в контексте вашего процесса. Дальше он обычно пытается закрепиться и расширить доступ — а для большинства техник эскалации нужны конкретные системные вызовы: смонтировать что-то с обходом ограничений, загрузить модуль ядра, вызвать ptrace на соседний процесс, поиграть с unshare, чтобы вырваться из текущих пространств имён.

Если этих вызовов физически нет в разрешённом списке, эксплойт упирается не в «недостаточно прав», а в куда более жёсткую стену: ядро вообще не пропускает такой запрос дальше точки входа. Это не проверка внутри библиотеки, которую можно обойти багом в ней самой, — решение принимается на уровне обработчика syscall в ядре, до того как выполнится хоть строчка кода конкретного вызова. Скомпрометированный процесс, даже с правами root внутри контейнера, физически не может выполнить запрещённый вызов: попытка либо вернёт ошибку (EPERM / ENOSYS), либо вызовет принудительное завершение процесса сигналом SIGSYS.

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

Что видит процесс, когда вызывает запрещённый syscall

У seccomp-BPF есть несколько возможных действий на случай, если процесс всё же попытался вызвать что-то из чёрного списка, и то, какое из них применяется, определяет, как выглядит поломка снаружи:

  • SCMP_ACT_ERRNO — вызов возвращает ошибку, обычно EPERM (Operation not permitted) или ENOSYS (Function not implemented), как будто ядро никогда не поддерживало эту функцию. Процесс продолжает жить и может, если написан аккуратно, обработать ошибку и деградировать. Docker для большинства запрещённых по умолчанию вызовов использует именно этот вариант.
  • SCMP_ACT_KILL / SCMP_ACT_KILL_PROCESS — процесс немедленно убивается сигналом SIGSYS в момент попытки вызова, без шанса на graceful-обработку. Снаружи это выглядит как контейнер, падающий с кодом выхода 139 или 159 (128 + номер сигнала), без сообщения в логе приложения — оно, если и есть, попадает не в stdout процесса, а в dmesg хоста.
  • SCMP_ACT_TRAP — процессу доставляется SIGSYS, который можно перехватить обработчиком сигнала; используется реже, в основном специализированными рантаймами.

Практическое следствие: если контейнер падает без вменяемой диагностики в собственных логах, а docker inspect или docker events показывают, что процесс убит сигналом, а не штатно завершился, — стоит проверить dmesg на хосте на предмет строк, упоминающих seccomp и номер убитого syscall, и заглянуть в journal, если в системе включён auditd:

dmesg | grep -i seccomp
journalctl -k --since "10 minutes ago" | grep -i seccomp

Если auditd настроен на аудит seccomp-событий, там же можно увидеть номер syscall (syscall=N), который был заблокирован, — а по номеру уже искать имя вызова в таблице /usr/include/asm/unistd_64.h или в ausyscall N --exact на системах, где есть пакет audit.

Обратная сторона: когда легитимная программа ломается

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

  • Отладочные и трассирующие инструменты. strace, gdb, профилировщики на ptrace могут не заработать в контейнере с дефолтным профилем, даже если вы добавили capability SYS_PTRACE — сам syscall всё ещё должен быть отдельно разрешён seccomp-профилем.
  • Браузеры и headless-рендереры со своей внутренней песочницей. Chromium и всё построенное на нём (headless-скрапинг, генерация PDF, e2e-тесты в CI) пытается создавать собственные вложенные пространства имён через unshare/clone для своего sandboxing-слоя. С ограниченным seccomp-профилем это часто ломается — обходной путь: запускать с флагом отключения внутренней песочницы либо явно ослаблять seccomp-профиль контейнера.
  • Инструменты бэкапа и работы с файловыми системами, которым нужно временно смонтировать loop-устройство или снапшот LVM изнутри контейнера, — упираются в запрет на mount/umount2.
  • Наблюдаемость через eBPF — агенты, ставящие пробы через bpf() прямо из контейнера, а не через привилегированный процесс рядом, не смогут загрузить программу в ядро без явного разрешения этого вызова.
  • Legacy и 32-битный софт, дёргающий personality() для смены домена исполнения, — редкий, но реальный случай в старых окружениях, перенесённых в контейнер почти без изменений.
  • Инструменты, работающие с kernel keyring (keyctl, add_key) — некоторые реализации Kerberos-тикетов и шифрования на уровне файловой системы полагаются на них.

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

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

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

docker run --security-opt seccomp=unconfined myimage:tag

Если контейнер после этого заработал — гипотеза подтверждена. Дальше нужно понять, какой именно syscall упирается в запрет, и для этого удобно перехватить попытку через strace:

docker run --security-opt seccomp=unconfined --cap-add SYS_PTRACE \
  myimage:tag strace -f -o /tmp/trace.log -e trace=all ./your_binary

В логе будет видно последний вызов перед сбоем (или тот, что раньше падал с EPERM/ENOSYS при включённом дефолтном профиле). Дальше — выбор из нескольких вариантов, от самого узкого к самому широкому:

  1. Собрать собственный seccomp-профиль на основе default.json, добавив в список разрешённых ровно тот syscall, которого не хватает. Это самый аккуратный путь: вы сохраняете всю остальную фильтрацию, расширяя список точечно.
{
  "defaultAction": "SCMP_ACT_ERRNO",
  "archMap": [{"architecture": "SCMP_ARCH_X86_64", "subArchitectures": ["SCMP_ARCH_X86", "SCMP_ARCH_X32"]}],
  "syscalls": [
    {
      "names": ["your_missing_syscall"],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

Такой минимальный файл на практике неудобен — проще взять полный default.json из репозитория Docker и точечно дописать нужный вызов в существующий блок SCMP_ACT_ALLOW. Применяется профиль флагом:

docker run --security-opt seccomp=/path/to/custom-profile.json myimage:tag
  1. Снять фильтрацию полностью для конкретного контейнера (seccomp=unconfined) — быстро, но откатывает вас к состоянию без этого слоя защиты. Разумно как временная мера на этапе диагностики или для контейнеров с и так широкими привилегиями (например, изолированный CI-раннер), но не как постоянное решение для сервиса, торчащего наружу.
  1. Пересмотреть, нужен ли программе именно этот вызов. Иногда причина не в самой задаче, а в конкретном флаге запуска — например, у Chromium есть штатный флаг отключения внутренней песочницы, убирающий саму потребность в unshare.

В связке с этим стоит помнить и про capabilities — соседний слой ограничений, который иногда путают с seccomp, хотя работают они по-разному: seccomp режет сами номера вызовов, а capabilities — права на конкретные привилегированные операции внутри разрешённых вызовов. Даже root внутри контейнера физически ограничен обоими механизмами одновременно, и путать их не стоит. Общая рекомендация по безопасности контейнеров, включая работу с обоими механизмами, собрана в практиках по Docker-безопасности.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно ли просто всегда запускать контейнеры с seccomp=unconfined, чтобы не разбираться с этим?

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

Seccomp защищает от всех атак на контейнер?

Нет, это один слой из нескольких: namespaces ограничивают видимость, cgroups — ресурсы, capabilities — конкретные привилегированные операции, seccomp — сами вызовы. Каждый слой закрывает свою часть поверхности атаки, и полагаться только на один из них не стоит.

Как понять, что контейнер упал именно из-за seccomp, а не по другой причине?

Смотрите код выхода контейнера (docker inspect, поле .State.ExitCode) — завершение сигналом SIGSYS часто даёт код 159, — и проверяйте dmesg/journalctl -k на хосте на строки с упоминанием seccomp рядом по времени с падением.

Работает ли seccomp одинаково в Docker и в Kubernetes?

Принцип тот же — фильтр ставится на уровне ядра для процессов в контейнере, но управление профилями в Kubernetes идёт через securityContext.seccompProfile на уровне пода или контейнера, а не через флаг docker run. Итоговое поведение зависит от конкретного container runtime (containerd, CRI-O) и того, какой профиль он подставляет по умолчанию.

Нужно ли что-то менять в seccomp, если контейнер запускается не от root?

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

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →