Операционные системы Безопасность и изоляция: права, capabilities, SELinux, namespaces, cgroups, jails
0%

Безопасность и изоляция: права, capabilities, SELinux, namespaces, cgroups, jails

Безопасность и изоляция: права, capabilities, SELinux, namespaces, cgroups, jails

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

Но «безопасность ОС» — это не один механизм, а слоёный пирог из десятка независимых подсистем, которые накапливались сорок лет и почти не согласованы между собой. Программисту это важно не в теории. Именно здесь живут вопросы, на которые приходится отвечать в проде: почему контейнер, запущенный от «непривилегированного» пользователя, всё-таки читает /proc/kcore; почему бинарник с setcap cap_net_bind_service перестал биндиться на 80-й порт после того, как в юнит добавили NoNewPrivileges=yes; почему chmod 777 не помог, а getenforce показывает Enforcing; почему strace говорит, что вызова не существует, хотя он точно есть.

Разберём слои снизу вверх: сначала классическая модель прав, потом её починка через capabilities, потом мандатный контроль, потом фильтрация вызовов, потом изоляция ресурсов и представлений — и в конце то, как из всего этого собирают контейнер, jail и sandbox в разных ОС.

Модель угроз: от чего вообще защищаемся

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

Ключевая мысль, которую стоит усвоить сразу: чем ниже в этом списке граница, тем она прочнее и тем дороже стоит. Разделение по uid — почти бесплатно и защищает от случайности, а не от атакующего с локальным доступом. Гипервизор надёжен, но стоит десятков мегабайт памяти и заметной доли CPU. Контейнер находится ровно посередине и — это принципиально — не является границей безопасности сам по себе, о чём ниже будет отдельный разговор.

Дискреционный доступ: uid, gid и двенадцать бит

Классическая Unix-модель, придуманная в начале 1970-х и не изменившаяся с тех пор ни на йоту. Каждый процесс несёт набор идентификаторов, каждый объект — владельца, группу и биты прав. Это DAC (Discretionary Access Control): «дискреционный» означает, что владелец объекта сам решает, кому дать доступ.

У процесса не один uid, а целых четыре комплекта:

$ cat /proc/self/status | grep -E '^(Uid|Gid|Groups)'
Uid:	1000	1000	1000	1000
Gid:	1000	1000	1000	1000
Groups:	4 24 27 30 46 100 1000

Четыре числа в строке Uid — это, по порядку: real (кто запустил), effective (по кому проверяются права), saved set-uid (куда можно вернуться) и filesystem uid (Linux-специфичное расширение, историческое наследие NFS-сервера в ядре). Разделение real и effective существует ради одного механизма — setuid-битов.

Биты прав — это 12 бит, а не 9, как обычно рисуют:

$ ls -l /usr/bin/passwd /tmp /usr/bin/wall
-rwsr-xr-x 1 root root   68208 /usr/bin/passwd
drwxrwxrwt 18 root root   4096 /tmp
-rwxr-sr-x 1 root tty     3532 /usr/bin/wall
  • s в позиции владельца — setuid: при execve() effective uid становится равен владельцу файла. Так passwd пишет в /etc/shadow от имени root.
  • s в позиции группы — setgid. На каталоге смысл другой: новые файлы наследуют группу каталога (базовый приём для общих директорий команды).
  • tsticky bit. На каталоге запрещает удалять чужие файлы, даже если у вас есть право записи в каталог. Без него любой пользователь мог бы удалить чужой файл в /tmp.

Права проверяются в порядке «владелец → группа → остальные», и проверка останавливается на первом совпадении. Это регулярный источник недоумения:

# файл, доступный всем, кроме владельца
$ chmod 077 secret.txt && ls -l secret.txt
----rwxrwx 1 alice alice 42 secret.txt
$ cat secret.txt          # alice — владелец, её ветка даёт 0
cat: secret.txt: Permission denied

Alice не может прочитать свой файл, хотя «все остальные» могут. Ядро увидело, что uid совпал с владельцем, применило биты владельца и дальше не смотрело.

Когда девяти бит мало — есть POSIX ACL, реализованные поверх расширенных атрибутов:

$ setfacl -m u:deploy:rx /srv/app
$ getfacl /srv/app
# file: srv/app
# owner: root
# group: root
user::rwx
user:deploy:r-x
group::r-x
mask::r-x
other::---

$ ls -ld /srv/app
drwxr-xr-x+ 3 root root 4096 /srv/app     # плюс в конце = есть ACL

Ловушка ACL — запись mask. Она ограничивает эффективные права всех именованных пользователей и групп, и chmod g+w меняет именно маску, а не групповые права. Классический сценарий: администратор выдал ACL, потом сделал chmod 750, и все именованные записи молча обрезались.

Почему DAC-модели недостаточно

Проблема в одном слове: root. В классическом Unix проверка выглядела буквально как if (euid == 0) return OK;. Одна учётная запись имеет все права на свете, и любой процесс, которому нужно одно маленькое привилегированное действие (например, забиндить порт 80 или открыть raw-сокет для ping), получает вместе с ним право стереть диск, загрузить модуль ядра и прочитать чужую память.

Второй недостаток — «дискреционность». Пользователь может отдать свои данные кому угодно, включая троян, работающий от его имени. DAC защищает пользователей друг от друга, но не защищает пользователя от его собственных программ.

Оба недостатка чинили отдельно и по-разному: первый — capabilities, второй — мандатный контроль.

Capabilities: root, разобранный на детали

POSIX capabilities (Linux, с версии 2.2, 1999) разбивают всемогущество root на отдельные, независимо выдаваемые права. В ядре 6.x их 41 штука (CAP_LAST_CAP == 40, см. /usr/include/linux/capability.h).

Самые ходовые:

