12.5. Seccomp, AppArmor, SELinux
Цели
После этого материала вы сможете:
- назвать, что блокирует профиль seccomp по умолчанию и почему именно это;
- написать собственный профиль seccomp и понимать цену его сопровождения;
- диагностировать блокировку: определить, какой вызов или путь запрещён;
- различить, какой из трёх механизмов сработал в конкретном случае;
- объяснить назначение
:zи:Zпри монтировании на системах с SELinux; - объяснить, что даёт
no-new-privilegesи почему его цена близка к нулю; - решить, нужен ли вам собственный профиль, — обоснованно.
Предварительные знания
- 11.2. Runtime hardening — практика применения;
- 12.4. Capabilities и privileged;
- 7.3. Bind mounts — метки SELinux.
Ключевые термины
| Термин | Объяснение |
|---|---|
seccomp | Фильтр системных вызовов на уровне процесса |
BPF | Программа фильтрации, исполняемая ядром |
MAC | Мандатный контроль доступа: правила задаёт система, не владелец |
AppArmor | MAC на основе путей файловой системы |
SELinux | MAC на основе меток объектов |
контекст | Метка SELinux вида system_u:object_r:тип:s0 |
Теория
Три механизма и их различия
| seccomp | AppArmor | SELinux | |
|---|---|---|---|
| Что ограничивает | Системные вызовы | Доступ к путям и возможностям | Доступ к объектам по меткам |
| Основа правил | Номер вызова и аргументы | Путь файла | Метка (тип) объекта |
| Где применяется | Везде | Debian, Ubuntu, SUSE | RHEL, Fedora, CentOS |
| Профиль Docker по умолчанию | default | docker-default | Есть, но требует настройки |
| Сложность своего профиля | Средняя | Средняя | Высокая |
| Диагностика | strace, dmesg | dmesg, aa-status | ausearch, 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
{
"defaultAction": "SCMP_ACT_ERRNO",
"defaultErrnoRet": 1,
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": ["read", "write", "openat", "close", "fstat", "exit_group"],
"action": "SCMP_ACT_ALLOW"
}
]
}
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 | Новые вызовы в стандартной библиотеке |
| Обновление glibc | clone3 вместо clone, openat2 вместо openat |
| Новая зависимость | Что угодно |
| Смена базового образа | Другая libc — другой набор |
Известный пример: переход glibc на clone3 ломал container'ы со старыми профилями, включая профиль Docker до его обновления.
Отсюда рекомендация: свой профиль оправдан при конкретной угрозе, а не «на всякий случай» (урок 12.1).
AppArmor
В Ubuntu и Debian Docker применяет профиль docker-default автоматически. Он ограничивает:
| Что | Пример |
|---|---|
Запись в /proc | Кроме собственных подкаталогов процесса |
Доступ к /sys | Большинство путей только на чтение |
| Монтирование | Запрещено |
| Загрузка профилей AppArmor | Запрещена |
| Прямой доступ к устройствам | Ограничен |
Свой профиль:
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 |
docker run -v /srv/data:/data:Z ...
Предупреждение: суффикс меняет метки рекурсивно и необратимо. Применение :Z к /home или /usr нарушит работу системы.
no-new-privileges
Четвёртый механизм, стоящий рядом с тремя предыдущими и почти ничего не стоящий:
docker run --security-opt no-new-privileges ...
Он устанавливает флаг ядра PR_SET_NO_NEW_PRIVS, после которого процесс не может получить привилегии через execve. Конкретно перестают действовать:
| Механизм | Что переставало бы работать |
|---|---|
setuid-бит на файле | su, sudo, passwd, mount из образа |
setgid-бит | Смена группы при запуске |
| File capabilities | Capabilities, назначенные бинарнику |
Флаг наследуется всеми потомками и не может быть снят — как и фильтр 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 |
| seccomp | dmesg при SCMP_ACT_LOG | EPERM от разрешённого по правам вызова |
| AppArmor | dmesg, journalctl | Строка apparmor="DENIED" |
| SELinux | /var/log/audit/audit.log | Строка avc: denied |
Порядок диагностики:
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. Именно поэтому диагностика идёт по шагам.
Команды и примеры
Что даёт профиль по умолчанию
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}')
"
Ожидаемый вывод:
═══ профиль по умолчанию ═══
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. Различить по коду ошибки невозможно.
Что происходит без профиля
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
Ожидаемый вывод:
═══ 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_settime — SYS_TIME. Это и показывает, что механизмы независимы.
Свой профиль: минимальный белый список
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
Ожидаемый вывод:
═══ профиль составлен ═══
действие по умолчанию: SCMP_ACT_ERRNO
разрешено вызовов: 48
═══ проверка: простая программа ═══
работает под своим профилем
═══ проверка: то, что профиль не разрешает ═══
создание сокета: Operation not permitted — socket нет в белом списке
Программа без сети работает, сетевая — нет. Профиль делает ровно то, что описано.
Обратите внимание на список: 48 вызовов понадобилось, чтобы просто запустить интерпретатор и напечатать строку. Реальному приложению нужно больше, и составлять список вручную непрактично.
Сбор списка вызовов через SCMP_ACT_LOG
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
Ожидаемый вывод:
═══ профиль с логированием ═══
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). Это объясняет, почему белый список для реального приложения составляют инструментами, а не вручную.
Диагностика: что именно заблокировано
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'
Ожидаемый вывод:
═══ диагностика: монтирование 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:
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
Ожидаемый вывод:
═══ диагностика: вызов, блокируемый seccomp ═══
базовая: Operation not permitted
+ SYS_PACCT: Operation not permitted
+ seccomp=unconfined: прошёл фильтр, errno=14
═══ вывод ═══
Добавление capability не помогло, снятие seccomp — помогло.
Значит, блокировал именно фильтр системных вызовов.
Это ровно та развилка, которую нельзя пройти по коду ошибки:
и capability, и seccomp дают EPERM.
Три строки исчерпывающе показывают методику: последовательное исключение вместо угадывания.
AppArmor
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/^/ /'
Ожидаемый вывод:
═══ состояние 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 и метки монтирования
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
Ожидаемый вывод:
═══ состояние 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 на практике
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
Ожидаемый вывод:
═══ 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 — это восемь потенциальных путей повышения привилегий, если атакующий получил выполнение кода от непривилегированного пользователя.
Флаг закрывает их все одной строкой, ничего не ломая. По отношению эффекта к цене это лучшая мера из рассмотренных в уроке.
Стоит ли писать свой профиль
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
Ожидаемый вывод:
вопрос вес ответ
────────────────────────────────────────────────────────────────────
Запускается ли недоверенный код? 5 нет
→ Да → нужен не профиль, а gVisor/Kata
Есть ли КОНКРЕТНАЯ угроза, не покрытая умолчанием? 4 нет
→ Нет → профиль по умолчанию достаточен
Требуется ли соответствие стандарту с таким требованием? 3 нет
→ Нет → снимается один из доводов
Готовы ли сопровождать профиль при обновлениях? 3 да
Есть ли инструменты сбора списка вызовов? 2 да
оценка: 5 из 17
ВЫВОД: профиля по умолчанию достаточно
Усилия лучше направить на меры с большим отношением эффекта к цене
Для типичного сценария ответ отрицательный. Это важный результат: собственный профиль — дорогая мера, и её применяют по обоснованию, а не по умолчанию (урок 12.1).
Практическое упражнение
Задание. Освойте диагностику блокировок и обоснуйте решение о профиле.
Требования:
- Показать, какие вызовы блокирует профиль по умолчанию и что меняется без него.
- Написать минимальный профиль seccomp, под которым работает простая программа.
- Показать, что под этим профилем не работает операция вне белого списка.
- Реализовать пошаговую диагностику: определить, что блокирует операцию — capability, seccomp или AppArmor.
- Проверить диагностику на трёх случаях с разными причинами.
- Обосновать решение о собственном профиле и записать его.
Подсказки
Подсказка 1
Для пункта 1 сравнивайте код ошибки: EPERM означает блокировку фильтром, EFAULT или EINVAL — что вызов дошёл до ядра.
Подсказка 2
Пункт 4 требует последовательного исключения: код ошибки один и тот же для всех механизмов.
Подсказка 3
Три случая для пункта 5: операция, требующая capability; вызов, блокируемый seccomp; операция, работающая сразу.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Требование 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-подобной системы; их поведение описано, но на этом стенде не воспроизводится. Наконец, диагностика не покрывает случай, когда причин несколько — она находит первую и останавливается.
Проверка результата
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 из-за gosu | Entrypoint сломался | Задать USER в Dockerfile вместо переключения |
:Z на системный каталог | Скопировали пример | Метки меняются рекурсивно; сломает систему |
Ожидают действия :z на Ubuntu | Видели в инструкции | Суффиксы для SELinux, а не AppArmor |
| Считают три механизма взаимозаменяемыми | Похожие симптомы | Ограничивают разное; дополняют друг друга |
| Профиль составлен по одному прогону | Инструмент дал список | Редкие пути кода в него не попали |
Контрольные вопросы
На понимание:
- Что блокирует профиль seccomp по умолчанию? Назовите три категории.
- Чем
EPERMотличается отEFAULTпри проверке доступности вызова? - Почему нельзя определить источник блокировки по коду ошибки?
- Чем AppArmor отличается от SELinux по принципу правил?
- Зачем нужны суффиксы
:zи:Zи где они не действуют? - Что делает
no-new-privilegesи почему его включают всегда?
На применение:
- Как определить, что блокирует операцию?
- Как собрать список системных вызовов приложения?
- Как обосновать решение о собственном профиле?
На диагностику:
- Операция возвращает
Operation not permitted. Порядок действий? - После обновления базового образа container перестал запускаться под своим профилем. Причина?
- Entrypoint образа перестал работать после добавления
no-new-privileges. Причина и правильное решение?
Краткое резюме
- Три механизма ограничивают разное: seccomp — вызовы, AppArmor — пути, SELinux — метки.
- Профиль seccomp по умолчанию блокирует около 44 вызовов из более чем 300.
- Он работает по белому списку: разрешённое перечислено, остальное даёт
EPERM. EPERMозначает, что вызов не дошёл до обработчика;EFAULT— что дошёл.- Часть вызовов разрешена условно и требует capability — источник отказа неоднозначен.
- Определить источник можно только последовательным исключением.
- Собственный профиль ломается при обновлении glibc, Python или зависимостей.
SCMP_ACT_LOGиstrace -cдают список используемых вызовов без риска.- Список из одного прогона неполон: редкие пути кода в него не попадают.
- AppArmor применяет
docker-defaultавтоматически в Debian и Ubuntu. - Суффиксы
:zи:Zнужны для SELinux и меняют метки рекурсивно и необратимо. no-new-privilegesотключаетsetuidна уровне ядра, стоит почти ничего и включается всегда.- Свой профиль оправдан при конкретной угрозе, а не по умолчанию.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: seccomp | https://docs.docker.com/engine/security/seccomp/ | Профиль по умолчанию, список блокируемых вызовов |
| Docker: AppArmor | https://docs.docker.com/engine/security/apparmor/ | docker-default, свои профили |
| Docker: SELinux labels | https://docs.docker.com/engine/storage/bind-mounts/#configure-the-selinux-label | Суффиксы :z и :Z |
Docker: --security-opt | https://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_privs | https://docs.kernel.org/userspace-api/no_new_privs.html | Семантика PR_SET_NO_NEW_PRIVS |
| libseccomp | https://github.com/seccomp/libseccomp | Формат профиля, действия |
| AppArmor | https://gitlab.com/apparmor/apparmor/-/wikis/Documentation | Синтаксис профилей, диагностика |
| SELinux: container policy | https://github.com/containers/container-selinux | Типы container_t, container_file_t |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Secrets
Главное оглавление