Главная/Security/Урок

12.5. Seccomp, AppArmor, SELinux

Цели

После этого материала вы сможете:

  • назвать, что блокирует профиль seccomp по умолчанию и почему именно это;
  • написать собственный профиль seccomp и понимать цену его сопровождения;
  • диагностировать блокировку: определить, какой вызов или путь запрещён;
  • различить, какой из трёх механизмов сработал в конкретном случае;
  • объяснить назначение :z и :Z при монтировании на системах с SELinux;
  • объяснить, что даёт no-new-privileges и почему его цена близка к нулю;
  • решить, нужен ли вам собственный профиль, — обоснованно.

Предварительные знания

Ключевые термины

ТерминОбъяснение
seccompФильтр системных вызовов на уровне процесса
BPFПрограмма фильтрации, исполняемая ядром
MACМандатный контроль доступа: правила задаёт система, не владелец
AppArmorMAC на основе путей файловой системы
SELinuxMAC на основе меток объектов
контекстМетка SELinux вида system_u:object_r:тип:s0

Теория

Три механизма и их различия

seccompAppArmorSELinux
Что ограничиваетСистемные вызовыДоступ к путям и возможностямДоступ к объектам по меткам
Основа правилНомер вызова и аргументыПуть файлаМетка (тип) объекта
Где применяетсяВездеDebian, Ubuntu, SUSERHEL, Fedora, CentOS
Профиль Docker по умолчаниюdefaultdocker-defaultЕсть, но требует настройки
Сложность своего профиляСредняяСредняяВысокая
Диагностикаstrace, dmesgdmesg, aa-statusausearch, audit.log

Они дополняют друг друга: seccomp сокращает набор достижимых обработчиков ядра, AppArmor и SELinux ограничивают доступ к объектам.

Профиль seccomp по умолчанию

Docker применяет его автоматически ко всем container'ам. Он работает по принципу белого списка: разрешены перечисленные вызовы, остальные возвращают EPERM.

Что блокируется и почему:

КатегорияПримеры вызововПочему заблокировано
Управление модулями ядраinit_module, delete_module, finit_moduleВыполнение кода в ядре
Работа с ядром напрямуюkexec_load, rebootЗамена ядра, перезагрузка host
Пространство имёнunshare частично, setnsПобег через смену namespace
Отладкаptrace (условно)Чтение памяти соседей
Устройстваmknod частичноСоздание доступа к дискам
Управление часамиclock_settime, settimeofdayЧасы общие с host
Экспериментальныеbpf, perf_event_openИзвестные векторы эскалации
Учётacct, quotactlВлияние на host

Всего блокируется около 44 вызовов из более чем 300.

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

Свой профиль seccomp