Capability Что даёт Типичный потребитель
CAP_NET_BIND_SERVICE привязка к портам < 1024 веб-сервер
CAP_NET_RAW raw- и packet-сокеты ping, tcpdump
CAP_NET_ADMIN настройка интерфейсов, iptables сетевые демоны, CNI
CAP_DAC_OVERRIDE игнорировать проверку mode-битов бэкап-агенты
CAP_CHOWN менять владельца файла распаковка архивов
CAP_SETUID / CAP_SETGID произвольная смена uid/gid login, контейнерные рантаймы
CAP_SYS_PTRACE трассировка любых процессов отладчики, профайлеры
CAP_SYS_ADMIN ≈ 30 разных вещей: mount, quota, namespaces… «новый root»
CAP_BPF, CAP_PERFMON eBPF и perf (выделены в 5.8) observability-агенты

CAP_SYS_ADMIN — известный провал дизайна. В неё сваливали всё, что не подходило под другие категории, и сегодня она эквивалентна root по последствиям. Если вы видите её в манифесте контейнера, изоляции по факту нет. Michael Kerrisk называл это «the new root» ещё в 2012 году в обзоре на LWN, и с тех пор лучше не стало.

Пять наборов и формула execve

Здесь начинается место, где ошибаются почти все. У потока не один набор capabilities, а пять, и при execve() они пересчитываются по вполне конкретной формуле.

Преобразование наборов capabilities при execve

Проверить фактическое состояние всегда можно через /proc:

$ grep ^Cap /proc/self/status
CapInh:	0000000000000000
CapPrm:	0000000000000000
CapEff:	0000000000000000
CapBnd:	000001ffffffffff
CapAmb:	0000000000000000

$ capsh --decode=000001ffffffffff | head -3
0x000001ffffffffff=cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,
cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,...

Практический пример — сервис, который слушает 443-й порт, но не является root:

# было: запуск от root и потом drop privileges — сложно и легко ошибиться
# стало: одна capability на бинарнике
$ sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/myserver
$ getcap /usr/local/bin/myserver
/usr/local/bin/myserver cap_net_bind_service=ep

$ sudo -u nobody /usr/local/bin/myserver --port 443
listening on :443

Суффикс +ep означает: положить в permitted-набор файла и поднять бит effective, чтобы процессу не пришлось звать capset() самому.

Самая частая ловушка. В systemd-юните стоит NoNewPrivileges=yes (правильная практика!) — и файловые capabilities перестают работать вообще, потому что no_new_privs по определению запрещает execve() повышать привилегии. Решение — не setcap, а декларация в юните:

[Service]
ExecStart=/usr/local/bin/myserver --port 443
User=myapp
NoNewPrivileges=yes
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE

AmbientCapabilities работает потому, что systemd сам ставит capability в ambient-набор до execve(), а ambient переживает exec обычного (непривилегированного) файла — ровно ради этого сценария его и добавили в ядро 4.3.

Как правильно сбрасывать привилегии в C

Если демон всё же стартует от root, сброс должен быть необратимым. Порядок операций критичен: setgid строго до setuid, иначе после потери root вы уже не сможете сменить группу.

#define _GNU_SOURCE
#include <sys/capability.h>
#include <sys/prctl.h>
#include <grp.h>
#include <unistd.h>
#include <err.h>

/* Оставить только CAP_NET_BIND_SERVICE и уйти под непривилегированный uid. */
static void drop_to(uid_t uid, gid_t gid)
{
    /* 1. Сохранить capabilities при смене uid — иначе ядро обнулит их. */
    if (prctl(PR_SET_KEEPCAPS, 1, 0, 0, 0) < 0)
        err(1, "PR_SET_KEEPCAPS");

    /* 2. Сузить bounding set: это НЕОБРАТИМО, обратно вернуть нельзя. */
    for (int cap = 0; cap <= CAP_LAST_CAP; cap++)
        if (cap != CAP_NET_BIND_SERVICE)
            prctl(PR_CAPBSET_DROP, cap, 0, 0, 0);

    /* 3. Группы: сначала дополнительные, потом основная, только затем uid. */
    if (setgroups(0, NULL) < 0) err(1, "setgroups");
    if (setgid(gid) < 0)        err(1, "setgid");
    if (setuid(uid) < 0)        err(1, "setuid");

    /* 4. Проверка: убедиться, что вернуться в root уже нельзя. */
    if (setuid(0) == 0)
        errx(1, "ФАТАЛЬНО: сброс привилегий не сработал");

    /* 5. Оставить в permitted/effective ровно одну capability. */
    cap_t caps = cap_init();
    cap_value_t keep[] = { CAP_NET_BIND_SERVICE };
    cap_set_flag(caps, CAP_PERMITTED, 1, keep, CAP_SET);
    cap_set_flag(caps, CAP_EFFECTIVE, 1, keep, CAP_SET);
    if (cap_set_proc(caps) < 0) err(1, "cap_set_proc");
    cap_free(caps);

    /* 6. Запретить любое будущее повышение привилегий. */
    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0)
        err(1, "no_new_privs");
}

Шаг 4 не паранойя: исторические CVE в OpenSSH, Sendmail и Postfix возникали именно потому, что возвращаемое значение setuid() не проверялось, а вызов мог упасть, например, из-за RLIMIT_NPROC.

Порядок в целом выглядит так:

Мандатный доступ: LSM, SELinux и AppArmor

DAC чинит вопрос «кто», MAC (Mandatory Access Control) чинит вопрос «что вообще позволено этой программе, независимо от того, чего хочет её владелец». Политику пишет администратор системы, и root-процесс её нарушить не может.

Технически в Linux всё это построено на LSM — Linux Security Modules, каркасе из ~250 хуков, расставленных по ядру в местах принятия решений. LSM появился в 2001 году после того, как Линус отказался вмерживать SELinux напрямую и потребовал общий интерфейс (оригинальная статья USENIX 2002).

