Capabilities и namespaces — разрезаем ‘всемогущество root’ на кусочки
Старый Unix имел бинарное разделение: «либо ты root и можешь всё, либо ты не root и не можешь почти ничего привилегированного». Это плохо работает в современной инфраструктуре. Программе ping нужно открывать raw socket — но зачем ей право монтировать файловые системы? Сервису-биндеру 80 порта нужен только bind privileged port — зачем ему доступ ко всему сразу?
Linux capabilities — ответ: разбить полномочия root на ~40 независимых способностей. Программа может получить CAP_NET_RAW (raw socket) и больше ничего — никакого root, никакой опасности. А namespaces — это другой механизм изоляции: даёт процессу свой взгляд на pids, mounts, network, users. На их сочетании построены Docker, Kubernetes, systemd-nspawn — все современные контейнеры.
Зачем разрезать root на capabilities
Стандартный пример: /usr/bin/ping. Чтобы отправить ICMP-пакет, нужен raw socket — это привилегированная операция. Исторически решение было: сделать ping setuid root. Запустил ping — EUID=0, можешь всё.
Проблема: если в ping есть buffer overflow, атакующий получает root. А что нужно ping’у? Только одна возможность — открыть raw socket. Всё остальное (read /etc/shadow, kill процессы, mount FS) ему не нужно. Setuid root даёт всё, а нужно лишь капельку.
Capabilities решают это:
ls -l /usr/bin/ping
# -rwxr-xr-x 1 root root 76712 May 18 12:00 /usr/bin/ping
# Заметили: НЕТ setuid (s в правах). Просто rwxr-xr-x.
getcap /usr/bin/ping
# /usr/bin/ping cap_net_raw=ep
Здесь cap_net_raw=ep — ping имеет только одну capability (CAP_NET_RAW), и только она нужна. Атакующий через уязвимость в ping получит… возможность отправлять raw сокеты. И всё. Не root.
Список основных capabilities
Linux 6+ имеет ~40 capabilities. Из часто встречающихся:
| Capability | Что даёт |
|---|---|
CAP_NET_BIND_SERVICE | Bind на privileged ports (порт меньше 1024) |
CAP_NET_RAW | Открывать raw и packet сокеты (ping, tcpdump) |
CAP_NET_ADMIN | Настраивать network: routes, iptables, interfaces |
CAP_SYS_ADMIN | Огромный набор: mount, swap, namespaces — «маленький root» |
CAP_SYS_PTRACE | ptrace других процессов |
CAP_SYS_TIME | Менять системное время |
CAP_SYS_NICE | Менять nice/priority других процессов |
CAP_SYS_MODULE | Загружать kernel modules |
CAP_KILL | Слать сигналы чужим процессам |
CAP_DAC_OVERRIDE | Игнорировать DAC (rwx) проверки — эквивалент root для файлов |
CAP_CHOWN | Менять владельца файла |
CAP_SETUID | setuid() в произвольный UID |
CAP_AUDIT_WRITE | Писать в audit log |
CAP_SYS_ADMIN — особенный. Это «catch-all», в неё запихали столько всего, что её часто называют «new root». Никогда не давайте её сервисам без острой необходимости.
# Все capabilities ядра:
capsh --print
# Current: cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,...
# Документация:
man capabilities # большой man с полным списком
Как capabilities работают в процессе
У процесса есть НЕСКОЛЬКО наборов capabilities:
- Permitted — что процесс ИМЕЕТ ПРАВО иметь.
- Effective — что СЕЙЧАС используется при permission checks.
- Inheritable — что передастся после exec в новую программу.
- Ambient — наследуется без файловых capabilities (новый механизм для unprivileged scripts).
- Bounding — максимум, который процесс может когда-либо иметь.
Эти наборы видны в /proc/PID/status:
cat /proc/$$/status | grep ^Cap
# CapInh: 0000000000000000
# CapPrm: 0000000000000000
# CapEff: 0000000000000000
# CapBnd: 000001ffffffffff
# CapAmb: 0000000000000000
# Декодировать:
capsh --decode=000001ffffffffff
# 0x000001ffffffffff=cap_chown,cap_dac_override,...
Обычный пользовательский процесс имеет пустые Eff/Prm — никаких эффективных capabilities. Только Bound широкий (потенциально может получить, если запустить нужный setuid-binary или из-под root).
Корневой процесс (root) имеет полный набор по умолчанию.
File capabilities: вместо setuid
Можно установить capabilities на исполняемый файл — они применятся к запущенному процессу:
# Дать локальной программе capability bind privileged port:
sudo setcap 'cap_net_bind_service=ep' /usr/local/bin/myapp
# Проверить:
getcap /usr/local/bin/myapp
# /usr/local/bin/myapp cap_net_bind_service=ep
# Теперь myapp может биндиться на порт 80 без setuid:
/usr/local/bin/myapp --port 80 # works! без root
=ep означает: добавить и в effective, и в permitted при запуске. +ep — алиас.
Это золотой паттерн для bind privileged port: вместо запуска сервиса под root + drop privileges, просто дать ему точечную capability.
# Найти все файлы с capabilities в системе:
sudo getcap -r / 2>/dev/null
# /usr/bin/ping cap_net_raw=ep
# /usr/sbin/tcpdump cap_net_admin,cap_net_raw=eip
# /usr/bin/mtr cap_net_raw=ep
Capabilities в systemd
systemd-units могут декларативно требовать capabilities:
[Service]
ExecStart=/usr/bin/myapp
User=appuser
Group=appuser
# Capability set -- что максимум может иметь:
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# Не получит новых capabilities через exec:
NoNewPrivileges=yes
# Дать ambient capability (наследуется в exec):
AmbientCapabilities=CAP_NET_BIND_SERVICE
Запуская сервис, systemd конфигурирует наборы capabilities заранее. Сервис стартует под appuser (не root!), но имеет CAP_NET_BIND_SERVICE — значит может биндиться на 80/443.
Это правильный современный паттерн запуска сетевых сервисов.
Что такое namespaces
Namespaces — другой механизм. Это изоляция представления о ресурсах. Процесс в своём namespace видит свой набор ресурсов, не зная о других.
Linux имеет восемь namespaces:
| Namespace | Что изолирует |
|---|---|
pid | PID-таблица: процесс видит только своих, init = PID 1 |
mnt | Mount table: своё представление о смонтированных FS |
net | Сеть: свои interfaces, routes, sockets |
uts | hostname, domainname |
ipc | SysV IPC, POSIX message queues |
user | UID/GID mapping: внутри UID=0, снаружи 1000 |
cgroup | Cgroup hierarchy |
time | Системное время (новое, Linux 5.6+) |
Контейнер — это процесс, запущенный во всех этих namespaces одновременно, плюс cgroups для лимитов ресурсов, плюс LSM (SELinux/AppArmor) для дополнительных проверок.
Поиграть с namespaces
unshare — утилита, создающая новый namespace и запускающая процесс в нём.
# Создать новый PID namespace -- внутри будем PID 1:
sudo unshare --pid --fork --mount-proc bash
# В новом bash:
ps aux
# USER PID CMD
# root 1 bash
# root 2 ps aux
# Вы PID 1! /proc монтирован заново, только эти процессы видны.
exit
# Network namespace -- своя сеть, никаких интерфейсов:
sudo unshare --net bash
# Внутри:
ip link
# Только lo (loopback)
exit
# UTS namespace -- свой hostname:
sudo unshare --uts bash
hostname container-test
hostname # container-test внутри
# В другом терминале на хосте:
# hostname # старое имя -- видит хост
exit
# User namespace -- внутри вы 'root' (UID 0), снаружи обычный пользователь:
unshare --user --map-root-user bash
id
# uid=0(root) gid=0(root) groups=0(root)
# При этом за пределами вы UID 1000.
exit
Последнее особенно интересно: user namespace позволяет «получить root» внутри namespace без реальных привилегий снаружи. Внутри вы можете делать всё, что обычно требует root (chmod чужих файлов, например), но только над ресурсами, которые мапятся в ваш UID хоста. Это безопасный root.
Rootless Docker и user namespaces: практический результат этого урока/proc/PID/ns: посмотреть namespaces процесса
ls -l /proc/$$/ns/
# lrwxrwxrwx 1 levoely levoely 0 May 18 12:00 cgroup -> 'cgroup:[4026531835]'
# lrwxrwxrwx 1 levoely levoely 0 May 18 12:00 ipc -> 'ipc:[4026531839]'
# lrwxrwxrwx 1 levoely levoely 0 May 18 12:00 mnt -> 'mnt:[4026531841]'
# lrwxrwxrwx 1 levoely levoely 0 May 18 12:00 net -> 'net:[4026531840]'
# lrwxrwxrwx 1 levoely levoely 0 May 18 12:00 pid -> 'pid:[4026531836]'
# lrwxrwxrwx 1 levoely levoely 0 May 18 12:00 user -> 'user:[4026531837]'
# lrwxrwxrwx 1 levoely levoely 0 May 18 12:00 uts -> 'uts:[4026531838]'
Числа в скобках — inode-номера namespace’ов. Два процесса в одном namespace имеют одинаковый номер.
# Сравнить namespace shell и какого-то Docker-контейнера:
readlink /proc/$$/ns/pid
# pid:[4026531836] -- хостовой
docker run -d --name test alpine sleep 3600
CONTAINER_PID=$(docker inspect -f '{{.State.Pid}}' test)
readlink /proc/$CONTAINER_PID/ns/pid
# pid:[4026532187] -- свой namespace
Docker под капотом — это unshare + cgroups + chroot
Когда вы делаете docker run alpine, под капотом происходит примерно:
- Подготовка корня: Создаётся overlay FS из слоёв image. Это будет корнем контейнера.
- clone() с CLONE_NEW флагами: Создаются namespaces — pid, mnt, net, uts, ipc, user (опционально).
- pivot_root в подготовленный корень.
- cgroups: настраиваются лимиты CPU/RAM/IO в
/sys/fs/cgroup/. - Capabilities: ограничиваются (drop CAP_SYS_ADMIN и др. — Docker по умолчанию dropу 14 capabilities).
- Seccomp profile: активируется фильтр syscalls (Docker блокирует ~50 опасных).
- AppArmor/SELinux profile (если есть).
- exec /entrypoint: запускается процесс контейнера.
Всё это — стандартные Linux примитивы. Никаких «магических контейнеров», обёрнутые в удобный CLI.
# Можно посмотреть, какие namespace у контейнера:
docker run --name test -d alpine sleep 3600
PID=$(docker inspect -f '{{.State.Pid}}' test)
ls -l /proc/$PID/ns/
# Сравнить с хостовыми /proc/1/ns -- разные inode
# Capabilities контейнера:
docker exec test grep ^Cap /proc/self/status
# CapBnd: ... (типично 00000000a80425fb = subset, без SYS_ADMIN и др.)
Реальный пример: запустить веб-сервер на порту 80 без root
«Старый» способ: сервис стартует как root, после bind делает setuid в www-data.
Современный способ:
# 1. Создать сервис под dedicated user, без root прав:
sudo useradd -r -s /sbin/nologin webapp
# 2. Дать бинарнику capability:
sudo setcap 'cap_net_bind_service=ep' /usr/local/bin/myapp
# 3. systemd unit:
cat << 'EOF' | sudo tee /etc/systemd/system/myapp.service
[Unit]
Description=My web app
After=network.target
[Service]
Type=simple
User=webapp
Group=webapp
ExecStart=/usr/local/bin/myapp --port 80
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
ProtectSystem=strict
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl start myapp
sudo systemctl status myapp
Сервис бежит под webapp (UID >1000), имеет только CAP_NET_BIND_SERVICE (для порта 80). Если его взломают — атакующий не получит root, не сможет читать чужие файлы, не сможет менять систему.
Попробуй сам
# 1. Какие capability имеют ваши команды:
getcap /usr/bin/ping /usr/sbin/tcpdump /usr/bin/su 2>/dev/null
# 2. Посмотреть текущие capability вашего shell:
cat /proc/$$/status | grep ^Cap
capsh --print
# 3. Все namespaces shell:
ls -l /proc/$$/ns/
# 4. Создать свой namespace и посмотреть:
sudo unshare --pid --fork --mount-proc bash
ps -ef # вы PID 1
exit
# 5. Network namespace:
sudo unshare --net bash
ip link # только lo
ping 8.8.8.8 # фейл, нет сетевых интерфейсов
exit
# 6. User namespace (без sudo!):
unshare --user --map-root-user bash
id # UID=0 внутри
ls -l /etc/shadow # всё равно не прочитаете -- mapping не покрывает root host
exit
# 7. Сравнить с Docker:
docker run --rm alpine cat /proc/self/status | grep Cap
# CapBnd: 00000000a80425fb -- Docker drop'нул многое
# 8. Снять capability с своего бинарника:
sudo cp /usr/bin/ping /tmp/myping
sudo setcap 'cap_net_raw=ep' /tmp/myping
getcap /tmp/myping
/tmp/myping 8.8.8.8 -c 1 # сработает без sudo
sudo setcap -r /tmp/myping
/tmp/myping 8.8.8.8 -c 1 # permission denied