json
{
  "defaultAction": "SCMP_ACT_ERRNO",
  "defaultErrnoRet": 1,
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [
    {
      "names": ["read", "write", "openat", "close", "fstat", "exit_group"],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}
bash
docker run --security-opt seccomp=./profile.json ...
ДействиеЧто делает
SCMP_ACT_ALLOWРазрешить
SCMP_ACT_ERRNOВернуть ошибку (по умолчанию EPERM)
SCMP_ACT_KILLЗавершить процесс
SCMP_ACT_LOGРазрешить и записать в журнал
SCMP_ACT_TRACEПередать трассировщику

Действие SCMP_ACT_LOG бесценно при разработке профиля: оно позволяет собрать список используемых вызовов, ничего не ломая.

Цена собственного профиля высока. Набор используемых вызовов меняется при:

СобытиеЧто может добавиться
Обновление PythonНовые вызовы в стандартной библиотеке
Обновление glibcclone3 вместо clone, openat2 вместо openat
Новая зависимостьЧто угодно
Смена базового образаДругая libc — другой набор

Известный пример: переход glibc на clone3 ломал container'ы со старыми профилями, включая профиль Docker до его обновления.

Отсюда рекомендация: свой профиль оправдан при конкретной угрозе, а не «на всякий случай» (урок 12.1).

AppArmor

В Ubuntu и Debian Docker применяет профиль docker-default автоматически. Он ограничивает:

ЧтоПример
Запись в /procКроме собственных подкаталогов процесса
Доступ к /sysБольшинство путей только на чтение
МонтированиеЗапрещено
Загрузка профилей AppArmorЗапрещена
Прямой доступ к устройствамОграничен

Свой профиль:

bash
docker run --security-opt apparmor=my-profile ...
docker run --security-opt apparmor=unconfined ...   # отключение

Профиль должен быть предварительно загружен в ядро через apparmor_parser.

SELinux

В RHEL-подобных системах Docker помечает процессы container'ов типом container_t, а их файлы — container_file_t.

Практическое следствие для монтирования (урок 7.3):

СуффиксЧто делает
:zОбщая метка: каталог доступен нескольким container'ам
:ZЧастная метка: только этому container'у
без суффиксаМетка не меняется — вероятен Permission denied
bash
docker run -v /srv/data:/data:Z ...

Предупреждение: суффикс меняет метки рекурсивно и необратимо. Применение :Z к /home или /usr нарушит работу системы.

no-new-privileges

Четвёртый механизм, стоящий рядом с тремя предыдущими и почти ничего не стоящий:

bash
docker run --security-opt no-new-privileges ...

Он устанавливает флаг ядра PR_SET_NO_NEW_PRIVS, после которого процесс не может получить привилегии через execve. Конкретно перестают действовать:

МеханизмЧто переставало бы работать
setuid-бит на файлеsu, sudo, passwd, mount из образа
setgid-битСмена группы при запуске
File capabilitiesCapabilities, назначенные бинарнику

Флаг наследуется всеми потомками и не может быть снят — как и фильтр seccomp.

Практический смысл: если атакующий получил выполнение кода от непривилегированного пользователя внутри container'а, он не сможет повысить привилегии через setuid-программу из образа. А такие программы есть почти в каждом базовом образе: /bin/su, /usr/bin/passwd, /bin/mount.

Цена — близка к нулю. Приложению, не использующему su или sudo, флаг ничего не ломает. Поэтому в отличие от собственного профиля seccomp его включают всегда (урок 11.2).

Единственное исключение — образы, где точка входа сама переключает пользователя через gosu или su-exec. Такие entrypoint'ы работают через setuid и с этим флагом ломаются. Правильный ответ — не отключать флаг, а задать USER в Dockerfile (урок 12.3).

Диагностика блокировки

Симптом одинаков — Permission denied или Operation not permitted. Источников три, и различать их нужно по-разному.

МеханизмГде искать следПризнак
Отсутствие capabilityНигдеТихий EPERM
seccompdmesg при SCMP_ACT_LOGEPERM от разрешённого по правам вызова
AppArmordmesg, journalctlСтрока apparmor="DENIED"
SELinux/var/log/audit/audit.logСтрока avc: denied

Порядок диагностики:

text
1. Запустить с --cap-add нужной capability
       ├── заработало? → дело было в capabilities
       └── нет →
2. Запустить с --security-opt seccomp=unconfined  (ВРЕМЕННО, для диагностики)
       ├── заработало? → дело в seccomp
       └── нет →
3. Запустить с --security-opt apparmor=unconfined  (ВРЕМЕННО)
       ├── заработало? → дело в AppArmor
       └── нет → SELinux или логика приложения

Отключения на шагах 2 и 3 — только для диагностики. Обнаружив причину, возвращают защиту и решают задачу точечно.


Внутренний механизм

Как seccomp фильтрует

Профиль компилируется в программу BPF, которая исполняется ядром при каждом системном вызове. Программа получает номер вызова и аргументы, возвращает действие.

Отсюда два свойства:

Фильтр нельзя снять. После установки он действует до завершения процесса и наследуется потомками. Это и делает его надёжным.

Проверка дешёвая. Программа BPF выполняется за десятки наносекунд; накладные расходы незаметны.

Проверить состояние: grep Seccomp /proc/self/status. Значение 2 — фильтрация, 0 — фильтра нет.

Почему отказ бывает неотличим

Отсутствие capability и запрет seccomp дают один и тот же EPERM. Ядро не сообщает источник — приложение видит только код ошибки.

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


Команды и примеры

Что даёт профиль по умолчанию

bash
mkdir -p /tmp/seccomp && cd /tmp/seccomp

cat > syscheck.py <<'PY'
"""Проверка доступности системных вызовов, блокируемых seccomp."""
from __future__ import annotations

import ctypes
import ctypes.util
import json
import os
from pathlib import Path

_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)

# Номера вызовов для x86_64
SYSCALLS = {
    "init_module": 175,      # загрузка модуля ядра
    "delete_module": 176,    # выгрузка модуля
    "kexec_load": 246,       # замена ядра
    "acct": 163,             # учёт процессов
    "bpf": 321,              # программы BPF
    "perf_event_open": 298,  # счётчики производительности
    "clock_settime": 227,    # изменение часов
    "unshare": 272,          # создание namespace
}


def seccomp_status() -> str:
    for line in Path("/proc/self/status").read_text().splitlines():
        if line.startswith("Seccomp:"):
            return line.split()[1]
    return "?"


def try_syscall(number: int) -> str:
    """Вызывает с заведомо неверными аргументами: важен КОД ошибки.

    EPERM (1) при неверных аргументах означает блокировку seccomp:
    ядро отвергло вызов до проверки аргументов.
    EINVAL (22), EFAULT (14), ENOSYS (38) означают, что вызов прошёл фильтр.
    """
    ctypes.set_errno(0)
    _libc.syscall(number, 0, 0, 0, 0, 0, 0)
    err = ctypes.get_errno()
    names = {1: "EPERM (заблокирован)", 14: "EFAULT (прошёл фильтр)",
             22: "EINVAL (прошёл фильтр)", 38: "ENOSYS (нет в ядре)",
             13: "EACCES", 0: "успех"}
    return names.get(err, f"errno {err}")


if __name__ == "__main__":
    print(json.dumps({
        "seccomp": seccomp_status(),
        "расшифровка": {"0": "фильтра нет", "2": "фильтрация активна"}.get(
            seccomp_status(), "?"),
        "вызовы": {name: try_syscall(num) for name, num in SYSCALLS.items()},
    }, ensure_ascii=False, indent=2))
PY

echo "═══ профиль по умолчанию ═══"
docker run --rm -v "$PWD/syscheck.py:/s.py:ro" python:3.13-slim python /s.py \
    | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"  Seccomp: {d['seccomp']} ({d['расшифровка']})\")
for name, res in d['вызовы'].items():
    mark = '✗' if 'заблокирован' in res else '·'
    print(f'  {mark} {name:<18} {res}')
"

Ожидаемый вывод:

text
═══ профиль по умолчанию ═══
  Seccomp: 2 (фильтрация активна)
  ✗ init_module        EPERM (заблокирован)
  ✗ delete_module      EPERM (заблокирован)
  ✗ kexec_load         EPERM (заблокирован)
  ✗ acct               EPERM (заблокирован)
  ✗ bpf                EPERM (заблокирован)
  ✗ perf_event_open    EPERM (заблокирован)
  · clock_settime      EPERM (заблокирован)
  · unshare            EINVAL (прошёл фильтр)

Шесть вызовов заблокированы фильтром. unshare прошёл — он разрешён профилем, но ограничен другими механизмами.

clock_settime показывает неоднозначность: EPERM мог прийти и от seccomp, и от отсутствия SYS_TIME. Различить по коду ошибки невозможно.

Что происходит без профиля

bash
cd /tmp/seccomp
echo "═══ seccomp=unconfined (для сравнения, НЕ для эксплуатации) ═══"
docker run --rm --security-opt seccomp=unconfined \
    -v "$PWD/syscheck.py:/s.py:ro" python:3.13-slim python /s.py \
    | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"  Seccomp: {d['seccomp']} ({d['расшифровка']})\")
blocked = sum(1 for v in d['вызовы'].values() if 'заблокирован' in v)
print(f'  заблокировано вызовов: {blocked} из {len(d[\"вызовы\"])}')
for name, res in d['вызовы'].items():
    mark = '✗' if 'заблокирован' in res else '·'
    print(f'  {mark} {name:<18} {res}')
"

echo "═══ вывод ═══"
cat <<'TXT'
  Без профиля вызовы доходят до ядра и возвращают ошибки аргументов
  (EFAULT, EINVAL) вместо EPERM. Это означает, что ОБРАБОТЧИК ВЫЗОВА
  в ядре был достигнут — то есть его уязвимость стала доступной.

  Именно это сокращает seccomp: не сами операции, а поверхность
  соприкосновения с ядром.
TXT

Ожидаемый вывод:

text
═══ seccomp=unconfined (для сравнения, НЕ для эксплуатации) ═══
  Seccomp: 0 (фильтра нет)
  заблокировано вызовов: 1 из 8
  · init_module        EFAULT (прошёл фильтр)
  · delete_module      EFAULT (прошёл фильтр)
  · kexec_load         EPERM (заблокирован)
  · acct               EFAULT (прошёл фильтр)
  · bpf                EINVAL (прошёл фильтр)
  · perf_event_open    EFAULT (прошёл фильтр)
  · clock_settime      EPERM (заблокирован)
  · unshare            EINVAL (прошёл фильтр)

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

Оставшиеся два EPERM приходят от capabilities: kexec_load требует SYS_BOOT, clock_settimeSYS_TIME. Это и показывает, что механизмы независимы.

Свой профиль: минимальный белый список

bash
cd /tmp/seccomp
cat > minimal.json <<'EOF'
{
  "defaultAction": "SCMP_ACT_ERRNO",
  "defaultErrnoRet": 1,
  "archMap": [
    {
      "architecture": "SCMP_ARCH_X86_64",
      "subArchitectures": ["SCMP_ARCH_X86", "SCMP_ARCH_X32"]
    }
  ],
  "syscalls": [
    {
      "names": [
        "access", "arch_prctl", "brk", "close", "execve", "exit", "exit_group",
        "fstat", "futex", "getdents64", "getpid", "getrandom", "ioctl",
        "lseek", "mmap", "mprotect", "munmap", "newfstatat", "openat",
        "pread64", "prlimit64", "read", "readlink", "rseq", "rt_sigaction",
        "rt_sigprocmask", "set_robust_list", "set_tid_address", "statfs",
        "write", "getcwd", "dup", "dup2", "fcntl", "geteuid", "getuid",
        "getgid", "getegid", "sysinfo", "uname", "clone", "clone3", "wait4",
        "pipe2", "sigaltstack", "madvise", "epoll_create1", "faccessat2"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}
EOF

echo "═══ профиль составлен ═══"
python3 -c "
import json
p = json.load(open('minimal.json'))
print(f\"  действие по умолчанию: {p['defaultAction']}\")
print(f\"  разрешено вызовов:     {len(p['syscalls'][0]['names'])}\")
"

echo "═══ проверка: простая программа ═══"
docker run --rm --security-opt seccomp=./minimal.json \
    python:3.13-slim python -c "print('  работает под своим профилем')" 2>&1 | tail -2

echo "═══ проверка: то, что профиль не разрешает ═══"
docker run --rm --security-opt seccomp=./minimal.json \
    python:3.13-slim python -c "
import socket
try:
    socket.socket()
    print('  сокет создан')
except OSError as e:
    print(f'  создание сокета: {e.strerror} — socket нет в белом списке')
" 2>&1 | tail -2

Ожидаемый вывод:

text
═══ профиль составлен ═══
  действие по умолчанию: SCMP_ACT_ERRNO
  разрешено вызовов:     48
═══ проверка: простая программа ═══
  работает под своим профилем
═══ проверка: то, что профиль не разрешает ═══
  создание сокета: Operation not permitted — socket нет в белом списке

Программа без сети работает, сетевая — нет. Профиль делает ровно то, что описано.

Обратите внимание на список: 48 вызовов понадобилось, чтобы просто запустить интерпретатор и напечатать строку. Реальному приложению нужно больше, и составлять список вручную непрактично.

Сбор списка вызовов через SCMP_ACT_LOG

bash
cd /tmp/seccomp
cat > logging.json <<'EOF'
{
  "defaultAction": "SCMP_ACT_LOG",
  "archMap": [
    {
      "architecture": "SCMP_ARCH_X86_64",
      "subArchitectures": ["SCMP_ARCH_X86", "SCMP_ARCH_X32"]
    }
  ],
  "syscalls": []
}
EOF

echo "═══ профиль с логированием ═══"
cat <<'TXT'
  defaultAction: SCMP_ACT_LOG разрешает всё, но записывает каждый вызов
  в журнал ядра. Это даёт список фактически используемых вызовов
  без риска сломать приложение.

  Порядок работы:
    1. Запустить под профилем с SCMP_ACT_LOG
    2. Прогнать типичную нагрузку
    3. Собрать вызовы: dmesg | grep -o 'syscall=[0-9]*' | sort -u
    4. Преобразовать номера в имена
    5. Составить профиль с SCMP_ACT_ERRNO по умолчанию

  Ограничение: собранный список отражает ОДИН прогон. Редкие пути
  кода (обработка ошибок, необычные запросы) могут в него не попасть.
TXT

echo "═══ более практичный способ: strace ═══"
docker run --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined \
    python:3.13-slim sh -c '
    apt-get update -qq > /dev/null 2>&1 && apt-get install -y -qq strace > /dev/null 2>&1
    strace -c -f python -c "print(1)" 2>&1 | tail -20
' 2>/dev/null | sed 's/^/  /' | head -22

Ожидаемый вывод:

text
═══ профиль с логированием ═══
  defaultAction: SCMP_ACT_LOG разрешает всё, но записывает каждый вызов
  в журнал ядра. Это даёт список фактически используемых вызовов
  без риска сломать приложение.

  Порядок работы:
    1. Запустить под профилем с SCMP_ACT_LOG
    2. Прогнать типичную нагрузку
    3. Собрать вызовы: dmesg | grep -o 'syscall=[0-9]*' | sort -u
    4. Преобразовать номера в имена
    5. Составить профиль с SCMP_ACT_ERRNO по умолчанию

  Ограничение: собранный список отражает ОДИН прогон. Редкие пути
  кода (обработка ошибок, необычные запросы) могут в него не попасть.
═══ более практичный способ: strace ═══
  % time     seconds  usecs/call     calls    errors syscall
  ------ ----------- ----------- --------- --------- ----------------
   28.31    0.001204          16        73           mmap
   16.42    0.000698          10        68        22 openat
   12.05    0.000512           7        69           close
    9.87    0.000420           9        44           newfstatat
    8.13    0.000346          11        30           read
    ...
  ------ ----------- ----------- --------- --------- ----------------
  100.00    0.004253                    412        31 total

strace -c даёт сводку с частотой каждого вызова — удобнее для составления профиля, чем разбор журнала ядра.

Обратите внимание на строку total: 412 вызовов за запуск print(1). Это объясняет, почему белый список для реального приложения составляют инструментами, а не вручную.

Диагностика: что именно заблокировано

bash
cd /tmp/seccomp
cat > diagnose.sh <<'SH'
#!/usr/bin/env bash
# Пошаговая диагностика: какой механизм блокирует операцию.
set -uo pipefail

IMAGE="${1:-python:3.13-slim}"
shift || true
CMD=("$@")
[ "${#CMD[@]}" -eq 0 ] && CMD=(sh -c 'mount -t tmpfs tmpfs /mnt 2>&1')

run() {
    local label="$1"; shift
    local out rc
    out="$(docker run --rm "$@" "$IMAGE" "${CMD[@]}" 2>&1)"
    rc=$?
    printf '  %-38s ' "$label"
    if [ "$rc" -eq 0 ] && ! echo "$out" | grep -qi 'denied\|not permitted\|Operation not'; then
        printf 'РАБОТАЕТ\n'
        return 0
    fi
    printf '%s\n' "$(echo "$out" | tail -1 | cut -c1-40)"
    return 1
}

printf '\n  Диагностика блокировки для: %s\n\n' "${CMD[*]}"

if run "1. базовая конфигурация"; then
    printf '\n  Операция работает без изменений.\n'
    exit 0
fi

if run "2. + --cap-add=SYS_ADMIN" --cap-add=SYS_ADMIN; then
    printf '\n  ПРИЧИНА: отсутствие capability SYS_ADMIN.\n'
    printf '  Решение: --cap-drop=ALL --cap-add=SYS_ADMIN (точечно).\n'
    exit 0
fi

if run "3. + seccomp=unconfined" --security-opt seccomp=unconfined; then
    printf '\n  ПРИЧИНА: профиль seccomp.\n'
    printf '  Решение: свой профиль, разрешающий нужный вызов.\n'
    printf '  НЕ решение: seccomp=unconfined в эксплуатации.\n'
    exit 0
fi

if run "4. + apparmor=unconfined" --security-opt apparmor=unconfined; then
    printf '\n  ПРИЧИНА: профиль AppArmor.\n'
    printf '  Решение: свой профиль AppArmor.\n'
    exit 0
fi

if run "5. + оба вместе" --cap-add=SYS_ADMIN --security-opt seccomp=unconfined; then
    printf '\n  ПРИЧИНА: сочетание capability и seccomp.\n'
    printf '  Решение: точечная capability плюс профиль с нужным вызовом.\n'
    exit 0
fi

if run "6. --privileged (диагностика)" --privileged; then
    printf '\n  ПРИЧИНА: одно из ослаблений --privileged, не покрытое выше:\n'
    printf '    доступ к устройствам, /sys на запись, device cgroup.\n'
    printf '  Решение: --device для конкретного устройства.\n'
    exit 0
fi

printf '\n  Не работает даже с --privileged: причина вне механизмов изоляции\n'
printf '  (логика приложения, отсутствие файла, конфигурация).\n'
exit 1
SH
chmod +x diagnose.sh

echo "═══ диагностика: монтирование tmpfs ═══"
./diagnose.sh python:3.13-slim sh -c 'mount -t tmpfs tmpfs /mnt 2>&1 && echo ok'

Ожидаемый вывод:

text
═══ диагностика: монтирование tmpfs ═══

  Диагностика блокировки для: sh -c mount -t tmpfs tmpfs /mnt 2>&1 && echo ok

  1. базовая конфигурация                mount: permission denied (are yo
  2. + --cap-add=SYS_ADMIN               РАБОТАЕТ

  ПРИЧИНА: отсутствие capability SYS_ADMIN.
  Решение: --cap-drop=ALL --cap-add=SYS_ADMIN (точечно).

Скрипт нашёл причину со второй попытки и назвал точечное решение вместо --privileged.

Проверим случай, где виноват seccomp:

bash
cd /tmp/seccomp
echo "═══ диагностика: вызов, блокируемый seccomp ═══"
cat > tryacct.py <<'PY'
import ctypes, ctypes.util
libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
ctypes.set_errno(0)
libc.syscall(163, 0)          # acct — заблокирован профилем по умолчанию
err = ctypes.get_errno()
if err == 1:
    print("Operation not permitted")
    raise SystemExit(1)
print(f"прошёл фильтр, errno={err}")
PY

docker run --rm -v "$PWD/tryacct.py:/t.py:ro" python:3.13-slim python /t.py 2>&1 | sed 's/^/  базовая: /'
docker run --rm --cap-add=SYS_PACCT -v "$PWD/tryacct.py:/t.py:ro" \
    python:3.13-slim python /t.py 2>&1 | sed 's/^/  + SYS_PACCT: /'
docker run --rm --security-opt seccomp=unconfined -v "$PWD/tryacct.py:/t.py:ro" \
    python:3.13-slim python /t.py 2>&1 | sed 's/^/  + seccomp=unconfined: /'

echo "═══ вывод ═══"
cat <<'TXT'
  Добавление capability не помогло, снятие seccomp — помогло.
  Значит, блокировал именно фильтр системных вызовов.

  Это ровно та развилка, которую нельзя пройти по коду ошибки:
  и capability, и seccomp дают EPERM.
TXT

Ожидаемый вывод:

text
═══ диагностика: вызов, блокируемый seccomp ═══
  базовая: Operation not permitted
  + SYS_PACCT: Operation not permitted
  + seccomp=unconfined: прошёл фильтр, errno=14
═══ вывод ═══
  Добавление capability не помогло, снятие seccomp — помогло.
  Значит, блокировал именно фильтр системных вызовов.

  Это ровно та развилка, которую нельзя пройти по коду ошибки:
  и capability, и seccomp дают EPERM.

Три строки исчерпывающе показывают методику: последовательное исключение вместо угадывания.

AppArmor

bash
cd /tmp/seccomp
echo "═══ состояние AppArmor на системе ═══"
if command -v aa-status > /dev/null 2>&1; then
    sudo aa-status 2>/dev/null | head -4 | sed 's/^/  /' || \
        echo "  aa-status требует прав root"
elif [ -d /sys/kernel/security/apparmor ]; then
    echo "  AppArmor включён в ядре"
    ls /sys/kernel/security/apparmor/ 2>/dev/null | head -3 | sed 's/^/    /'
else
    echo "  AppArmor не обнаружен (вероятно, система с SELinux или без MAC)"
fi

echo "═══ профиль container'а ═══"
docker run --rm python:3.13-slim sh -c '
    if [ -r /proc/self/attr/current ]; then
        printf "  профиль: %s\n" "$(cat /proc/self/attr/current 2>/dev/null | tr -d "\0")"
    else
        echo "  /proc/self/attr/current недоступен"
    fi
' 2>/dev/null

echo "═══ что ограничивает docker-default ═══"
cat <<'TXT'
  Запись в /proc (кроме своих подкаталогов)
  Доступ к /sys — в основном только чтение
  Монтирование файловых систем
  Загрузка профилей AppArmor
  Прямой доступ к части устройств

  Отключение (ТОЛЬКО для диагностики):
    docker run --security-opt apparmor=unconfined ...

  Свой профиль:
    1. написать профиль в /etc/apparmor.d/
    2. загрузить: sudo apparmor_parser -r -W /etc/apparmor.d/my-profile
    3. применить: docker run --security-opt apparmor=my-profile ...
TXT

echo "═══ проверка ограничения записи в /sys ═══"
docker run --rm python:3.13-slim sh -c '
    touch /sys/kernel/probe 2>&1 | tail -1
' 2>/dev/null | sed 's/^/  /'

Ожидаемый вывод:

text
═══ состояние AppArmor на системе ═══
  apparmor module is loaded.
  108 profiles are loaded.
  76 profiles are in enforce mode.
═══ профиль container'а ═══
  профиль: docker-default (enforce)
═══ что ограничивает docker-default ═══
  Запись в /proc (кроме своих подкаталогов)
  Доступ к /sys — в основном только чтение
  Монтирование файловых систем
  Загрузка профилей AppArmor
  Прямой доступ к части устройств

  Отключение (ТОЛЬКО для диагностики):
    docker run --security-opt apparmor=unconfined ...

  Свой профиль:
    1. написать профиль в /etc/apparmor.d/
    2. загрузить: sudo apparmor_parser -r -W /etc/apparmor.d/my-profile
    3. применить: docker run --security-opt apparmor=my-profile ...
═══ проверка ограничения записи в /sys ═══
  touch: cannot touch '/sys/kernel/probe': Read-only file system

Строка docker-default (enforce) подтверждает, что профиль применён автоматически.

На системах без AppArmor эта строка будет пустой — механизм зависит от дистрибутива.

SELinux и метки монтирования

bash
cd /tmp/seccomp
echo "═══ состояние SELinux ═══"
if command -v getenforce > /dev/null 2>&1; then
    printf '  режим: %s\n' "$(getenforce)"
elif [ -f /sys/fs/selinux/enforce ]; then
    printf '  enforce: %s\n' "$(cat /sys/fs/selinux/enforce 2>/dev/null)"
else
    echo "  SELinux не обнаружен (система с AppArmor или без MAC)"
fi

echo "═══ контекст процесса в container ═══"
docker run --rm python:3.13-slim sh -c '
    if [ -r /proc/self/attr/current ]; then
        ctx="$(tr -d "\0" < /proc/self/attr/current)"
        printf "  %s\n" "${ctx:-(пусто)}"
    fi
' 2>/dev/null

echo "═══ зачем нужны :z и :Z ═══"
cat <<'TXT'
  На системах с SELinux каталог host имеет метку, не позволяющую
  процессу container'а (тип container_t) к нему обращаться.
  Результат — Permission denied при верных правах Unix.

  Суффиксы монтирования меняют метку:
    :z   общая (container_file_t, разделяемая) — несколько container'ов
    :Z   частная (уникальная категория) — только этот container

    docker run -v /srv/data:/data:Z ...

  ПРЕДУПРЕЖДЕНИЕ: метки меняются РЕКУРСИВНО и необратимо.
  Применение :Z к /home, /usr или /etc нарушит работу системы.

  На Ubuntu и Debian с AppArmor суффиксы игнорируются.
TXT

echo "═══ проверка на текущей системе ═══"
mkdir -p seltest && echo "данные" > seltest/file.txt
if command -v ls > /dev/null && ls -Z seltest/file.txt > /dev/null 2>&1; then
    ls -Z seltest/file.txt | sed 's/^/  до:    /'
    docker run --rm -v "$PWD/seltest:/data:Z" alpine:3.21 cat /data/file.txt > /dev/null 2>&1
    ls -Z seltest/file.txt | sed 's/^/  после: /'
else
    echo "  метки SELinux не поддерживаются файловой системой"
    docker run --rm -v "$PWD/seltest:/data" alpine:3.21 cat /data/file.txt 2>&1 | sed 's/^/  чтение без суффикса: /'
fi
rm -rf seltest

Ожидаемый вывод:

text
═══ состояние SELinux ═══
  SELinux не обнаружен (система с AppArmor или без MAC)
═══ контекст процесса в container ═══
  docker-default (enforce)
═══ зачем нужны :z и :Z ═══
  На системах с SELinux каталог host имеет метку, не позволяющую
  процессу container'а (тип container_t) к нему обращаться.
  Результат — Permission denied при верных правах Unix.

  Суффиксы монтирования меняют метку:
    :z   общая (container_file_t, разделяемая) — несколько container'ов
    :Z   частная (уникальная категория) — только этот container

    docker run -v /srv/data:/data:Z ...

  ПРЕДУПРЕЖДЕНИЕ: метки меняются РЕКУРСИВНО и необратимо.
  Применение :Z к /home, /usr или /etc нарушит работу системы.

  На Ubuntu и Debian с AppArmor суффиксы игнорируются.
═══ проверка на текущей системе ═══
  метки SELinux не поддерживаются файловой системой
  чтение без суффикса: данные

На системе с AppArmor суффиксы не нужны и чтение работает без них. На RHEL-подобной системе та же команда без :Z вернула бы Permission denied (урок 7.3).

no-new-privileges на практике

bash
cd /tmp/seccomp
echo "═══ setuid-программы в базовом образе ═══"
docker run --rm python:3.13-slim sh -c '
    find / -xdev -perm -4000 -type f 2>/dev/null | head -8
' 2>/dev/null | sed 's/^/  /'

echo "═══ повышение привилегий БЕЗ флага ═══"
docker run --rm --user 1000:1000 python:3.13-slim sh -c '
    printf "  uid до:  %s\n" "$(id -u)"
    if [ -u /bin/su ]; then
        printf "  /bin/su имеет setuid-бит: да\n"
    fi
    printf "  su доступен как механизм: %s\n" \
        "$(test -u /bin/su && echo да || echo нет)"
' 2>/dev/null

echo "═══ то же С флагом ═══"
docker run --rm --user 1000:1000 --security-opt no-new-privileges \
    python:3.13-slim sh -c '
    printf "  uid: %s\n" "$(id -u)"
    printf "  NoNewPrivs: %s\n" \
        "$(grep NoNewPrivs /proc/self/status | awk "{print \$2}")"
    printf "  setuid-бит на /bin/su по-прежнему есть: %s\n" \
        "$(test -u /bin/su && echo да || echo нет)"
    printf "  но ядро его игнорирует при execve\n"
' 2>/dev/null

echo "═══ вывод ═══"
cat <<'TXT'
  Флаг не убирает setuid-биты из образа — он делает их
  недействительными на уровне ядра.

  NoNewPrivs: 1 означает, что процесс и все его потомки
  не смогут повысить привилегии через execve. Снять флаг нельзя.

  Стоимость близка к нулю, поэтому включают всегда:
    docker run --security-opt no-new-privileges ...
TXT

Ожидаемый вывод:

text
═══ setuid-программы в базовом образе ═══
  /usr/bin/chsh
  /usr/bin/gpasswd
  /usr/bin/newgrp
  /usr/bin/chfn
  /usr/bin/passwd
  /usr/bin/su
  /usr/bin/mount
  /usr/bin/umount
═══ повышение привилегий БЕЗ флага ═══
  uid до:  1000
  /bin/su имеет setuid-бит: да
  su доступен как механизм: да
═══ то же С флагом ═══
  uid: 1000
  NoNewPrivs: 1
  setuid-бит на /bin/su по-прежнему есть: да
  но ядро его игнорирует при execve
═══ вывод ═══
  Флаг не убирает setuid-биты из образа — он делает их
  недействительными на уровне ядра.

  NoNewPrivs: 1 означает, что процесс и все его потомки
  не смогут повысить привилегии через execve. Снять флаг нельзя.

  Стоимость близка к нулю, поэтому включают всегда:
    docker run --security-opt no-new-privileges ...

Восемь setuid-программ в обычном python:3.13-slim — это восемь потенциальных путей повышения привилегий, если атакующий получил выполнение кода от непривилегированного пользователя.

Флаг закрывает их все одной строкой, ничего не ломая. По отношению эффекта к цене это лучшая мера из рассмотренных в уроке.

Стоит ли писать свой профиль

bash
cd /tmp/seccomp
cat > decide.py <<'PY'
"""Решение о необходимости собственного профиля seccomp."""
from __future__ import annotations

QUESTIONS = [
    ("Запускается ли недоверенный код?", 5,
     "Да → нужен не профиль, а gVisor/Kata"),
    ("Есть ли КОНКРЕТНАЯ угроза, не покрытая умолчанием?", 4,
     "Нет → профиль по умолчанию достаточен"),
    ("Требуется ли соответствие стандарту с таким требованием?", 3,
     "Нет → снимается один из доводов"),
    ("Готовы ли сопровождать профиль при обновлениях?", 3,
     "Нет → профиль сломается на обновлении glibc или Python"),
    ("Есть ли инструменты сбора списка вызовов?", 2,
     "Нет → составление вручную непрактично"),
]


def evaluate(answers: list[bool]) -> None:
    score = sum(w for (q, w, note), a in zip(QUESTIONS, answers) if a)
    total = sum(w for _, w, _ in QUESTIONS)

    print(f"  {'вопрос':<52} {'вес':>4}  ответ")
    print("  " + "─" * 68)
    for (question, weight, note), answer in zip(QUESTIONS, answers):
        print(f"  {question:<52} {weight:>4}  {'да' if answer else 'нет'}")
        if not answer:
            print(f"      → {note}")

    print()
    print(f"  оценка: {score} из {total}")
    if score >= 10:
        print("  ВЫВОД: свой профиль оправдан")
    elif score >= 6:
        print("  ВЫВОД: рассмотреть; сначала убедиться в наличии инструментов")
    else:
        print("  ВЫВОД: профиля по умолчанию достаточно")
        print("  Усилия лучше направить на меры с большим отношением эффекта к цене")


if __name__ == "__main__":
    # Типичный сценарий: свой код, нет конкретной угрозы, нет требований
    evaluate([False, False, False, True, True])
PY

python3 decide.py
cd /tmp && rm -rf /tmp/seccomp

Ожидаемый вывод:

text
  вопрос                                                вес  ответ
  ────────────────────────────────────────────────────────────────────
  Запускается ли недоверенный код?                        5  нет
      → Да → нужен не профиль, а gVisor/Kata
  Есть ли КОНКРЕТНАЯ угроза, не покрытая умолчанием?      4  нет
      → Нет → профиль по умолчанию достаточен
  Требуется ли соответствие стандарту с таким требованием?   3  нет
      → Нет → снимается один из доводов
  Готовы ли сопровождать профиль при обновлениях?         3  да
  Есть ли инструменты сбора списка вызовов?               2  да

  оценка: 5 из 17
  ВЫВОД: профиля по умолчанию достаточно
  Усилия лучше направить на меры с большим отношением эффекта к цене

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


Практическое упражнение

Задание. Освойте диагностику блокировок и обоснуйте решение о профиле.

Требования:

  1. Показать, какие вызовы блокирует профиль по умолчанию и что меняется без него.
  2. Написать минимальный профиль seccomp, под которым работает простая программа.
  3. Показать, что под этим профилем не работает операция вне белого списка.
  4. Реализовать пошаговую диагностику: определить, что блокирует операцию — capability, seccomp или AppArmor.
  5. Проверить диагностику на трёх случаях с разными причинами.
  6. Обосновать решение о собственном профиле и записать его.

Подсказки

Подсказка 1

Для пункта 1 сравнивайте код ошибки: EPERM означает блокировку фильтром, EFAULT или EINVAL — что вызов дошёл до ядра.

Подсказка 2

Пункт 4 требует последовательного исключения: код ошибки один и тот же для всех механизмов.

Подсказка 3

Три случая для пункта 5: операция, требующая capability; вызов, блокируемый seccomp; операция, работающая сразу.

Решение

Показать решение
bash
mkdir -p /tmp/mac && cd /tmp/mac

cat > syscalls.py <<'PY'
"""Проверка доступности системных вызовов по коду ошибки.

EPERM (1) при заведомо неверных аргументах = блокировка фильтром:
ядро отвергло вызов до разбора аргументов.
EFAULT (14) / EINVAL (22) = вызов дошёл до обработчика.
"""
from __future__ import annotations

import ctypes
import ctypes.util
import json
import sys
from pathlib import Path

_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)

# x86_64
SYSCALLS = {
    "init_module": 175, "delete_module": 176, "kexec_load": 246,
    "acct": 163, "bpf": 321, "perf_event_open": 298,
    "add_key": 248, "keyctl": 250, "userfaultfd": 323,
}

ERRNO = {
    0: ("успех", "прошёл"),
    1: ("EPERM", "ЗАБЛОКИРОВАН"),
    14: ("EFAULT", "прошёл"),
    22: ("EINVAL", "прошёл"),
    38: ("ENOSYS", "нет в ядре"),
}


def seccomp_mode() -> str:
    for line in Path("/proc/self/status").read_text().splitlines():
        if line.startswith("Seccomp:"):
            return line.split()[1]
    return "?"


def probe(number: int) -> dict[str, str]:
    ctypes.set_errno(0)
    _libc.syscall(number, 0, 0, 0, 0, 0, 0)
    err = ctypes.get_errno()
    name, verdict = ERRNO.get(err, (f"errno {err}", "прошёл"))
    return {"errno": name, "вердикт": verdict}


if __name__ == "__main__":
    results = {n: probe(num) for n, num in SYSCALLS.items()}
    blocked = [n for n, r in results.items() if r["вердикт"] == "ЗАБЛОКИРОВАН"]
    print(json.dumps({
        "seccomp": seccomp_mode(),
        "режим": {"0": "фильтра нет", "2": "фильтрация"}.get(seccomp_mode(), "?"),
        "вызовы": results,
        "заблокировано": len(blocked),
        "всего": len(results),
    }, ensure_ascii=False, indent=2))
PY

cat > minimal.json <<'EOF'
{
  "defaultAction": "SCMP_ACT_ERRNO",
  "defaultErrnoRet": 1,
  "archMap": [
    {
      "architecture": "SCMP_ARCH_X86_64",
      "subArchitectures": ["SCMP_ARCH_X86", "SCMP_ARCH_X32"]
    }
  ],
  "syscalls": [
    {
      "names": [
        "access", "arch_prctl", "brk", "clone", "clone3", "close",
        "dup", "dup2", "epoll_create1", "execve", "exit", "exit_group",
        "faccessat2", "fcntl", "fstat", "futex", "getcwd", "getdents64",
        "getegid", "geteuid", "getgid", "getpid", "getrandom", "getuid",
        "ioctl", "lseek", "madvise", "mmap", "mprotect", "munmap",
        "newfstatat", "openat", "pipe2", "pread64", "prlimit64", "read",
        "readlink", "rseq", "rt_sigaction", "rt_sigprocmask", "set_robust_list",
        "set_tid_address", "sigaltstack", "statfs", "sysinfo", "uname",
        "wait4", "write"
      ],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}
EOF

cat > diagnose.sh <<'SH'
#!/usr/bin/env bash
# Пошаговая диагностика блокировки.
# Последовательное исключение: код ошибки одинаков у всех механизмов.
set -uo pipefail

IMAGE="${1:?образ}"; shift
CMD=("$@")

attempt() {
    local label="$1"; shift
    local out rc
    out="$(docker run --rm "$@" "$IMAGE" "${CMD[@]}" 2>&1)"; rc=$?
    if [ "$rc" -eq 0 ]; then
        printf '    %-34s ✓ РАБОТАЕТ\n' "$label"
        return 0
    fi
    printf '    %-34s ✗ %s\n' "$label" "$(echo "$out" | tail -1 | cut -c1-34)"
    return 1
}

printf '  операция: %s\n' "${CMD[*]}"

if attempt "1. базовая конфигурация"; then
    echo "  ПРИЧИНА: блокировки нет"; exit 0
fi
if attempt "2. + --cap-add=SYS_ADMIN" --cap-add=SYS_ADMIN; then
    echo "  ПРИЧИНА: capability SYS_ADMIN"
    echo "  РЕШЕНИЕ: --cap-drop=ALL --cap-add=SYS_ADMIN"; exit 0
fi
if attempt "3. + --cap-add=SYS_PACCT" --cap-add=SYS_PACCT; then
    echo "  ПРИЧИНА: capability SYS_PACCT"
    echo "  РЕШЕНИЕ: --cap-drop=ALL --cap-add=SYS_PACCT"; exit 0
fi
if attempt "4. + seccomp=unconfined" --security-opt seccomp=unconfined; then
    echo "  ПРИЧИНА: профиль seccomp"
    echo "  РЕШЕНИЕ: свой профиль с нужным вызовом (НЕ unconfined в эксплуатации)"; exit 0
fi
if attempt "5. + apparmor=unconfined" --security-opt apparmor=unconfined; then
    echo "  ПРИЧИНА: профиль AppArmor"
    echo "  РЕШЕНИЕ: свой профиль AppArmor"; exit 0
fi
if attempt "6. --privileged (диагностика)" --privileged; then
    echo "  ПРИЧИНА: устройства, /sys или device cgroup"
    echo "  РЕШЕНИЕ: --device для конкретного узла"; exit 0
fi

echo "  ПРИЧИНА: вне механизмов изоляции (логика, конфигурация, отсутствие файла)"
exit 1
SH
chmod +x diagnose.sh

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Требование 1: что блокирует профиль по умолчанию ═══\n'
with_profile="$(docker run --rm -v "$PWD/syscalls.py:/s.py:ro" \
    python:3.13-slim python /s.py 2>/dev/null)"
without_profile="$(docker run --rm --security-opt seccomp=unconfined \
    -v "$PWD/syscalls.py:/s.py:ro" python:3.13-slim python /s.py 2>/dev/null)"

echo "$with_profile" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"    с профилем (Seccomp={d['seccomp']}): заблокировано {d['заблокировано']} из {d['всего']}\")
for name, r in d['вызовы'].items():
    if r['вердикт'] == 'ЗАБЛОКИРОВАН':
        print(f'      ✗ {name}')
"
echo "$without_profile" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\"    без профиля (Seccomp={d['seccomp']}): заблокировано {d['заблокировано']} из {d['всего']}\")
"

n_with="$(echo "$with_profile" | python3 -c "import json,sys; print(json.load(sys.stdin)['заблокировано'])")"
n_without="$(echo "$without_profile" | python3 -c "import json,sys; print(json.load(sys.stdin)['заблокировано'])")"
[ "$n_with" -gt "$n_without" ] \
    && ok "профиль блокирует $n_with вызовов против $n_without без него" \
    || bad "разницы нет: $n_with и $n_without"

printf '\n═══ Требования 2-3: свой профиль ═══\n'
n_allowed="$(python3 -c "import json; print(len(json.load(open('minimal.json'))['syscalls'][0]['names']))")"
printf '    разрешено вызовов в профиле: %s\n' "$n_allowed"

simple="$(docker run --rm --security-opt seccomp=./minimal.json \
    python:3.13-slim python -c "print('РАБОТАЕТ')" 2>&1 | tail -1)"
printf '    простая программа: %s\n' "$simple"
[ "$simple" = "РАБОТАЕТ" ] && ok "программа работает под своим профилем" \
    || bad "программа не запустилась: $simple"

netres="$(docker run --rm --security-opt seccomp=./minimal.json \
    python:3.13-slim python -c "
import socket
try:
    socket.socket()
    print('сокет создан')
except OSError as e:
    print(f'ОТКАЗ: {e.strerror}')
" 2>&1 | tail -1)"
printf '    создание сокета:   %s\n' "$netres"
case "$netres" in
    *ОТКАЗ*) ok "операция вне белого списка запрещена" ;;
    *) bad "операция прошла: $netres" ;;
esac

printf '\n═══ Требования 4-5: диагностика на трёх случаях ═══\n'

printf '\n  Случай 1: монтирование (ожидается capability)\n'
./diagnose.sh python:3.13-slim sh -c 'mount -t tmpfs tmpfs /mnt' > case1.txt 2>&1
cat case1.txt | sed 's/^/  /'
grep -q "ПРИЧИНА: capability SYS_ADMIN" case1.txt \
    && ok "определена верно: capability" || bad "определено неверно"

printf '\n  Случай 2: вызов acct (ожидается seccomp)\n'
cat > tryacct.py <<'PY'
import ctypes, ctypes.util, sys
libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
ctypes.set_errno(0)
libc.syscall(163, 0)
err = ctypes.get_errno()
if err == 1:
    print("Operation not permitted")
    sys.exit(1)
print(f"прошёл, errno={err}")
PY
docker run --rm -v "$PWD/tryacct.py:/t.py:ro" --entrypoint true python:3.13-slim 2>/dev/null
./diagnose.sh python:3.13-slim sh -c \
    'python -c "
import ctypes, ctypes.util, sys
libc = ctypes.CDLL(ctypes.util.find_library(\"c\"), use_errno=True)
ctypes.set_errno(0)
libc.syscall(163, 0)
sys.exit(1 if ctypes.get_errno() == 1 else 0)
"' > case2.txt 2>&1
cat case2.txt | sed 's/^/  /'
grep -qE "ПРИЧИНА: (профиль seccomp|capability SYS_PACCT)" case2.txt \
    && ok "определена верно: seccomp или capability" || bad "определено неверно"

printf '\n  Случай 3: обычная операция (блокировки нет)\n'
./diagnose.sh python:3.13-slim sh -c 'echo проверка > /tmp/x && cat /tmp/x' > case3.txt 2>&1
cat case3.txt | sed 's/^/  /'
grep -q "ПРИЧИНА: блокировки нет" case3.txt \
    && ok "определена верно: блокировки нет" || bad "определено неверно"

printf '\n═══ Требование 6: решение о профиле ═══\n'
python3 - <<'PY' | tee decision-input.txt
QUESTIONS = [
    ("Запускается недоверенный код?", 5, False,
     "нет → профиль не решит задачу; нужны gVisor/Kata"),
    ("Есть конкретная угроза вне умолчания?", 4, False,
     "нет → профиль по умолчанию покрывает известные векторы"),
    ("Требование стандарта или регулятора?", 3, False,
     "нет → внешнего требования нет"),
    ("Готовность сопровождать при обновлениях?", 3, True,
     "да → но это цена, а не довод"),
    ("Есть инструменты сбора списка вызовов?", 2, True,
     "да → strace и SCMP_ACT_LOG доступны"),
]

score = sum(w for _, w, a, _ in QUESTIONS if a)
total = sum(w for _, w, _, _ in QUESTIONS)

print(f"    {'критерий':<44} {'вес':>4}  ответ")
print("    " + "─" * 62)
for q, w, a, note in QUESTIONS:
    print(f"    {q:<44} {w:>4}  {'да' if a else 'нет'}")
    print(f"        {note}")
print()
print(f"    оценка: {score} из {total}")
verdict = ("свой профиль оправдан" if score >= 10
           else "рассмотреть" if score >= 6
           else "профиля по умолчанию достаточно")
print(f"    ВЫВОД: {verdict}")
PY

cat > SECCOMP-DECISION.md <<'TXT'
# Решение: собственный профиль seccomp

Дата: 2026-07-31.

## Решение

**Свой профиль seccomp НЕ применяется.** Используется профиль Docker
по умолчанию.

## Обоснование

| Критерий | Ответ | Следствие |
|---|---|---|
| Недоверенный код | нет | Профиль не решил бы задачу: нужны gVisor или Kata |
| Конкретная угроза вне умолчания | нет | Профиль по умолчанию блокирует ~44 вызова, включая известные векторы побега |
| Требование стандарта | нет | Внешнего обязательства нет |
| Готовность сопровождать | да | Но это цена меры, а не довод за неё |
| Инструменты сбора вызовов | да | strace и SCMP_ACT_LOG доступны |

Оценка 5 из 17 — ниже порога.

## Цена, которую не платим

Собственный профиль ломается при обновлениях:

- переход glibc на `clone3` вместо `clone`;
- появление `openat2` вместо `openat`;
- новые вызовы в стандартной библиотеке Python при обновлении версии;
- любая новая зависимость.

Каждый случай проявляется как отказ приложения с сообщением
`Operation not permitted`, не указывающим на причину.

## Что применяем вместо

- Профиль seccomp по умолчанию: **не отключать никогда**.
- `--cap-drop=ALL` с точечным `--cap-add` ([урок 12.4]).
- `no-new-privileges`.
- Профиль AppArmor `docker-default` (применяется автоматически).

## Условие пересмотра

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

## Что НЕ является поводом отключить seccomp

Отказ операции с `Operation not permitted` — повод провести диагностику
(см. diagnose.sh), а не выполнить `--security-opt seccomp=unconfined`.
В трёх проверенных случаях причиной дважды была capability, а не seccomp.
TXT
head -10 SECCOMP-DECISION.md | sed 's/^/    /'
[ -s SECCOMP-DECISION.md ] && grep -q "Условие пересмотра" SECCOMP-DECISION.md \
    && ok "решение записано с обоснованием и условием пересмотра" \
    || bad "решение неполно"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"

cd /tmp && rm -rf /tmp/mac
exit "$fail"

Ожидаемый вывод:

text
═══ Требование 1: что блокирует профиль по умолчанию ═══
    с профилем (Seccomp=2): заблокировано 6 из 9
      ✗ init_module
      ✗ delete_module
      ✗ acct
      ✗ bpf
      ✗ perf_event_open
      ✗ userfaultfd
    без профиля (Seccomp=0): заблокировано 1 из 9
  ✓ профиль блокирует 6 вызовов против 1 без него

═══ Требования 2-3: свой профиль ═══
    разрешено вызовов в профиле: 48
    простая программа: РАБОТАЕТ
  ✓ программа работает под своим профилем
    создание сокета:   ОТКАЗ: Operation not permitted
  ✓ операция вне белого списка запрещена

═══ Требования 4-5: диагностика на трёх случаях ═══

  Случай 1: монтирование (ожидается capability)
  операция: sh -c mount -t tmpfs tmpfs /mnt
    1. базовая конфигурация            ✗ mount: permission denied
    2. + --cap-add=SYS_ADMIN           ✓ РАБОТАЕТ
  ПРИЧИНА: capability SYS_ADMIN
  РЕШЕНИЕ: --cap-drop=ALL --cap-add=SYS_ADMIN
  ✓ определена верно: capability

  Случай 2: вызов acct (ожидается seccomp)
  операция: sh -c python -c "..."
    1. базовая конфигурация            ✗ 
    2. + --cap-add=SYS_ADMIN           ✗ 
    3. + --cap-add=SYS_PACCT           ✗ 
    4. + seccomp=unconfined            ✓ РАБОТАЕТ
  ПРИЧИНА: профиль seccomp
  РЕШЕНИЕ: свой профиль с нужным вызовом (НЕ unconfined в эксплуатации)
  ✓ определена верно: seccomp или capability

  Случай 3: обычная операция (блокировки нет)
  операция: sh -c echo проверка > /tmp/x && cat /tmp/x
    1. базовая конфигурация            ✓ РАБОТАЕТ
  ПРИЧИНА: блокировки нет
  ✓ определена верно: блокировки нет

═══ Требование 6: решение о профиле ═══
    критерий                                      вес  ответ
    ──────────────────────────────────────────────────────────────
    Запускается недоверенный код?                   5  нет
        нет → профиль не решит задачу; нужны gVisor/Kata
    Есть конкретная угроза вне умолчания?           4  нет
        нет → профиль по умолчанию покрывает известные векторы
    Требование стандарта или регулятора?            3  нет
        нет → внешнего требования нет
    Готовность сопровождать при обновлениях?        3  да
        да → но это цена, а не довод
    Есть инструменты сбора списка вызовов?          2  да
        да → strace и SCMP_ACT_LOG доступны

    оценка: 5 из 17
    ВЫВОД: профиля по умолчанию достаточно
    # Решение: собственный профиль seccomp
    
    Дата: 2026-07-31.
    
    ## Решение
    
    **Свой профиль seccomp НЕ применяется.** Используется профиль Docker
    по умолчанию.
  ✓ решение записано с обоснованием и условием пересмотра

═══ ИТОГ ═══
  все требования выполнены

Все требования выполнены.

Случай 2 в требовании 5 показателен: диагностика перебрала две capabilities, прежде чем дойти до seccomp. Это и есть цена того, что код ошибки одинаков — угадать источник нельзя, можно только исключить.

Три решения, определяющие качество.

Проверка вызовов различает EPERM и EFAULT. Оба означают неуспех, но EFAULT — «неверный адрес аргумента», то есть обработчик в ядре отработал. Именно это различие показывает, что делает seccomp: он не запрещает операцию, он не пускает вызов к обработчику. Проверка «вернуло ли ошибку» этого не увидела бы.

Диагностика перебирает capabilities до снятия seccomp. Обратный порядок — сначала seccomp=unconfined — чаще давал бы результат с первой попытки и создавал впечатление, что виноват фильтр. Порядок «от дешёвого и безопасного к грубому» приводит к точечному решению вместо отключения защиты.

Решение записывает не только вывод, но и цену непринятой меры. Раздел «Цена, которую не платим» перечисляет конкретные поломки: clone3, openat2, обновление Python. Через полгода это объяснит, почему профиль не стали делать, — гораздо лучше, чем «решили, что не надо».

Чего решение не делает. Профиль minimal.json составлен вручную и покрывает один сценарий: запуск интерпретатора и печать строки. Реальному приложению нужны сотни вызовов, и список собирают инструментами. AppArmor и SELinux не проверяются: первый есть не везде, второй требует RHEL-подобной системы; их поведение описано, но на этом стенде не воспроизводится. Наконец, диагностика не покрывает случай, когда причин несколько — она находит первую и останавливается.

Проверка результата

bash
docker run --rm python:3.13-slim sh -c 'grep -E "Seccomp:|Seccomp_filters:" /proc/self/status'
docker run --rm python:3.13-slim sh -c 'cat /proc/self/attr/current 2>/dev/null | tr -d "\0"; echo'
docker run --rm --security-opt seccomp=unconfined python:3.13-slim sh -c 'grep Seccomp: /proc/self/status'
docker run --rm --security-opt no-new-privileges python:3.13-slim sh -c 'grep NoNewPrivs /proc/self/status'

Ожидается Seccomp: 2 и профиль AppArmor в первых двух командах, Seccomp: 0 в третьей, NoNewPrivs: 1 в четвёртой.

Типичные ошибки

ОшибкаПричинаИсправление
seccomp=unconfined при отказе операцииСнимает симптомПровести диагностику; чаще виновата capability
Свой профиль «на всякий случай»Кажется усилениемЛомается при обновлении glibc или Python
Составление белого списка вручнуюКажется выполнимымСотни вызовов; собирать инструментами
Определение причины по коду ошибкиЛогично предположитьEPERM одинаков у capability и seccomp
apparmor=unconfined в эксплуатацииОсталось после диагностикиВозвращать после выяснения причины
Не включают no-new-privilegesНе знают о нёмЦена нулевая; закрывает setuid-пути
Отключают no-new-privileges из-за gosuEntrypoint сломалсяЗадать USER в Dockerfile вместо переключения
:Z на системный каталогСкопировали примерМетки меняются рекурсивно; сломает систему
Ожидают действия :z на UbuntuВидели в инструкцииСуффиксы для SELinux, а не AppArmor
Считают три механизма взаимозаменяемымиПохожие симптомыОграничивают разное; дополняют друг друга
Профиль составлен по одному прогонуИнструмент дал списокРедкие пути кода в него не попали

Контрольные вопросы

На понимание:

  1. Что блокирует профиль seccomp по умолчанию? Назовите три категории.
  2. Чем EPERM отличается от EFAULT при проверке доступности вызова?
  3. Почему нельзя определить источник блокировки по коду ошибки?
  4. Чем AppArmor отличается от SELinux по принципу правил?
  5. Зачем нужны суффиксы :z и :Z и где они не действуют?
  6. Что делает no-new-privileges и почему его включают всегда?

На применение:

  1. Как определить, что блокирует операцию?
  2. Как собрать список системных вызовов приложения?
  3. Как обосновать решение о собственном профиле?

На диагностику:

  1. Операция возвращает Operation not permitted. Порядок действий?
  2. После обновления базового образа container перестал запускаться под своим профилем. Причина?
  3. Entrypoint образа перестал работать после добавления no-new-privileges. Причина и правильное решение?

Краткое резюме

  1. Три механизма ограничивают разное: seccomp — вызовы, AppArmor — пути, SELinux — метки.
  2. Профиль seccomp по умолчанию блокирует около 44 вызовов из более чем 300.
  3. Он работает по белому списку: разрешённое перечислено, остальное даёт EPERM.
  4. EPERM означает, что вызов не дошёл до обработчика; EFAULT — что дошёл.
  5. Часть вызовов разрешена условно и требует capability — источник отказа неоднозначен.
  6. Определить источник можно только последовательным исключением.
  7. Собственный профиль ломается при обновлении glibc, Python или зависимостей.
  8. SCMP_ACT_LOG и strace -c дают список используемых вызовов без риска.
  9. Список из одного прогона неполон: редкие пути кода в него не попадают.
  10. AppArmor применяет docker-default автоматически в Debian и Ubuntu.
  11. Суффиксы :z и :Z нужны для SELinux и меняют метки рекурсивно и необратимо.
  12. no-new-privileges отключает setuid на уровне ядра, стоит почти ничего и включается всегда.
  13. Свой профиль оправдан при конкретной угрозе, а не по умолчанию.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker: seccomphttps://docs.docker.com/engine/security/seccomp/Профиль по умолчанию, список блокируемых вызовов
Docker: AppArmorhttps://docs.docker.com/engine/security/apparmor/docker-default, свои профили
Docker: SELinux labelshttps://docs.docker.com/engine/storage/bind-mounts/#configure-the-selinux-labelСуффиксы :z и :Z
Docker: --security-opthttps://docs.docker.com/reference/cli/docker/container/run/#security-optСинтаксис применения профилей
Linux: seccomp(2)https://man7.org/linux/man-pages/man2/seccomp.2.htmlРежимы, BPF-фильтры
Linux: no_new_privshttps://docs.kernel.org/userspace-api/no_new_privs.htmlСемантика PR_SET_NO_NEW_PRIVS
libseccomphttps://github.com/seccomp/libseccompФормат профиля, действия
AppArmorhttps://gitlab.com/apparmor/apparmor/-/wikis/DocumentationСинтаксис профилей, диагностика
SELinux: container policyhttps://github.com/containers/container-selinuxТипы container_t, container_file_t

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Secrets
Главное оглавление

Markdown на GitHub ↗