Та же картина в пространственном виде — с ошибками, которые вы увидите на каждом этапе:

Последовательность проверок при системном вызове

Обратите внимание: LSM может только запретить, но не разрешить. Если DAC уже сказал «нет», SELinux не откроет файл. Это сознательное решение: MAC-модуль не должен уметь ослаблять базовую модель.

SELinux: type enforcement на практике

SELinux (АНБ, 2000; в mainline с 2.6) — самая мощная и самая недолюбливаемая реализация MAC. Её основа — type enforcement: у каждого субъекта и объекта есть контекст вида user:role:type:level, а политика — это гигантский список разрешённых троек «тип субъекта, тип объекта, класс операции».

$ ls -Z /var/www/html/index.html
unconfined_u:object_r:httpd_sys_content_t:s0 /var/www/html/index.html

$ ps -eZ | grep nginx
system_u:system_r:httpd_t:s0    1234 ?  00:00:01 nginx

Процесс nginx имеет тип httpd_t, файл — тип httpd_sys_content_t. В политике есть правило allow httpd_t httpd_sys_content_t:file { read getattr open }; — поэтому чтение работает. Записи нет — поэтому веб-сервер физически не может испортить контент, даже будучи скомпрометированным и работая от root.

Самый частый инцидент в жизни администратора выглядит так: файлы скопировали из домашнего каталога, они унаследовали тип user_home_t, и nginx возвращает 403 при chmod 644 и корректном владельце.

$ sudo ausearch -m AVC -ts recent | tail -4
type=AVC msg=audit(1721030400.123:456): avc:  denied  { read } for  pid=1234
  comm="nginx" name="index.html" dev="dm-0" ino=131074
  scontext=system_u:system_r:httpd_t:s0
  tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file permissive=0

# лечение: вернуть корректный тип по правилам file contexts
$ sudo restorecon -Rv /var/www/html
Relabeled /var/www/html/index.html from unconfined_u:object_r:user_home_t:s0
                                     to unconfined_u:object_r:httpd_sys_content_t:s0

Ключевой навык — уметь читать AVC-запись: scontext (кто), tcontext (к чему), tclass (какого класса объект), { read } (что делал). Этих четырёх полей хватает, чтобы понять причину в 90 % случаев.

Многое настраивается булевыми переключателями без правки политики:

$ getsebool -a | grep httpd_can_network
httpd_can_network_connect --> off
httpd_can_network_connect_db --> off

$ sudo setsebool -P httpd_can_network_connect on   # -P = persist через ребут

Антипаттерн, который надо назвать вслух: setenforce 0 в качестве «решения». Это выключает защиту на всей машине. Правильно — либо булев переключатель, либо semanage fcontext для нестандартных путей, либо, в крайнем случае, точечный модуль:

# audit2allow генерирует модуль по конкретному отказу — читать сгенерированное ОБЯЗАТЕЛЬНО,
# он с радостью выдаст вам разрешение на всё, если отказов было много
$ sudo ausearch -m AVC -ts recent | audit2allow -M myapp_fix
$ cat myapp_fix.te
$ sudo semodule -i myapp_fix.pp

AppArmor: то же самое, но по путям

AppArmor (Immunix, затем SUSE; сегодня дефолт в Ubuntu и Debian) вместо меток на inode использует пути в профилях. Это заметно проще для человека и заметно слабее по гарантиям: жёсткая ссылка, bind-mount или переименование могут вывести файл из-под правила, тогда как метка SELinux живёт на самом inode.

# /etc/apparmor.d/usr.local.bin.myapp
#include <tunables/global>

/usr/local/bin/myapp {
  #include <abstractions/base>
  #include <abstractions/nameservice>

  capability net_bind_service,

  /usr/local/bin/myapp        mr,
  /etc/myapp/**               r,
  /var/lib/myapp/**           rw,
  /var/log/myapp/*.log        w,
  owner /run/myapp.pid        rw,

  network inet stream,
  deny /etc/shadow            rwx,
  deny /home/**               rwx,
}
$ sudo apparmor_parser -r /etc/apparmor.d/usr.local.bin.myapp
$ sudo aa-status | head -5
apparmor module is loaded.
41 profiles are loaded.
38 profiles are in enforce mode.
3 profiles are in complain mode.

# отладочный режим: не запрещать, но логировать всё, что запретил бы
$ sudo aa-complain /usr/local/bin/myapp

Практический выбор: если у вас RHEL/Fedora/CentOS Stream — SELinux, он уже включён и вся экосистема под него настроена. Если Ubuntu/Debian/SUSE — AppArmor. Драться с дистрибутивом бессмысленно.

Landlock: MAC для тех, у кого нет root

С ядра 5.13 (2021) есть Landlock — LSM, которым может пользоваться непривилегированный процесс, чтобы ограничить сам себя. Это прямой ответ на OpenBSD-шный unveil, и это самый практичный для прикладного разработчика механизм из всего семейства MAC: не нужен ни root, ни политика в системе, ни согласование с админом.

/* Разрешить процессу читать только /usr и писать только в /var/lib/myapp. */
struct landlock_ruleset_attr ra = {
    .handled_access_fs = LANDLOCK_ACCESS_FS_READ_FILE |
                         LANDLOCK_ACCESS_FS_WRITE_FILE |
                         LANDLOCK_ACCESS_FS_MAKE_REG,
};
int rs = syscall(SYS_landlock_create_ruleset, &ra, sizeof(ra), 0);

struct landlock_path_beneath_attr pb = {
    .allowed_access = LANDLOCK_ACCESS_FS_READ_FILE,
    .parent_fd      = open("/usr", O_PATH | O_CLOEXEC),
};
syscall(SYS_landlock_add_rule, rs, LANDLOCK_RULE_PATH_BENEATH, &pb, 0);

prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);      /* обязательное условие */
syscall(SYS_landlock_restrict_self, rs, 0);   /* с этого момента — необратимо */

Документация и версии ABI — на landlock.io и в Documentation/userspace-api/landlock.

seccomp: фильтр на входе в ядро

Все предыдущие механизмы отвечают на вопрос «можно ли этому субъекту трогать этот объект». seccomp отвечает на другой: «можно ли этому процессу вообще звать этот системный вызов». Это сокращение attack surface: в ядре Linux ~350 системных вызовов, типичному приложению нужно 40–60, а уязвимости регулярно находят именно в редко используемых.

Режим SECCOMP_MODE_STRICT (2005) разрешал ровно четыре вызова и был почти бесполезен. Практический режим — SECCOMP_MODE_FILTER (3.5, 2012): вы загружаете cBPF-программу, которая на каждый syscall видит его номер, архитектуру и шесть скалярных аргументов, и возвращает вердикт.

#include <linux/filter.h>
#include <linux/seccomp.h>
#include <linux/audit.h>
#include <sys/prctl.h>
#include <sys/syscall.h>

/* Разрешить только read/write/exit_group/rt_sigreturn, остальное — убить процесс. */
static struct sock_filter filter[] = {
    /* Проверка архитектуры обязательна: без неё 32-битный ABI обходит фильтр. */
    BPF_STMT(BPF_LD  | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_X86_64, 1, 0),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),

    BPF_STMT(BPF_LD  | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_read,          4, 0),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write,         3, 0),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit_group,    2, 0),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_rt_sigreturn,  1, 0),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};

static struct sock_fprog prog = {
    .len = sizeof(filter) / sizeof(filter[0]),
    .filter = filter,
};

prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);              /* иначе seccomp откажет */
syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog);

Писать cBPF руками мучительно — в проде используют libseccomp, которая компилирует декларативные правила в тот же фильтр.

Важные детали, которые определяют пригодность механизма:

  • Фильтр видит только скаляры. Строку по указателю прочитать нельзя — это была бы TOCTOU-гонка: другой поток изменит память между проверкой и использованием. Поэтому «запретить open() только для /etc/shadow» через seccomp невозможно; это работа для LSM.
  • Фильтры складываются и необратимы. Каждый новый фильтр применяется дополнительно к существующим, снять нельзя.
  • Вердикты: KILL_PROCESS, KILL_THREAD, TRAP (SIGSYS), ERRNO(n) (вернуть код ошибки), USER_NOTIF (передать решение в userspace-супервизор — ядро 5.0, основа для реализации syscall’ов в контейнерных рантаймах), LOG, ALLOW.
  • ERRNO(ENOSYS) — вежливый вариант. Программы обычно умеют деградировать, если вызов «не поддержан», но падают от SIGSYS. Docker по умолчанию отдаёт EPERM для ~44 из 350+ вызовов (профиль по умолчанию).

Диагностика: если strace показывает загадочный ENOSYS на очевидно существующем вызове или процесс умирает от SIGSYS, первым делом смотрите:

$ grep Seccomp /proc/1234/status
Seccomp:	2                 # 0=выкл, 1=strict, 2=filter
Seccomp_filters:	3

Namespaces: изоляция представлений

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

В Linux их восемь:

Namespace Что изолирует Появился Флаг clone()
mnt таблицу монтирований 2.4.19 (2002) CLONE_NEWNS
uts hostname и domainname 2.6.19 CLONE_NEWUTS
ipc System V IPC, POSIX-очереди 2.6.19 CLONE_NEWIPC
pid пространство PID 2.6.24 CLONE_NEWPID
net интерфейсы, порты, таблицы маршрутов, iptables 2.6.29 CLONE_NEWNET
user отображение uid/gid и capabilities 3.8 (2013) CLONE_NEWUSER
cgroup видимую корневую точку иерархии cgroup 4.6 CLONE_NEWCGROUP
time CLOCK_MONOTONIC и CLOCK_BOOTTIME 5.6 CLONE_NEWTIME

Каждый namespace — это объект в ядре, на который ссылаются через файлы в /proc:

$ ls -l /proc/self/ns/
lrwxrwxrwx 1 m m 0 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 m m 0 ipc    -> 'ipc:[4026531839]'
lrwxrwxrwx 1 m m 0 mnt    -> 'mnt:[4026531841]'
lrwxrwxrwx 1 m m 0 net    -> 'net:[4026531840]'
lrwxrwxrwx 1 m m 0 pid    -> 'pid:[4026531836]'
lrwxrwxrwx 1 m m 0 user   -> 'user:[4026531837]'
lrwxrwxrwx 1 m m 0 uts    -> 'uts:[4026531838]'

Числа в скобках — inode-номера. Два процесса в одном namespace имеют одинаковый номер; это самый быстрый способ проверить, действительно ли изоляция есть:

$ readlink /proc/self/ns/net /proc/$(pgrep -f nginx | head -1)/ns/net
net:[4026531840]
net:[4026532296]      # разные — nginx в своём сетевом namespace

Три системных вызова управляют всем: clone() с флагами (создать процесс сразу в новых), unshare() (перевести себя в новые), setns() (войти в существующий по файловому дескриптору — так работает nsenter и docker exec).

Практика: собрать «контейнер» голыми руками

# Сетевой namespace: полностью отдельный стек
$ sudo ip netns add sandbox
$ sudo ip netns exec sandbox ip link show
1: lo: <LOOPBACK> mtu 65536 state DOWN            # только loopback, и тот опущен
$ sudo ip netns exec sandbox ss -tlnp              # портов не видно вообще

# PID namespace: наш процесс становится PID 1
$ sudo unshare --pid --fork --mount-proc bash
# ps aux
USER  PID %CPU %MEM    VSZ   RSS TTY STAT START   TIME COMMAND
root    1  0.0  0.0  12144  3452 pts/2 S   14:22  0:00 bash
root   12  0.0  0.0  14024  3320 pts/2 R+  14:22  0:00 ps aux

--mount-proc здесь обязателен: без него /proc останется смонтированным из родительского namespace и ps покажет все процессы системы. Классическая ошибка «PID namespace не работает» — это почти всегда забытый remount /proc.

User namespace — тот, что меняет всё

CLONE_NEWUSER — единственный namespace, который непривилегированный пользователь может создать сам. Внутри него процесс получает полный набор capabilities, но только по отношению к объектам, созданным внутри этого же namespace.

$ id -u
1000
$ unshare --user --map-root-user bash
# id -u
0
# cat /proc/self/uid_map
         0       1000          1
# touch /etc/test
touch: cannot touch '/etc/test': Permission denied

Мы «стали root», но /etc принадлежит настоящему uid 0 из родительского namespace, а наш «root» отображён на uid 1000 — доступа нет. Именно так работают rootless-контейнеры (Podman, Buildah, rootless Docker): диапазоны uid берутся из /etc/subuid, а маппинг настраивает setuid-хелпер newuidmap:

$ grep $USER /etc/subuid /etc/subgid
/etc/subuid:m:100000:65536
/etc/subgid:m:100000:65536

$ podman unshare cat /proc/self/uid_map
         0       1000          1
         1     100000      65536

Trade-off, о котором надо знать честно. User namespaces радикально расширили поверхность атаки на ядро: непривилегированный пользователь получил возможность дёргать код, который раньше был доступен только root (монтирование, netfilter, USB). Значительная доля Linux LPE-эксплойтов последних лет начинается со строчки unshare(CLONE_NEWUSER|CLONE_NEWNET). Поэтому Debian и Ubuntu исторически держали переключатель:

# Ubuntu ≥ 23.10: ограничение через AppArmor
$ sysctl kernel.apparmor_restrict_unprivileged_userns
kernel.apparmor_restrict_unprivileged_userns = 1

# RHEL и старые Debian
$ sysctl user.max_user_namespaces kernel.unprivileged_userns_clone

Решение — компромисс: выключите — сломаете Podman, Flatpak, Chrome-sandbox и systemd-юниты с PrivateUsers=; оставите — примете риск. В многопользовательских системах и на хостах CI обычно ограничивают.

cgroups: изоляция ресурсов, а не прав

Namespaces отвечают на «что я вижу», cgroups — на «сколько я могу съесть». Это отдельная ось, и путать их не надо: cgroups не мешают процессу читать чужие файлы, зато мешают ему выесть всю память и уронить хост. Формально это защита не от кражи данных, а от отказа в обслуживании, — но в проде именно она спасает чаще.

cgroup v2 (единая иерархия, стабильна с 4.5, дефолт почти везде с 2021) смонтирована в /sys/fs/cgroup:

$ mount | grep cgroup2
cgroup2 on /sys/fs/cgroup type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate)

$ cat /sys/fs/cgroup/cgroup.controllers
cpuset cpu io memory hugetlb pids rdma misc

Создание группы с лимитами — это буквально работа с файлами:

$ sudo mkdir /sys/fs/cgroup/myapp

# делегировать контроллеры вниз: без этого файлы memory.max в дочерней не появятся
$ echo "+memory +pids +cpu" | sudo tee /sys/fs/cgroup/cgroup.subtree_control

$ echo "512M"    | sudo tee /sys/fs/cgroup/myapp/memory.max
$ echo "400M"    | sudo tee /sys/fs/cgroup/myapp/memory.high   # мягкий порог: throttle
$ echo "100"     | sudo tee /sys/fs/cgroup/myapp/pids.max      # анти-forkbomb
$ echo "50000 100000" | sudo tee /sys/fs/cgroup/myapp/cpu.max  # 0.5 ядра

$ echo $$ | sudo tee /sys/fs/cgroup/myapp/cgroup.procs         # поместить shell сюда

Разница memory.max и memory.high — практически важная. max — жёсткий предел: при превышении срабатывает cgroup-OOM-killer. high — мягкий: ядро начинает агрессивно вытеснять страницы и притормаживать аллокации, но процесс не убивает. Для сервисов с пиками правильная конфигурация — high заметно ниже max.

Наблюдение за давлением (PSI, ядро 4.20) — лучший сигнал для автоскейлинга, чем средняя загрузка:

$ cat /sys/fs/cgroup/myapp/memory.pressure
some avg10=12.43 avg60=8.21 avg300=2.11 total=1832919
full avg10=3.11  avg60=1.02 avg300=0.31 total=402118

$ cat /sys/fs/cgroup/myapp/memory.events
low 0
high 1204          # сколько раз упирались в мягкий порог
max 3
oom 0
oom_kill 0

Два правила cgroup v2, на которых спотыкаются:

  1. No internal processes. Процессы могут жить только в листовых узлах иерархии (кроме корня). Нельзя одновременно держать процессы в /sys/fs/cgroup/myapp и включать там контроллеры для дочерних групп.
  2. Делегирование через cgroup.subtree_control. Родитель явно перечисляет, какие контроллеры доступны детям. Ребёнок не может включить то, чего не дал родитель.

В реальности вручную с этим почти не работают: systemd управляет всей иерархией, и правильный способ — свойства юнита (MemoryMax=512M, CPUQuota=50%, TasksMax=100) либо systemd-run --scope -p MemoryMax=1G ... для разовых задач.

Как из этого собирают контейнер

Контейнер — не сущность ядра. Это соглашение о том, как одновременно применить namespaces, cgroups, capabilities, seccomp, LSM и pivot_root. Вот что делает runc при старте контейнера:

Порядок здесь не декоративный. seccomp применяется предпоследним, потому что сам runc до execve нуждается в вызовах, которые фильтр запретит. no_new_privs ставится до seccomp, потому что это условие ядра для непривилегированной загрузки фильтра. pivot_root предпочтительнее chroot, потому что chroot тривиально обходится (классический трюк с открытым fd на каталог).

Контейнер — не граница безопасности

Это надо повторить прямым текстом, потому что архитектурные решения строят на обратном предположении. Все контейнеры на хосте делят одно ядро. Поверхность атаки — ~350 системных вызовов плюс весь код драйверов, доступный через /proc, /sys и ioctl. Одна ошибка в ядре — и изоляция снимается целиком:

  • CVE-2019-5736 — перезапись бинарника runc на хосте через /proc/self/exe изнутри контейнера.
  • CVE-2022-0847 (Dirty Pipe) — запись в произвольные страницы page cache, включая read-only bind-mount из хоста.
  • CVE-2024-21626 (Leaky Vessels) — утечка файлового дескриптора рабочего каталога наружу из контейнера.

Следствия для архитектуры:

  • Не размещайте недоверенный код разных арендаторов в контейнерах на одном хосте. Для мультитенантности — виртуальные машины (Firecracker, Kata Containers) или перехват syscall’ов в userspace (gVisor, где ядро Linux частично переписано на Go и работает в пользовательском пространстве).
  • --privileged отключает почти всё описанное выше разом. Он эквивалентен root на хосте, точка.
  • Монтирование /var/run/docker.sock внутрь контейнера — тоже root на хосте: имея доступ к сокету, можно запустить привилегированный контейнер с примонтированным /.
  • Не запускайте процессы внутри контейнера от uid 0: USER app в Dockerfile плюс runAsNonRoot: true в Kubernetes.

Подробнее о спектре «контейнер — микро-VM — гипервизор» — в статье Виртуализация и контейнеры.

BSD: jails и Capsicum

FreeBSD пришла к изоляции раньше и другим путём. Jail (FreeBSD 4.0, 2000, статья Poul-Henning Kamp и Robert Watson) — это не композиция из восьми механизмов, а один системный вызов и один целостный концепт: «поддерево ФС + набор адресов + собственный root, чьи привилегии ядро урезает по единому набору правил».

# /etc/jail.conf
web {
    path = "/jails/web";
    host.hostname = "web.example.com";
    vnet;                                # собственный сетевой стек целиком
    vnet.interface = "epair0b";
    allow.raw_sockets = 0;
    allow.mount = 0;
    allow.sysvipc = 0;
    exec.start = "/bin/sh /etc/rc";
    exec.stop  = "/bin/sh /etc/rc.shutdown";
    persist;
}
# freebsd
$ sudo service jail start web
$ jls
   JID  IP Address      Hostname            Path
     1  -               web.example.com     /jails/web
$ jexec 1 /bin/sh

Отличия от Linux-контейнеров, важные при выборе платформы:

  • Целостность модели. В Linux забыть один флаг = дыра. В FreeBSD jail — это одна сущность с одним набором правил allow.*; трудно случайно оставить половину изоляции.
  • VNET даёт полноценный отдельный сетевой стек, включая свои pf-правила и таблицы маршрутизации, — аналог CLONE_NEWNET, но появился раньше и интегрирован глубже.
  • Иерархия jail-в-jail поддерживается штатно (children.max).
  • ZFS-датасеты делегируются в jail (jailed=on), что даёт арендатору собственные снапшоты — комбинация, которой в Linux до сих пор нет из коробки.
  • Нет образов и реестра. Экосистемы уровня OCI и Kubernetes у jails нет, и это главная практическая причина, по которой индустрия ушла в Linux.

Capsicum (FreeBSD 9.0, 2012; статья USENIX Security 2010) — вторая идея FreeBSD, ортогональная jails: capability-модель на файловых дескрипторах. Процесс входит в «capability mode» и после этого теряет доступ ко всему глобальному пространству имён — открыть файл по пути нельзя вообще, работать можно только с уже открытыми fd и производными от них.

#include <sys/capsicum.h>

int fd = open("/var/data/input.bin", O_RDONLY);

cap_rights_t rights;
cap_rights_init(&rights, CAP_READ, CAP_FSTAT);   /* только чтение по этому fd */
cap_rights_limit(fd, &rights);

if (cap_enter() < 0) err(1, "cap_enter");        /* необратимо */
/* Отсюда open("/etc/passwd") вернёт ECAPMODE. */

Это строже seccomp: ограничение выражено не в терминах «какие вызовы», а в терминах «к каким объектам», и не имеет проблемы TOCTOU с путями.

OpenBSD пошёл третьим путём — минимализм и удобство для программиста. pledge(2) (5.9, 2015) объявляет, какими классами функциональности программа собирается пользоваться дальше:

#include <unistd.h>

/* Фаза старта: читаем конфиг, открываем сокет, пишем pid-файл. */
if (pledge("stdio rpath wpath cpath inet unix dns proc exec", NULL) == -1)
    err(1, "pledge");

setup_listeners();
read_config();

/* После инициализации сужаемся до минимума — обратно расширить нельзя. */
if (pledge("stdio inet", NULL) == -1)
    err(1, "pledge");

/* Любая попытка open() отсюда убьёт процесс сигналом SIGABRT. */

unveil(2) (6.4, 2018) дополняет его файловой частью:

unveil("/etc/myapp", "r");      /* только чтение */
unveil("/var/log/myapp", "rw"); /* чтение и запись */
unveil(NULL, NULL);             /* зафиксировать список — дальше ничего не добавить */

Сравните объём кода: seccomp-фильтр выше занимает 20 строк BPF и требует библиотеки, pledge — одна строка, читаемая вслух. Именно поэтому в OpenBSD pledge реально применён почти во всей базовой системе, а в Linux seccomp-профили пишут единицы. Дизайн, которым пользуются, побеждает дизайн, который мощнее. Подробнее о философии семейства — в статье Семейство BSD.

Solaris Zones (2005) заслуживают упоминания как исторически самая зрелая реализация: zones плюс RBAC с ролями и профилями плюс privileges (аналог capabilities, но детальнее — их около 80) плюс интеграция с ZFS и Crossbow. Многое из того, что Linux собирал по кускам десять лет, там было единой архитектурой в 2005-м; наследует это illumos.

macOS и Windows: другие модели

XNU

macOS унаследовал от TrustedBSD ту же MAC-инфраструктуру, что дала Linux идею LSM, и построил поверх несколько слоёв:

  • Sandbox (исторически Seatbelt) — профили на диалекте Scheme в /System/Library/Sandbox/Profiles/, применяемые через sandbox_init(3). Всё в App Store работает под App Sandbox обязательно.
  • Entitlements — подписанные ключи в бинарнике, разрешающие конкретные возможности. Посмотреть: codesign -d --entitlements :- /Applications/Safari.app.
  • SIP (System Integrity Protection), с 10.11 — ограничивает даже root: /System, /usr (кроме /usr/local) и /bin неприкосновенны, а критичные процессы нельзя трассировать. Управление — только из recovery через csrutil. Это признание того же факта, что и capabilities: всемогущий root — плохая идея.
  • TCC (Transparency, Consent, Control) — те самые диалоги «приложение хочет доступ к камере/микрофону/Документам». Состояние — в SQLite-базе ~/Library/Application Support/com.apple.TCC/TCC.db, читать напрямую нельзя (защищено самим TCC).
  • Signed System Volume с 11.0 — корневая ФС смонтирована read-only и криптографически верифицируется целиком через дерево хешей.

Windows NT

Модель NT спроектирована с нуля в 1993-м и с Unix почти не пересекается — она изначально ACL-ориентированная:

  • SID (Security Identifier) вместо числового uid: S-1-5-21-…-1001.
  • Access token — прикреплён к процессу, содержит SID пользователя, SID групп, список привилегий (SeDebugPrivilege, SeBackupPrivilege — прямой аналог capabilities, появившийся на 6 лет раньше) и уровень целостности.
  • Security descriptor на каждом объекте (не только на файлах — на процессах, мьютексах, ключах реестра, каналах) с DACL из ACE-записей allow/deny и SACL для аудита. Это заметно выразительнее 12 бит Unix.
  • Mandatory Integrity Control (Vista) — уровни Low/Medium/High/System. Процесс не может писать в объект более высокого уровня. Вкладки Internet Explorer и позже браузерные рендереры работают на Low.
  • UAC — администратор получает два токена: обычный и с фильтрацией; повышение переключает на полный.
  • AppContainer (Windows 8) — изоляция по SID-возможностям, база для UWP и для sandbox современных браузеров.
  • Job objects — примерный аналог cgroups: лимиты памяти, CPU, числа процессов.

Практический вывод для кроссплатформенного кода: не пытайтесь эмулировать chmod на Windows. Модели не изоморфны — Unix-права выражаются через ACL, но не наоборот. Подробнее — Другие ОС.

Защита внутри процесса

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

$ checksec --file=/usr/local/bin/myapp
RELRO           STACK CANARY   NX     PIE      RPATH   FORTIFY
Full RELRO      Canary found   NX on  PIE      No RPATH  Yes
  • ASLR — рандомизация адресов. Проверить: sysctl kernel.randomize_va_space (должно быть 2). Работает для исполняемого файла только если тот собран как PIE (-fPIE -pie); иначе базовый адрес фиксирован и ROP-цепочки строятся тривиально.
  • NX / W^X — страница либо исполняемая, либо записываемая. OpenBSD обеспечивает это жёстче всех, вплоть до запрета mprotect(PROT_WRITE|PROT_EXEC) через W^X-политику на уровне ФС.
  • Stack canary-fstack-protector-strong, разумный дефолт.
  • RELRO-Wl,-z,relro,-z,now делает GOT read-only после старта, закрывая перезапись указателей на функции.
  • FORTIFY_SOURCE-D_FORTIFY_SOURCE=3 (glibc 2.35+) добавляет проверки размеров в memcpy, sprintf и родственные, когда размер известен компилятору.
  • CFIclang -fsanitize=cfi с LTO; в ядре Linux CONFIG_CFI_CLANG включён по умолчанию на arm64 в Android с 2021 года.

Со стороны ядра: SMEP/SMAP (ядро не может исполнять код и читать данные пользовательских страниц), KPTI (разделение таблиц страниц как обход Meltdown, ценой ~5–30 % на syscall-интенсивных нагрузках), lockdown-режим (запрет способов записи в память ядра даже для root при Secure Boot). Про механику страниц и таблиц — в статье Управление памятью.

Собираем всё вместе: hardening одного systemd-юнита

Теория становится практикой в одном файле. systemd — это, помимо прочего, готовый фронтенд ко всем механизмам этой статьи.

[Unit]
Description=Myapp API
After=network.target

[Service]
ExecStart=/usr/local/bin/myapp
User=myapp
Group=myapp

# --- привилегии ---
NoNewPrivileges=yes
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE

# --- файловая система (mount namespace) ---
ProtectSystem=strict          # весь / только на чтение
ProtectHome=yes               # /home, /root, /run/user не видны
PrivateTmp=yes                # собственный /tmp
ReadWritePaths=/var/lib/myapp
StateDirectory=myapp

# --- ядро и устройства ---
PrivateDevices=yes            # только /dev/null, zero, random, tty
ProtectKernelTunables=yes     # /proc/sys, /sys только на чтение
ProtectKernelModules=yes      # запрет загрузки модулей
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible         # чужие процессы не видны в /proc
RestrictNamespaces=yes        # нельзя создавать namespaces
LockPersonality=yes

# --- память и вызовы ---
MemoryDenyWriteExecute=yes    # ни одной W+X страницы (несовместимо с JIT!)
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
SystemCallArchitectures=native
RestrictRealtime=yes
RestrictSUIDSGID=yes

# --- сеть ---
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
IPAddressDeny=any
IPAddressAllow=10.0.0.0/8 127.0.0.1

# --- ресурсы (cgroup v2) ---
MemoryMax=512M
MemoryHigh=400M
CPUQuota=200%
TasksMax=256

[Install]
WantedBy=multi-user.target

У systemd есть встроенный аудитор, который оценивает юнит и объясняет каждый пункт:

$ systemd-analyze security myapp.service
NAME                         DESCRIPTION                              EXPOSURE
✓ PrivateNetwork=            Service has access to the host's network     0.5
✗ User=/DynamicUser=         Service runs under a static non-root user    0.3
✓ CapabilityBoundingSet=~CAP_SYS_ADMIN  Service has no administrator...   0.0
...
→ Overall exposure level for myapp.service: 2.1 OK 🙂

Практическое замечание: MemoryDenyWriteExecute=yes ломает любой JIT — Node.js, JVM, PyPy, .NET, современные браузерные движки. RestrictNamespaces=yes ломает всё, что запускает контейнеры или использует sandbox. Включайте по одному и проверяйте, а не копируйте блок целиком.

Типичные заблуждения

«Мы запускаем контейнеры, значит изолированы». Контейнер защищает от случайного взаимовлияния, а не от целенаправленной атаки. Общее ядро — общая судьба.

«root внутри контейнера — это же не настоящий root». Без user namespace это ровно uid 0 хоста, просто с урезанным набором capabilities. Проброс тома, ошибка в рантайме, --privileged — и он становится настоящим.

«chmod 777 решит проблему прав». Если мешает SELinux, не решит: getenforce, ausearch -m AVC. Если мешает namespace — тоже не решит, потому что дело не в правах. И в обоих случаях 777 — это отдельная новая уязвимость.

«setuid-бинарник безопасен, если код корректный». Ядро при setuid-exec переходит в secure-execution mode (AT_SECURE=1), сбрасывает LD_PRELOAD и LD_LIBRARY_PATH, но остаётся всё остальное окружение, дескрипторы, ulimits, локали. PwnKit (CVE-2021-4034 в pkexec) — эксплуатация окружения при пустом argv. Не пишите новые setuid-бинарники; используйте capabilities или сервис, доступный через unix-сокет.

«seccomp запретит доступ к файлу». Не запретит: фильтр не читает память по указателю. Пути — это LSM, Landlock или unveil.

«cgroups — это безопасность». Это защита от DoS. Данные они не защищают.

«у нас нет root-процессов, значит capabilities не нужны». Нужны в обратную сторону: CapabilityBoundingSet= в юните гарантирует, что процесс не поднимет привилегии даже при успешной эксплуатации setuid-бинарника или ошибки в рантайме.

«ASLR всё закроет». Без PIE рандомизации основного образа нет; при утечке одного адреса вся рандомизация обесценивается; на 32 битах энтропии слишком мало для реального сопротивления брутфорсу.

Мини-итог

Уровни защиты складываются, а не заменяют друг друга, и каждый закрывает свой класс угроз:

Механизм Отвечает на вопрос Обходится, если
DAC (mode, ACL) кто владеет объектом атакующий получил нужный uid
capabilities какие привилегированные действия можно выдан CAP_SYS_ADMIN
MAC (SELinux/AppArmor) что позволено этой программе политика в permissive или слишком широка
seccomp какие syscall’ы доступны нужный вызов не отфильтрован
namespaces что процесс вообще видит уязвимость в ядре, проброс из хоста
cgroups сколько ресурсов можно съесть лимит не выставлен
гипервизор доступ к железу побег из VM (редко и дорого)

Практический минимум для любого сервиса: не root; NoNewPrivileges=yes; пустой или минимальный CapabilityBoundingSet; read-only корень с явным списком записываемых путей; лимиты памяти и задач; MAC-профиль в enforce, а не permissive. Это десять строк в юните и почти нулевая стоимость — и они закрывают подавляющее большинство реальных сценариев эскалации.

Связанные темы трека: механика перехода в ядро и стоимость проверок — Системные вызовы и IPC; архитектурная причина существования границы «ядро/пользователь» — Архитектуры ядер; наблюдение за отказами и профилирование — Наблюдаемость и производительность.

Источники

Книги:

  • Michael Kerrisk. The Linux Programming Interface — главы 9, 38, 39 про uid/gid, setuid и capabilities остаются лучшим изложением на русском и английском.
  • Robert Love. Linux Kernel Development, 3rd ed. — обзор LSM и модели прав в ядре.
  • Marshall Kirk McKusick et al. The Design and Implementation of the FreeBSD Operating System, 2nd ed. — jails, Capsicum, MAC framework из первых рук.
  • Pavel Yosifovich, Mark Russinovich et al. Windows Internals, part 1, 7th ed. — глава «Security» про токены, ACL и integrity levels.
  • Jonathan Levin. MacOS and iOS Internals, Volume III: Security & Insecurity — sandbox, entitlements, AMFI.

Статьи и спецификации:

Документация и man-страницы:

Что дальше

Мы разбирали защиту работающей системы, но всё это опирается на предположение, что само ядро и initramfs загрузились нескомпрометированными. Пора посмотреть, что происходит до первого процесса: как прошивка находит загрузчик, чем UEFI отличается от BIOS, зачем нужен initramfs и как Secure Boot связывает цепочку доверия от чипа до systemd.

Следующая статья: Загрузка системы: BIOS и UEFI, MBR/GPT, GRUB, systemd-boot, initramfs.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов