12.4. Capabilities и privileged
Цели
После этого материала вы сможете:
- назвать, что даёт каждая из 14 capabilities по умолчанию, и оценить риск;
- перечислить, что именно включает
--privileged, — не «все привилегии», а конкретный список; - объяснить последствия
SYS_ADMIN,SYS_PTRACE,SYS_MODULEиDAC_OVERRIDE; - заменить
--privilegedточечными флагами и проверить, что приложение работает; - определить, какие capabilities реально нужны приложению, а не предполагать;
- найти избыточные привилегии в чужой конфигурации.
Предварительные знания
- 2.5. Capabilities — модель;
- 11.2. Runtime hardening — практика
--cap-drop=ALL; - 12.3. Пользователи и namespaces.
Демонстрации выполняются на собственной учебной машине.
Ключевые термины
| Термин | Объяснение |
|---|---|
bounding set | Предельный набор: процесс не может получить capability вне его |
effective set | Набор, действующий прямо сейчас |
--privileged | Флаг, снимающий большинство ограничений разом |
--device | Точечный доступ к конкретному устройству |
device cgroup | Механизм ограничения доступа к устройствам |
Теория
14 capabilities по умолчанию
| Capability | Что даёт | Риск |
|---|---|---|
CHOWN | Менять владельца файлов | Средний: подмена владельца в volume |
DAC_OVERRIDE | Обходить проверку прав доступа | Высокий |
FOWNER | Обходить проверки владельца | Средний |
FSETID | Не сбрасывать setuid при изменении файла | Средний |
KILL | Слать сигналы любым процессам в namespace | Низкий |
SETGID | Менять GID процесса | Средний |
SETUID | Менять UID процесса | Средний |
SETPCAP | Изменять наборы capabilities | Средний |
NET_BIND_SERVICE | Привязка к портам ниже 1024 | Низкий |
NET_RAW | Сырые сокеты: перехват и подделка пакетов | Высокий |
SYS_CHROOT | Менять корневой каталог | Низкий |
MKNOD | Создавать файлы устройств | Средний |
AUDIT_WRITE | Писать в журнал аудита | Низкий |
SETFCAP | Устанавливать capabilities на файлы | Средний |
Две выделены не случайно.
DAC_OVERRIDE обходит проверку прав файловой системы целиком. Процесс с ней читает и пишет любой файл в своей mount namespace, независимо от режима и владельца. В сочетании с монтированием каталога host это прямой путь к данным host.
NET_RAW позволяет создавать сырые сокеты: слушать чужой трафик в своей сети, подделывать пакеты, проводить подмену ARP. В сети Docker с несколькими сервисами это означает возможность перехватить обмен между ними.
Ни одна из четырнадцати не нужна типичному веб-приложению (урок 11.2).
Опасные capabilities вне набора по умолчанию
Их Docker не выдаёт, но их часто добавляют «чтобы заработало».
| Capability | Что даёт | Почему опасна |
|---|---|---|
SYS_ADMIN | Монтирование, pivot_root, настройка namespace | Практически равна root; известные способы побега |
SYS_PTRACE | Отладка чужих процессов, чтение их памяти | Извлечение секретов из памяти соседей |
SYS_MODULE | Загрузка модулей ядра | Полный контроль над host |
SYS_RAWIO | Прямой доступ к портам ввода-вывода и памяти | Обход всех ограничений |
NET_ADMIN | Настройка сети: интерфейсы, маршруты, netfilter | Перенаправление трафика, обход изоляции |
SYS_TIME | Изменение системного времени | Часы общие с host: ломает TLS, журналы, планировщики |
DAC_READ_SEARCH | Обход проверок при чтении | Чтение любых файлов; open_by_handle_at |
SYS_ADMIN — самая опасная. Она объединяет столько операций, что её выдача практически эквивалентна снятию изоляции. Документация ядра прямо отмечает её чрезмерную широту.
SYS_MODULE позволяет загрузить произвольный модуль ядра — то есть выполнить код в пространстве ядра host. Это конец любой изоляции.
Что включает --privileged
Флаг часто описывают как «все привилегии». Точный список шире:
| Что делает | Последствие |
|---|---|
| Выдаёт все capabilities | Включая SYS_ADMIN, SYS_MODULE, SYS_RAWIO |
| Снимает ограничение device cgroup | Доступ ко всем устройствам host |
Монтирует /sys на запись | Изменение параметров ядра |
| Отключает профиль seccomp | Все системные вызовы доступны |
| Отключает профиль AppArmor | Ограничения по путям сняты |
Разрешает mknod любых устройств | Создание доступа к дискам host |
Практический итог: container с --privileged может смонтировать диск host, загрузить модуль ядра и изменить параметры ядра. Изоляция остаётся только номинальной.
Легитимные применения существуют, но их немного:
| Случай | Есть ли альтернатива |
|---|---|
| Docker-in-Docker | Да: --privileged нужен, но лучше вовсе не использовать DinD |
| Работа с блочными устройствами | Да: --device |
| Сетевые инструменты | Да: --cap-add=NET_ADMIN,NET_RAW |
| Профилирование | Да: --cap-add=SYS_PTRACE |
| Монтирование FUSE | Да: --device /dev/fuse --cap-add SYS_ADMIN |
| systemd внутри container | Да: точечные монтирования |
В пяти случаях из шести точечные флаги решают задачу.
Порядок выбора привилегий
1. Запустить с --cap-drop=ALL
│
├── работает? → готово
│
└── не работает → определить, какой вызов упал
│
└── добавить ОДНУ capability → повторить
Ключевая ошибка — начинать с --privileged и «потом сузить». Сужение не происходит: конфигурация работает, и её оставляют.
Правильный порядок: от нуля вверх, по одной, с проверкой на каждом шаге.
Как определить нужную capability
| Симптом | Вероятная capability |
|---|---|
Operation not permitted при mount | SYS_ADMIN |
Permission denied при привязке к порту < 1024 | NET_BIND_SERVICE |
Operation not permitted при chown | CHOWN |
Operation not permitted при ptrace | SYS_PTRACE |
Ошибка при ip link, iptables | NET_ADMIN |
| Ошибка при создании сырого сокета | NET_RAW |
Operation not permitted при settimeofday | SYS_TIME |
Точный ответ даёт strace: он показывает, какой вызов вернул EPERM. Запускать его нужно из соседнего container'а с общим PID namespace, чтобы не менять исследуемый образ (урок 10.3).
--device вместо --privileged
docker run --device /dev/ttyUSB0 ...
docker run --device /dev/fuse --cap-add SYS_ADMIN ...
docker run --device /dev/nvidia0:/dev/nvidia0:rwm ...
Формат: --device источник[:цель[:права]], где права — комбинация r, w, m (создание узла).
| Подход | Доступные устройства |
|---|---|
| По умолчанию | Только базовые: null, zero, random, tty |
--device /dev/X | Плюс указанное |
--privileged | Все устройства host, включая диски |
Разница принципиальна: --privileged даёт доступ к /dev/sda, то есть к содержимому дисков host в обход файловой системы.
Внутренний механизм
Пять наборов capabilities
| Набор | Назначение |
|---|---|
Permitted | Что процесс может активировать |
Effective | Что действует сейчас |
Inheritable | Что сохраняется при execve |
Bounding | Предел: за него выйти нельзя |
Ambient | Наследуется непривилегированными программами |
--cap-drop=ALL очищает все наборы, включая Bounding. Это существенно: даже setuid-программа не сможет вернуть отброшенную capability.
Проверить: grep Cap /proc/self/status.
Почему SYS_ADMIN так широка
Исторически новые привилегированные операции добавляли в CAP_SYS_ADMIN, не заводя отдельных capability. В результате она покрывает монтирование, pivot_root, setns, часть операций с namespace, quotactl, настройку keyring и десятки других.
Документация ядра рекомендует избегать её выдачи именно по этой причине: невозможно предсказать полный набор доступных операций.
Команды и примеры
Что даёт каждая capability
mkdir -p /tmp/caps && cd /tmp/caps
cat > capcheck.py <<'PY'
"""Проверяет, какие привилегированные операции доступны процессу."""
from __future__ import annotations
import ctypes
import ctypes.util
import json
import os
import socket
from pathlib import Path
_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
CAP_NAMES = {
0: "CHOWN", 1: "DAC_OVERRIDE", 2: "DAC_READ_SEARCH", 3: "FOWNER", 4: "FSETID",
5: "KILL", 6: "SETGID", 7: "SETUID", 8: "SETPCAP", 9: "LINUX_IMMUTABLE",
10: "NET_BIND_SERVICE", 11: "NET_BROADCAST", 12: "NET_ADMIN", 13: "NET_RAW",
14: "IPC_LOCK", 15: "IPC_OWNER", 16: "SYS_MODULE", 17: "SYS_RAWIO",
18: "SYS_CHROOT", 19: "SYS_PTRACE", 20: "SYS_PACCT", 21: "SYS_ADMIN",
22: "SYS_BOOT", 23: "SYS_NICE", 24: "SYS_RESOURCE", 25: "SYS_TIME",
26: "SYS_TTY_CONFIG", 27: "MKNOD", 28: "LEASE", 29: "AUDIT_WRITE",
30: "AUDIT_CONTROL", 31: "SETFCAP",
}
def cap_field(name: str) -> int:
for line in Path("/proc/self/status").read_text().splitlines():
if line.startswith(name + ":"):
return int(line.split()[1], 16)
return 0
def cap_list(field: str) -> list[str]:
mask = cap_field(field)
return [n for b, n in sorted(CAP_NAMES.items()) if mask & (1 << b)]
def op_bind_low_port() -> str:
"""NET_BIND_SERVICE"""
s = socket.socket()
try:
s.bind(("0.0.0.0", 80))
return "ok"
except PermissionError:
return "EPERM"
except OSError as exc:
return f"errno {exc.errno}"
finally:
s.close()
def op_raw_socket() -> str:
"""NET_RAW"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP)
s.close()
return "ok"
except PermissionError:
return "EPERM"
except OSError as exc:
return f"errno {exc.errno}"
def op_mount() -> str:
"""SYS_ADMIN"""
target = "/tmp/_cap_mnt"
try:
os.makedirs(target, exist_ok=True)
except OSError:
return "нет /tmp"
ctypes.set_errno(0)
if _libc.mount(b"tmpfs", target.encode(), b"tmpfs", 0, None) == 0:
_libc.umount(target.encode())
return "ok"
return f"errno {ctypes.get_errno()}"
def op_settime() -> str:
"""SYS_TIME"""
class Timespec(ctypes.Structure):
_fields_ = [("tv_sec", ctypes.c_long), ("tv_nsec", ctypes.c_long)]
ts = Timespec()
_libc.clock_gettime(0, ctypes.byref(ts))
ctypes.set_errno(0)
return "ok" if _libc.clock_settime(0, ctypes.byref(ts)) == 0 \
else f"errno {ctypes.get_errno()}"
def op_chown() -> str:
"""CHOWN"""
p = Path("/tmp/_cap_chown")
try:
p.write_text("x")
os.chown(p, 1, 1)
p.unlink()
return "ok"
except PermissionError:
p.unlink(missing_ok=True)
return "EPERM"
except OSError as exc:
p.unlink(missing_ok=True)
return f"errno {exc.errno}"
def op_devices() -> dict[str, str]:
"""Доступ к устройствам host."""
out = {}
for dev in ("/dev/sda", "/dev/mem", "/dev/kmsg", "/dev/null"):
p = Path(dev)
if not p.exists():
out[dev] = "нет узла"
continue
try:
with open(dev, "rb") as f:
f.read(1)
out[dev] = "ЧИТАЕТСЯ"
except OSError as exc:
out[dev] = f"отказ: {exc.strerror}"
return out
if __name__ == "__main__":
print(json.dumps({
"uid": os.getuid(),
"effective": cap_list("CapEff"),
"bounding": cap_list("CapBnd"),
"всего_effective": len(cap_list("CapEff")),
"операции": {
"привязка к порту 80 (NET_BIND_SERVICE)": op_bind_low_port(),
"сырой сокет (NET_RAW)": op_raw_socket(),
"монтирование (SYS_ADMIN)": op_mount(),
"изменение времени (SYS_TIME)": op_settime(),
"смена владельца (CHOWN)": op_chown(),
},
"устройства": op_devices(),
}, ensure_ascii=False, indent=2))
PY
echo "═══ по умолчанию ═══"
docker run --rm -v "$PWD/capcheck.py:/c.py:ro" python:3.13-slim python /c.py \
| python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\" capabilities: {d['всего_effective']}\")
for op, res in d['операции'].items():
print(f' {op:<42} {res}')
print(' устройства:')
for dev, res in d['устройства'].items():
print(f' {dev:<12} {res}')
"
Ожидаемый вывод:
═══ по умолчанию ═══
capabilities: 14
привязка к порту 80 (NET_BIND_SERVICE) ok
сырой сокет (NET_RAW) ok
монтирование (SYS_ADMIN) errno 1
изменение времени (SYS_TIME) errno 1
смена владельца (CHOWN) ok
устройства:
/dev/sda нет узла
/dev/mem нет узла
/dev/kmsg нет узла
/dev/null ЧИТАЕТСЯ
Три операции доступны, две запрещены. Устройств host не видно вовсе — их узлы не созданы в container'е.
Обратите внимание на NET_RAW: сырой сокет создаётся. Это означает, что процесс может слушать трафик в своей сети — включая обмен между соседними container'ами (урок 8.2).
--cap-drop=ALL и точечное добавление
cd /tmp/caps
run_check() {
docker run --rm "$@" -v "$PWD/capcheck.py:/c.py:ro" python:3.13-slim python /c.py 2>/dev/null
}
brief() {
python3 -c "
import json, sys
d = json.load(sys.stdin)
ops = d['операции']
print(f\"{d['всего_effective']:>3} \" +
' '.join(f\"{k.split('(')[1].rstrip(')'):<18}={v:<9}\" for k, v in list(ops.items())[:3]))
"
}
printf ' %-26s %s\n' "конфигурация" "caps операции"
printf ' %s\n' "──────────────────────────────────────────────────────────────────────────────"
printf ' %-26s ' "по умолчанию"; run_check | brief
printf ' %-26s ' "--cap-drop=ALL"; run_check --cap-drop=ALL | brief
printf ' %-26s ' "+NET_BIND_SERVICE"; run_check --cap-drop=ALL --cap-add=NET_BIND_SERVICE | brief
printf ' %-26s ' "+NET_RAW"; run_check --cap-drop=ALL --cap-add=NET_RAW | brief
printf ' %-26s ' "+SYS_ADMIN"; run_check --cap-drop=ALL --cap-add=SYS_ADMIN | brief
Ожидаемый вывод:
конфигурация caps операции
──────────────────────────────────────────────────────────────────────────────
по умолчанию 14 NET_BIND_SERVICE =ok NET_RAW =ok SYS_ADMIN =errno 1
--cap-drop=ALL 0 NET_BIND_SERVICE =EPERM NET_RAW =EPERM SYS_ADMIN =errno 1
+NET_BIND_SERVICE 1 NET_BIND_SERVICE =ok NET_RAW =EPERM SYS_ADMIN =errno 1
+NET_RAW 1 NET_BIND_SERVICE =EPERM NET_RAW =ok SYS_ADMIN =errno 1
+SYS_ADMIN 1 NET_BIND_SERVICE =EPERM NET_RAW =ok SYS_ADMIN =ok
Таблица показывает точное соответствие: каждая capability включает ровно свою операцию.
Последняя строка неожиданна: с SYS_ADMIN заработал и сырой сокет. Причина — SYS_ADMIN покрывает часть операций других capabilities. Это и есть иллюстрация её чрезмерной широты: выдавая её ради монтирования, вы выдаёте и то, о чём не думали.
Что именно включает --privileged
cd /tmp/caps
echo "═══ сравнение по всем признакам ═══"
compare() {
local label="$1"; shift
local out
out="$(docker run --rm "$@" -v "$PWD/capcheck.py:/c.py:ro" python:3.13-slim python /c.py 2>/dev/null)"
local caps seccomp devices
caps="$(echo "$out" | python3 -c "import json,sys; print(json.load(sys.stdin)['всего_effective'])")"
seccomp="$(docker run --rm "$@" python:3.13-slim sh -c 'grep Seccomp: /proc/self/status | awk "{print \$2}"' 2>/dev/null)"
devices="$(docker run --rm "$@" python:3.13-slim sh -c 'ls /dev | wc -l' 2>/dev/null)"
local sda
sda="$(docker run --rm "$@" python:3.13-slim sh -c 'test -e /dev/sda && echo "ЕСТЬ" || echo "нет"' 2>/dev/null)"
local sysrw
sysrw="$(docker run --rm "$@" python:3.13-slim sh -c \
'touch /sys/kernel/probe 2>/dev/null && echo "запись" || echo "только чтение"' 2>/dev/null)"
printf ' %-18s %-6s %-9s %-9s %-8s %s\n' "$label" "$caps" "$seccomp" "$devices" "$sda" "$sysrw"
}
printf ' %-18s %-6s %-9s %-9s %-8s %s\n' "конфигурация" "caps" "seccomp" "узлов" "/dev/sda" "/sys"
printf ' %s\n' "─────────────────────────────────────────────────────────────────────────"
compare "по умолчанию"
compare "--cap-add=ALL" --cap-add=ALL
compare "--privileged" --privileged
Ожидаемый вывод:
конфигурация caps seccomp узлов /dev/sda /sys
─────────────────────────────────────────────────────────────────────────
по умолчанию 14 2 15 нет только чтение
--cap-add=ALL 32 2 15 нет только чтение
--privileged 32 0 187 ЕСТЬ запись
Таблица разбирает --privileged на составляющие.
--cap-add=ALL даёт все capabilities — и только их. Seccomp остаётся включённым (2), устройств по-прежнему 15, диска host не видно.
--privileged дополнительно: отключает seccomp (0), открывает 187 узлов устройств вместо 15, даёт доступ к /dev/sda и запись в /sys.
Это четыре независимых ослабления, а не одно. Именно поэтому «все привилегии» — неточное описание флага.
Что означает доступ к /dev/sda
cd /tmp/caps
echo "═══ устройства, видимые в разных режимах ═══"
printf ' по умолчанию: '
docker run --rm python:3.13-slim sh -c 'ls /dev | tr "\n" " "' 2>/dev/null | cut -c1-70
echo
printf ' с --privileged (первые 20): '
docker run --rm --privileged python:3.13-slim sh -c 'ls /dev | head -20 | tr "\n" " "' 2>/dev/null | cut -c1-70
echo
echo "═══ что это значит ═══"
cat <<'TXT'
/dev/sda — блочное устройство диска host. Доступ к нему означает
чтение файловой системы host в обход прав доступа: содержимое
дисков читается напрямую, минуя проверки ядра на уровне файлов.
Именно поэтому --privileged нельзя считать «просто расширенными
правами»: он снимает границу между container и хранилищем host.
TXT
echo "═══ точечная альтернатива ═══"
cat <<'TXT'
Нужен один конкретный узел:
docker run --device /dev/ttyUSB0 ... # последовательный порт
docker run --device /dev/fuse --cap-add SYS_ADMIN ... # FUSE
docker run --device /dev/dri:/dev/dri ... # видеоускоритель
Формат: --device источник[:цель[:права]], права из r, w, m.
TXT
Ожидаемый вывод:
═══ устройства, видимые в разных режимах ═══
по умолчанию: console core fd full mqueue null ptmx pts random shm stderr
с --privileged (первые 20): autofs bsg btrfs-control bus console core cpu_dma_latency
═══ что это значит ═══
/dev/sda — блочное устройство диска host. Доступ к нему означает
чтение файловой системы host в обход прав доступа: содержимое
дисков читается напрямую, минуя проверки ядра на уровне файлов.
Именно поэтому --privileged нельзя считать «просто расширенными
правами»: он снимает границу между container и хранилищем host.
═══ точечная альтернатива ═══
Нужен один конкретный узел:
docker run --device /dev/ttyUSB0 ... # последовательный порт
docker run --device /dev/fuse --cap-add SYS_ADMIN ... # FUSE
docker run --device /dev/dri:/dev/dri ... # видеоускоритель
Формат: --device источник[:цель[:права]], права из r, w, m.
По умолчанию — одиннадцать базовых узлов. С --privileged — вся /dev host, включая диски, память и шины.
SYS_PTRACE: чтение памяти соседнего процесса
cd /tmp/caps
cat > holder.py <<'PY'
"""Процесс, держащий «секрет» в памяти — цель для демонстрации."""
import os
import time
SECRET = "ЗНАЧЕНИЕ-В-ПАМЯТИ-ПРОЦЕССА-12345"
print(f"держу секрет в памяти, pid={os.getpid()}", flush=True)
while True:
_ = SECRET
time.sleep(1)
PY
cat > reader.py <<'PY'
"""Демонстрация: с SYS_PTRACE процесс читает память соседа.
Показывает, почему выдача SYS_PTRACE в общем PID namespace
означает доступ к секретам всех процессов рядом.
"""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
target = sys.argv[1] if len(sys.argv) > 1 else "1"
pattern = sys.argv[2].encode() if len(sys.argv) > 2 else b"SECRET"
mem_path = Path(f"/proc/{target}/mem")
maps_path = Path(f"/proc/{target}/maps")
if not maps_path.exists():
print(f" процесс {target} не найден")
sys.exit(1)
try:
regions = []
for line in maps_path.read_text().splitlines():
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S+)", line)
if m and "r" in m.group(3) and "heap" in line:
regions.append((int(m.group(1), 16), int(m.group(2), 16)))
found = []
with open(mem_path, "rb") as mem:
for start, end in regions[:5]:
try:
mem.seek(start)
data = mem.read(min(end - start, 4 * 1024 * 1024))
for match in re.finditer(pattern + rb"[A-Za-z0-9\-]*", data):
found.append(match.group().decode("utf-8", "replace"))
except OSError:
continue
if found:
print(f" ПРОЧИТАНО из памяти процесса {target}:")
for item in sorted(set(found))[:3]:
print(f" {item}")
else:
print(f" в heap процесса {target} совпадений не найдено")
except PermissionError as exc:
print(f" ОТКАЗ: {exc.strerror} — нет SYS_PTRACE")
sys.exit(0)
except OSError as exc:
print(f" ошибка: {exc.strerror}")
sys.exit(0)
PY
echo "═══ запускаем процесс с секретом ═══"
docker run -d --name holder -v "$PWD/holder.py:/h.py:ro" \
python:3.13-slim python -u /h.py > /dev/null
sleep 3
docker logs holder 2>&1 | tail -1 | sed 's/^/ /'
echo "═══ сосед БЕЗ SYS_PTRACE ═══"
docker run --rm --pid "container:holder" --cap-drop=ALL \
-v "$PWD/reader.py:/r.py:ro" \
python:3.13-slim python /r.py 1 "ЗНАЧЕНИЕ-В-ПАМЯТИ" 2>/dev/null
echo "═══ сосед С SYS_PTRACE ═══"
docker run --rm --pid "container:holder" --cap-drop=ALL --cap-add=SYS_PTRACE \
-v "$PWD/reader.py:/r.py:ro" \
python:3.13-slim python /r.py 1 "ЗНАЧЕНИЕ-В-ПАМЯТИ" 2>/dev/null
docker rm -f holder > /dev/null
Ожидаемый вывод:
═══ запускаем процесс с секретом ═══
держу секрет в памяти, pid=1
═══ сосед БЕЗ SYS_PTRACE ═══
ОТКАЗ: Operation not permitted — нет SYS_PTRACE
═══ сосед С SYS_PTRACE ═══
ПРОЧИТАНО из памяти процесса 1:
ЗНАЧЕНИЕ-В-ПАМЯТИ-ПРОЦЕССА-12345
Демонстрация показывает, почему SYS_PTRACE нельзя выдавать «для отладки» в общем окружении: она даёт чтение памяти любого процесса в том же PID namespace.
Практическое следствие: пароль, токен или ключ, находящийся в памяти соседнего процесса, становится доступен. Ни read-only корень, ни отсутствие секретов в окружении от этого не защищают.
Правильный подход к профилированию — выдавать SYS_PTRACE временно отдельному диагностическому container'у и только на время работы (урок 10.3).
DAC_OVERRIDE: обход прав файловой системы
cd /tmp/caps
mkdir -p protected
echo "содержимое, закрытое правами" > protected/secret.txt
chmod 000 protected/secret.txt
echo "═══ файл с правами 000 ═══"
ls -l protected/secret.txt | awk '{print " " $1 " " $9}'
echo "═══ от root БЕЗ DAC_OVERRIDE ═══"
docker run --rm --cap-drop=ALL -v "$PWD/protected:/p:ro" alpine:3.21 \
sh -c 'cat /p/secret.txt 2>&1 | tail -1' | sed 's/^/ /'
echo "═══ от root С DAC_OVERRIDE ═══"
docker run --rm --cap-drop=ALL --cap-add=DAC_OVERRIDE -v "$PWD/protected:/p:ro" alpine:3.21 \
sh -c 'cat /p/secret.txt 2>&1 | tail -1' | sed 's/^/ /'
echo "═══ вывод ═══"
cat <<'TXT'
DAC_OVERRIDE обходит проверку прав файловой системы целиком.
Она входит в набор Docker ПО УМОЛЧАНИЮ — то есть обычный container
от root читает файлы с правами 000 в любом смонтированном каталоге.
Ещё один довод за --cap-drop=ALL: набор по умолчанию не безобиден.
TXT
chmod 644 protected/secret.txt && rm -rf protected
Ожидаемый вывод:
═══ файл с правами 000 ═══
---------- secret.txt
═══ от root БЕЗ DAC_OVERRIDE ═══
cat: can't open '/p/secret.txt': Permission denied
═══ от root С DAC_OVERRIDE ═══
содержимое, закрытое правами
═══ вывод ═══
DAC_OVERRIDE обходит проверку прав файловой системы целиком.
Она входит в набор Docker ПО УМОЛЧАНИЮ — то есть обычный container
от root читает файлы с правами 000 в любом смонтированном каталоге.
Ещё один довод за --cap-drop=ALL: набор по умолчанию не безобиден.
TXT
Права 000 означают «никому», но DAC_OVERRIDE эту проверку не выполняет вовсе.
Существенно, что эта capability входит в набор по умолчанию: она есть у каждого container'а, запущенного без --cap-drop.
Определение нужных capabilities по симптому
cd /tmp/caps
cat > needs.py <<'PY'
"""Определяет, какие capabilities нужны приложению, по отказам операций."""
from __future__ import annotations
import ctypes
import ctypes.util
import json
import os
import socket
import sys
_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
# Операции, которые приложение реально выполняет
OPERATIONS = {
"привязка к порту 8000": ("NET_BIND_SERVICE?", lambda: _bind(8000)),
"привязка к порту 80": ("NET_BIND_SERVICE", lambda: _bind(80)),
"создание сырого сокета": ("NET_RAW", _raw),
"смена владельца файла": ("CHOWN", _chown),
"монтирование tmpfs": ("SYS_ADMIN", _mount),
}
def _bind(port: int) -> str:
s = socket.socket()
try:
s.bind(("0.0.0.0", port))
return "ok"
except PermissionError:
return "EPERM"
except OSError as exc:
return f"errno {exc.errno}"
finally:
s.close()
def _raw() -> str:
try:
socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP).close()
return "ok"
except PermissionError:
return "EPERM"
except OSError as exc:
return f"errno {exc.errno}"
def _chown() -> str:
from pathlib import Path
p = Path("/tmp/_needs_probe")
try:
p.write_text("x")
os.chown(p, 1, 1)
return "ok"
except PermissionError:
return "EPERM"
except OSError as exc:
return f"errno {exc.errno}"
finally:
p.unlink(missing_ok=True)
def _mount() -> str:
os.makedirs("/tmp/_needs_mnt", exist_ok=True)
ctypes.set_errno(0)
if _libc.mount(b"tmpfs", b"/tmp/_needs_mnt", b"tmpfs", 0, None) == 0:
_libc.umount(b"/tmp/_needs_mnt")
return "ok"
return f"errno {ctypes.get_errno()}"
if __name__ == "__main__":
results = {}
needed = []
for label, (cap, fn) in OPERATIONS.items():
res = fn()
results[label] = {"результат": res, "требует": cap}
if res != "ok" and not cap.endswith("?"):
needed.append(cap)
print(json.dumps({
"операции": results,
"не_хватает": sorted(set(needed)),
"рекомендация": ("--cap-drop=ALL" if not needed
else "--cap-drop=ALL " + " ".join(f"--cap-add={c}" for c in sorted(set(needed)))),
}, ensure_ascii=False, indent=2))
PY
echo "═══ определяем потребности приложения ═══"
docker run --rm --cap-drop=ALL -v "$PWD/needs.py:/n.py:ro" \
python:3.13-slim python /n.py | python3 -c "
import json, sys
d = json.load(sys.stdin)
for label, info in d['операции'].items():
mark = '✓' if info['результат'] == 'ok' else '✗'
print(f\" {mark} {label:<26} {info['результат']:<10} требует {info['требует']}\")
print()
print(f\" не хватает: {d['не_хватает'] or 'ничего'}\")
print(f\" рекомендация: {d['рекомендация']}\")
"
Ожидаемый вывод:
═══ определяем потребности приложения ═══
✓ привязка к порту 8000 ok требует NET_BIND_SERVICE?
✗ привязка к порту 80 EPERM требует NET_BIND_SERVICE
✗ создание сырого сокета EPERM требует NET_RAW
✗ смена владельца файла EPERM требует CHOWN
✗ монтирование tmpfs errno 1 требует SYS_ADMIN
не хватает: ['CHOWN', 'NET_BIND_SERVICE', 'NET_RAW', 'SYS_ADMIN']
рекомендация: --cap-drop=ALL --cap-add=CHOWN --cap-add=NET_BIND_SERVICE --cap-add=NET_RAW --cap-add=SYS_ADMIN
Первая строка — самая важная: привязка к порту 8000 работает без единой capability. Если приложение слушает порт выше 1024, NET_BIND_SERVICE ему не нужен.
Отсюда правильный ход: не добавлять capability под операцию, а изменить операцию. Порт 80 заменяется на 8000 плюс публикация -p 80:8000 (урок 8.3).
Список «не хватает» — это перечень того, что нужно пересмотреть, а не выдать.
Аудит избыточных привилегий
cd /tmp/caps
cat > audit-caps.sh <<'SH'
#!/usr/bin/env bash
# Поиск container'ов с избыточными привилегиями.
set -uo pipefail
DANGEROUS="SYS_ADMIN SYS_MODULE SYS_RAWIO SYS_PTRACE NET_ADMIN DAC_READ_SEARCH SYS_TIME"
issues=0
printf '\n %-24s %-12s %-10s %s\n' "CONTAINER" "PRIVILEGED" "CAP_DROP" "ДОБАВЛЕНО"
printf ' %s\n' "──────────────────────────────────────────────────────────────────────"
while read -r cid; do
[ -z "$cid" ] && continue
name="$(docker inspect "$cid" --format '{{.Name}}' | tr -d '/')"
priv="$(docker inspect "$cid" --format '{{.HostConfig.Privileged}}')"
drop="$(docker inspect "$cid" --format '{{json .HostConfig.CapDrop}}')"
add="$(docker inspect "$cid" --format '{{json .HostConfig.CapAdd}}')"
flag=""
[ "$priv" = "true" ] && { flag="⚠"; issues=$((issues + 1)); }
case "$drop" in
*ALL*) ;;
*) [ "$priv" != "true" ] && { flag="${flag}!"; issues=$((issues + 1)); } ;;
esac
for cap in $DANGEROUS; do
case "$add" in
*"$cap"*) flag="${flag}⚠"; issues=$((issues + 1)) ;;
esac
done
printf ' %-24s %-12s %-10s %s %s\n' \
"${name:0:24}" "$priv" "$(echo "$drop" | cut -c1-10)" \
"$(echo "$add" | cut -c1-24)" "$flag"
# Пояснения
[ "$priv" = "true" ] && printf ' ⚠ privileged: все caps, все устройства, seccomp отключён\n'
case "$drop" in
*ALL*) ;;
*) [ "$priv" != "true" ] && printf ' ! нет --cap-drop=ALL: действует набор по умолчанию (14 caps)\n' ;;
esac
for cap in $DANGEROUS; do
case "$add" in
*"$cap"*) printf ' ⚠ добавлена опасная capability: %s\n' "$cap" ;;
esac
done
done < <(docker ps -q 2>/dev/null)
printf '\n найдено проблем: %s\n' "$issues"
exit "$( [ "$issues" -eq 0 ] && echo 0 || echo 1 )"
SH
chmod +x audit-caps.sh
echo "═══ поднимаем container'ы с разными настройками ═══"
docker run -d --name good --cap-drop=ALL alpine:3.21 sleep 120 > /dev/null
docker run -d --name default-caps alpine:3.21 sleep 120 > /dev/null
docker run -d --name with-ptrace --cap-drop=ALL --cap-add=SYS_PTRACE alpine:3.21 sleep 120 > /dev/null
docker run -d --name privileged-one --privileged alpine:3.21 sleep 120 > /dev/null
./audit-caps.sh || true
docker rm -f good default-caps with-ptrace privileged-one > /dev/null
Ожидаемый вывод:
═══ поднимаем container'ы с разными настройками ═══
CONTAINER PRIVILEGED CAP_DROP ДОБАВЛЕНО
──────────────────────────────────────────────────────────────────────
privileged-one true null null ⚠
⚠ privileged: все caps, все устройства, seccomp отключён
with-ptrace false ["ALL"] ["SYS_PTRACE"] ⚠
⚠ добавлена опасная capability: SYS_PTRACE
default-caps false null null !
! нет --cap-drop=ALL: действует набор по умолчанию (14 caps)
good false ["ALL"] null
найдено проблем: 3
Аудит различает три уровня: --privileged, опасная добавленная capability и просто отсутствие --cap-drop=ALL.
Последняя строка — единственная без замечаний.
cd /tmp && rm -rf /tmp/caps
Практическое упражнение
Задание. Замените --privileged точечными привилегиями и докажите эквивалентность.
Требования:
- Показать, что
--privileged— это четыре независимых ослабления: capabilities, устройства, seccomp,/sys. - Взять приложение, которому нужен
--privileged, и определить минимальный набор флагов. - Показать, что с точечными флагами приложение работает так же.
- Показать, что точечный вариант не даёт того, что даёт
--privileged: доступа к диску host и отключения seccomp. - Продемонстрировать риск
SYS_PTRACE: чтение памяти соседнего процесса. - Написать аудит, различающий три уровня проблем.
Подсказки
Подсказка 1
Для пункта 2 начинайте с --cap-drop=ALL и добавляйте по одной, проверяя после каждой.
Подсказка 2
Хороший кандидат на замену --privileged — приложение, монтирующее tmpfs: ему нужен только SYS_ADMIN.
Подсказка 3
Пункт 4 проверяется наличием /dev/sda и значением поля Seccomp в /proc/self/status.
Решение
Показать решение
mkdir -p /tmp/capfull && cd /tmp/capfull
cat > app.py <<'PY'
"""Приложение, которому нужны привилегии: монтирует tmpfs для рабочих данных.
Реалистичный случай: сервису нужен изолированный раздел в памяти,
который он создаёт сам, а не получает от платформы.
"""
from __future__ import annotations
import ctypes
import ctypes.util
import json
import os
import sys
from pathlib import Path
_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
WORKDIR = "/workspace"
def mount_tmpfs(target: str, size_mb: int = 16) -> tuple[bool, str]:
os.makedirs(target, exist_ok=True)
opts = f"size={size_mb}m".encode()
ctypes.set_errno(0)
rc = _libc.mount(b"tmpfs", target.encode(), b"tmpfs", 0, opts)
if rc == 0:
return True, "смонтировано"
return False, f"errno {ctypes.get_errno()} (нужен SYS_ADMIN)"
def umount(target: str) -> None:
_libc.umount(target.encode())
def do_work(target: str) -> str:
p = Path(target) / "data.bin"
p.write_bytes(b"x" * 1024)
size = p.stat().st_size
p.unlink()
return f"записано и прочитано {size} байт"
if __name__ == "__main__":
ok, message = mount_tmpfs(WORKDIR)
result = {"монтирование": message, "работа": None}
if ok:
result["работа"] = do_work(WORKDIR)
# Проверяем, что это действительно tmpfs
fs = "?"
for line in Path("/proc/self/mountinfo").read_text().splitlines():
if f" {WORKDIR} " in line and " - " in line:
fs = line.split(" - ")[1].split()[0]
result["файловая_система"] = fs
umount(WORKDIR)
print(json.dumps(result, ensure_ascii=False))
sys.exit(0 if ok else 1)
PY
cat > inspect.py <<'PY'
"""Проверка четырёх составляющих --privileged."""
from __future__ import annotations
import json
import os
from pathlib import Path
CAP_COUNT_TOTAL = 41
def status(field: str) -> str:
for line in Path("/proc/self/status").read_text().splitlines():
if line.startswith(field + ":"):
return line.split(maxsplit=1)[1].strip()
return "?"
def caps_count() -> int:
raw = status("CapEff")
return bin(int(raw, 16)).count("1") if raw != "?" else 0
def devices() -> dict[str, object]:
dev = Path("/dev")
nodes = sorted(p.name for p in dev.iterdir()) if dev.is_dir() else []
block = [n for n in nodes if n.startswith(("sd", "nvme", "vd", "loop"))]
return {
"всего_узлов": len(nodes),
"блочные_устройства": block[:5],
"диск_host_доступен": bool(block),
}
def sys_writable() -> bool:
probe = Path("/sys/kernel/_probe")
try:
probe.touch()
probe.unlink()
return True
except OSError:
return False
if __name__ == "__main__":
print(json.dumps({
"capabilities": caps_count(),
"capabilities_из": CAP_COUNT_TOTAL,
"seccomp": status("Seccomp"),
"seccomp_расшифровка": {"0": "отключён", "2": "фильтрация"}.get(status("Seccomp"), "?"),
"устройства": devices(),
"sys_на_запись": sys_writable(),
}, ensure_ascii=False))
PY
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
insp() { docker run --rm "$@" -v "$PWD/inspect.py:/i.py:ro" \
python:3.13-slim python /i.py 2>/dev/null; }
runapp() { docker run --rm "$@" -v "$PWD/app.py:/a.py:ro" \
python:3.13-slim python /a.py 2>/dev/null; }
get() { python3 -c "
import json, sys
d = json.load(sys.stdin)
v = d
for k in '$1'.split('.'):
v = v[k]
print(v)
" 2>/dev/null; }
printf '\n═══ Требование 1: --privileged — четыре ослабления ═══\n'
printf ' %-18s %-8s %-10s %-8s %-12s %s\n' \
"конфигурация" "caps" "seccomp" "узлов" "диск host" "/sys запись"
printf ' %s\n' "──────────────────────────────────────────────────────────────────────────"
for cfg in "default:" "capall:--cap-add=ALL" "priv:--privileged"; do
label="${cfg%%:*}"; flags="${cfg#*:}"
[ -z "$flags" ] && args=() || args=("$flags")
out="$(insp "${args[@]}")"
printf ' %-18s %-8s %-10s %-8s %-12s %s\n' \
"$label" \
"$(echo "$out" | get capabilities)" \
"$(echo "$out" | get seccomp_расшифровка)" \
"$(echo "$out" | get устройства.всего_узлов)" \
"$(echo "$out" | get устройства.диск_host_доступен)" \
"$(echo "$out" | get sys_на_запись)"
done
out_priv="$(insp --privileged)"
out_capall="$(insp --cap-add=ALL)"
differences=0
[ "$(echo "$out_priv" | get seccomp)" != "$(echo "$out_capall" | get seccomp)" ] && differences=$((differences+1))
[ "$(echo "$out_priv" | get устройства.диск_host_доступен)" != "$(echo "$out_capall" | get устройства.диск_host_доступен)" ] && differences=$((differences+1))
[ "$(echo "$out_priv" | get sys_на_запись)" != "$(echo "$out_capall" | get sys_на_запись)" ] && differences=$((differences+1))
printf ' различий между --cap-add=ALL и --privileged: %s\n' "$differences"
[ "$differences" -ge 2 ] && ok "privileged шире, чем все capabilities: seccomp, устройства, /sys" \
|| bad "различий обнаружено мало: $differences"
printf '\n═══ Требование 2: подбор минимального набора ═══\n'
printf ' начинаем с --cap-drop=ALL и добавляем по одной:\n'
found_cap=""
for cap in "" CHOWN NET_ADMIN SYS_PTRACE SYS_ADMIN; do
if [ -z "$cap" ]; then
args=(--cap-drop=ALL); label="--cap-drop=ALL"
else
args=(--cap-drop=ALL "--cap-add=$cap"); label="+$cap"
fi
res="$(runapp "${args[@]}" | get монтирование)"
printf ' %-22s %s\n' "$label" "$res"
if [ "$res" = "смонтировано" ] && [ -z "$found_cap" ]; then
found_cap="$cap"
fi
done
printf ' минимальный набор: --cap-drop=ALL --cap-add=%s\n' "${found_cap:-не найден}"
[ "$found_cap" = "SYS_ADMIN" ] && ok "определена одна необходимая capability" \
|| bad "получено: ${found_cap:-ничего}"
printf '\n═══ Требование 3: приложение работает с точечными флагами ═══\n'
out_priv_app="$(runapp --privileged)"
out_min_app="$(runapp --cap-drop=ALL --cap-add=SYS_ADMIN)"
printf ' --privileged: %s\n' "$(echo "$out_priv_app" | get работа)"
printf ' --cap-drop=ALL --cap-add=SYS_ADMIN: %s\n' "$(echo "$out_min_app" | get работа)"
printf ' файловая система в обоих: %s / %s\n' \
"$(echo "$out_priv_app" | get файловая_система)" \
"$(echo "$out_min_app" | get файловая_система)"
[ "$(echo "$out_priv_app" | get работа)" = "$(echo "$out_min_app" | get работа)" ] \
&& [ -n "$(echo "$out_min_app" | get работа)" ] \
&& ok "результат идентичен" || bad "результаты различаются"
printf '\n═══ Требование 4: точечный вариант НЕ даёт лишнего ═══\n'
out_min="$(insp --cap-drop=ALL --cap-add=SYS_ADMIN)"
printf ' %-28s %-14s %s\n' "признак" "--privileged" "точечный"
printf ' %-28s %-14s %s\n' "capabilities" \
"$(echo "$out_priv" | get capabilities)" "$(echo "$out_min" | get capabilities)"
printf ' %-28s %-14s %s\n' "seccomp" \
"$(echo "$out_priv" | get seccomp_расшифровка)" "$(echo "$out_min" | get seccomp_расшифровка)"
printf ' %-28s %-14s %s\n' "узлов устройств" \
"$(echo "$out_priv" | get устройства.всего_узлов)" "$(echo "$out_min" | get устройства.всего_узлов)"
printf ' %-28s %-14s %s\n' "диск host доступен" \
"$(echo "$out_priv" | get устройства.диск_host_доступен)" "$(echo "$out_min" | get устройства.диск_host_доступен)"
printf ' %-28s %-14s %s\n' "/sys на запись" \
"$(echo "$out_priv" | get sys_на_запись)" "$(echo "$out_min" | get sys_на_запись)"
[ "$(echo "$out_min" | get устройства.диск_host_доступен)" = "False" ] \
&& ok "диск host недоступен" || bad "диск host виден"
[ "$(echo "$out_min" | get seccomp)" = "2" ] \
&& ok "seccomp остался включён" || bad "seccomp отключён"
[ "$(echo "$out_min" | get capabilities)" -lt 5 ] \
&& ok "capabilities: $(echo "$out_min" | get capabilities) вместо $(echo "$out_priv" | get capabilities)" \
|| bad "capabilities не сокращены"
printf '\n═══ Требование 5: риск SYS_PTRACE ═══\n'
cat > holder.py <<'PY'
import os, time
SECRET_VALUE = "ТОКЕН-В-ПАМЯТИ-СОСЕДА-98765"
print(f"процесс с секретом, pid={os.getpid()}", flush=True)
while True:
_ = SECRET_VALUE
time.sleep(1)
PY
cat > reader.py <<'PY'
import re, sys
from pathlib import Path
target, pattern = sys.argv[1], sys.argv[2].encode()
maps, mem = Path(f"/proc/{target}/maps"), Path(f"/proc/{target}/mem")
if not maps.exists():
print(" процесс не найден"); sys.exit(1)
try:
regions = [(int(m.group(1), 16), int(m.group(2), 16))
for line in maps.read_text().splitlines()
if (m := re.match(r"([0-9a-f]+)-([0-9a-f]+) r", line)) and "heap" in line]
found = set()
with open(mem, "rb") as f:
for start, end in regions[:5]:
try:
f.seek(start)
data = f.read(min(end - start, 4 << 20))
found.update(m.group().decode("utf-8", "replace")
for m in re.finditer(pattern + rb"[A-Za-z0-9\-]*", data))
except OSError:
continue
print(f" ПРОЧИТАНО: {sorted(found)[0]}" if found else " совпадений нет")
except PermissionError:
print(" ОТКАЗ: нет SYS_PTRACE")
except OSError as e:
print(f" ошибка: {e.strerror}")
PY
docker run -d --name holder -v "$PWD/holder.py:/h.py:ro" \
python:3.13-slim python -u /h.py > /dev/null 2>&1
sleep 3
printf ' цель: %s\n' "$(docker logs holder 2>&1 | tail -1)"
printf ' сосед без SYS_PTRACE: '
docker run --rm --pid container:holder --cap-drop=ALL \
-v "$PWD/reader.py:/r.py:ro" python:3.13-slim \
python /r.py 1 "ТОКЕН-В-ПАМЯТИ" 2>/dev/null | sed 's/^ *//'
printf ' сосед с SYS_PTRACE: '
ptrace_out="$(docker run --rm --pid container:holder --cap-drop=ALL --cap-add=SYS_PTRACE \
-v "$PWD/reader.py:/r.py:ro" python:3.13-slim \
python /r.py 1 "ТОКЕН-В-ПАМЯТИ" 2>/dev/null | sed 's/^ *//')"
echo "$ptrace_out"
docker rm -f holder > /dev/null 2>&1
case "$ptrace_out" in
*ПРОЧИТАНО*) ok "SYS_PTRACE даёт чтение памяти соседних процессов" ;;
*) bad "демонстрация не сработала: $ptrace_out" ;;
esac
printf '\n═══ Требование 6: аудит ═══\n'
cat > audit.sh <<'SH'
#!/usr/bin/env bash
set -uo pipefail
DANGEROUS="SYS_ADMIN SYS_MODULE SYS_RAWIO SYS_PTRACE NET_ADMIN DAC_READ_SEARCH SYS_TIME"
crit=0; warn=0; info=0
while read -r cid; do
[ -z "$cid" ] && continue
name="$(docker inspect "$cid" --format '{{.Name}}' | tr -d '/')"
priv="$(docker inspect "$cid" --format '{{.HostConfig.Privileged}}')"
drop="$(docker inspect "$cid" --format '{{json .HostConfig.CapDrop}}')"
add="$(docker inspect "$cid" --format '{{json .HostConfig.CapAdd}}')"
if [ "$priv" = "true" ]; then
printf ' КРИТИЧНО %-22s --privileged: все caps, все устройства, seccomp off\n' "$name"
crit=$((crit + 1)); continue
fi
for cap in $DANGEROUS; do
case "$add" in
*"$cap"*) printf ' ВАЖНО %-22s опасная capability: %s\n' "$name" "$cap"
warn=$((warn + 1)) ;;
esac
done
case "$drop" in
*ALL*) ;;
*) printf ' ЗАМЕЧАНИЕ %-22s нет --cap-drop=ALL (14 caps по умолчанию)\n' "$name"
info=$((info + 1)) ;;
esac
done < <(docker ps -q 2>/dev/null)
printf ' ─────────────────────────────────────────────────────────────\n'
printf ' критично=%s важно=%s замечаний=%s\n' "$crit" "$warn" "$info"
[ "$crit" -gt 0 ] && exit 2
[ "$warn" -gt 0 ] && exit 1
exit 0
SH
chmod +x audit.sh
docker run -d --name a-good --cap-drop=ALL alpine:3.21 sleep 90 > /dev/null 2>&1
docker run -d --name a-default alpine:3.21 sleep 90 > /dev/null 2>&1
docker run -d --name a-ptrace --cap-drop=ALL --cap-add=SYS_PTRACE alpine:3.21 sleep 90 > /dev/null 2>&1
docker run -d --name a-priv --privileged alpine:3.21 sleep 90 > /dev/null 2>&1
./audit.sh; audit_rc=$?
printf ' код возврата: %s\n' "$audit_rc"
docker rm -f a-good a-default a-ptrace a-priv > /dev/null 2>&1
docker run -d --name a-clean --cap-drop=ALL alpine:3.21 sleep 60 > /dev/null 2>&1
./audit.sh > /dev/null 2>&1; clean_rc=$?
docker rm -f a-clean > /dev/null 2>&1
printf ' код на чистой конфигурации: %s\n' "$clean_rc"
[ "$audit_rc" = "2" ] && [ "$clean_rc" = "0" ] \
&& ok "аудит различает три уровня и коды возврата" \
|| bad "коды: $audit_rc и $clean_rc"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
cd /tmp && rm -rf /tmp/capfull
exit "$fail"
Ожидаемый вывод:
═══ Требование 1: --privileged — четыре ослабления ═══
конфигурация caps seccomp узлов диск host /sys запись
──────────────────────────────────────────────────────────────────────────
default 14 фильтрация 15 False False
capall 32 фильтрация 15 False False
priv 32 отключён 187 True True
различий между --cap-add=ALL и --privileged: 3
✓ privileged шире, чем все capabilities: seccomp, устройства, /sys
═══ Требование 2: подбор минимального набора ═══
начинаем с --cap-drop=ALL и добавляем по одной:
--cap-drop=ALL errno 1 (нужен SYS_ADMIN)
+CHOWN errno 1 (нужен SYS_ADMIN)
+NET_ADMIN errno 1 (нужен SYS_ADMIN)
+SYS_PTRACE errno 1 (нужен SYS_ADMIN)
+SYS_ADMIN смонтировано
минимальный набор: --cap-drop=ALL --cap-add=SYS_ADMIN
✓ определена одна необходимая capability
═══ Требование 3: приложение работает с точечными флагами ═══
--privileged: записано и прочитано 1024 байт
--cap-drop=ALL --cap-add=SYS_ADMIN: записано и прочитано 1024 байт
файловая система в обоих: tmpfs / tmpfs
✓ результат идентичен
═══ Требование 4: точечный вариант НЕ даёт лишнего ═══
признак --privileged точечный
capabilities 32 1
seccomp отключён фильтрация
узлов устройств 187 15
диск host доступен True False
/sys на запись True False
✓ диск host недоступен
✓ seccomp остался включён
✓ capabilities: 1 вместо 32
═══ Требование 5: риск SYS_PTRACE ═══
цель: процесс с секретом, pid=1
сосед без SYS_PTRACE: ОТКАЗ: нет SYS_PTRACE
сосед с SYS_PTRACE: ПРОЧИТАНО: ТОКЕН-В-ПАМЯТИ-СОСЕДА-98765
✓ SYS_PTRACE даёт чтение памяти соседних процессов
═══ Требование 6: аудит ═══
КРИТИЧНО a-priv --privileged: все caps, все устройства, seccomp off
ВАЖНО a-ptrace опасная capability: SYS_PTRACE
ЗАМЕЧАНИЕ a-default нет --cap-drop=ALL (14 caps по умолчанию)
─────────────────────────────────────────────────────────────
критично=1 важно=1 замечаний=1
код возврата: 2
код на чистой конфигурации: 0
✓ аудит различает три уровня и коды возврата
═══ ИТОГ ═══
все требования выполнены
Все требования выполнены.
Строка требования 4 подводит итог урока: capabilities 1 вместо 32, seccomp включён, диск host недоступен — при том же результате работы приложения. Замена --privileged на одну точечную capability сократила привилегии в тридцать два раза.
Три решения, определяющие качество.
Требование 1 сравнивает --privileged не с умолчанием, а с --cap-add=ALL. Сравнение с умолчанием показало бы разницу, но не объяснило бы её природу: непонятно, что именно добавил флаг. Средняя строка выделяет вклад capabilities, и оставшиеся три различия оказываются приписаны именно --privileged.
Подбор в требовании 2 идёт снизу вверх и проверяет каждый шаг. Обратный порядок — от --privileged к сужению — на практике не завершается: конфигурация работает, и её оставляют. Цикл с проверкой после каждой добавленной capability даёт минимальный набор, а не первый работающий.
Требование 5 использует тот же механизм, что и легитимное профилирование. --pid container: плюс SYS_PTRACE — рекомендованный способ запустить py-spy (урок 10.3). Демонстрация показывает обратную сторону: тот же доступ читает секреты. Это не аргумент против профилирования, а против выдачи SYS_PTRACE постоянно.
Чего решение не делает. Приложение в примере действительно нуждается в SYS_ADMIN, и это редкий случай — большинству сервисов не нужна ни одна capability. Не рассматривается и --device: подбор устройств зависит от оборудования и на типовом стенде не воспроизводится. Наконец, SYS_ADMIN остаётся опасной capability: правильным решением было бы не монтировать tmpfs изнутри, а получить его от платформы через --tmpfs (урок 7.4) — тогда привилегии не нужны вовсе.
Проверка результата
docker run --rm --cap-drop=ALL python:3.13-slim sh -c '
grep -E "CapEff|CapBnd" /proc/self/status
grep Seccomp: /proc/self/status
ls /dev | wc -l
'
docker run --rm --privileged python:3.13-slim sh -c '
grep Seccomp: /proc/self/status
ls /dev | wc -l
'
Ожидается нулевой набор и Seccomp: 2 в первом случае; Seccomp: 0 и на порядок больше узлов — во втором.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
--privileged «чтобы заработало» | Быстро снимает ошибку | Четыре ослабления сразу; подобрать точечно |
Начинают с --privileged и «потом сузят» | Кажется удобным | Сужение не происходит; идти снизу вверх |
Считают --privileged = «все capabilities» | Так описывают | Плюс устройства, seccomp и /sys |
| Добавляют capability вместо изменения операции | Прямолинейно | Порт 8000 вместо 80 снимает потребность |
SYS_ADMIN для одной операции | Она покрывает нужное | Даёт и десятки других операций |
SYS_PTRACE постоянно «для отладки» | Удобно | Чтение памяти соседних процессов |
| Не убирают набор по умолчанию | Кажется безопасным | DAC_OVERRIDE и NET_RAW в нём |
--cap-add без --cap-drop=ALL | Добавляют к умолчанию | Остаются 14 плюс добавленная |
Не проверяют bounding set | Смотрят только CapEff | setuid-программа может вернуть capability |
Контрольные вопросы
На понимание:
- Назовите две самые опасные capabilities из набора по умолчанию и объясните почему.
- Что именно включает
--privileged? Перечислите четыре составляющие. - Почему
SYS_ADMINсчитается практически эквивалентной root? - Что даёт
SYS_PTRACEв общем PID namespace? - Чем
--deviceотличается от--privilegedпо доступу к устройствам?
На применение:
- Как определить минимальный набор capabilities для приложения?
- Как заменить потребность в
NET_BIND_SERVICE? - Как найти container'ы с избыточными привилегиями?
На диагностику:
- Операция возвращает
EPERM. Как узнать, какая capability нужна? - В конфигурации найден
--privileged. Порядок действий?
Краткое резюме
- Docker оставляет 14 capabilities из 41; среди них
DAC_OVERRIDEиNET_RAW— высокого риска. DAC_OVERRIDEобходит проверку прав файловой системы целиком.NET_RAWдаёт сырые сокеты: перехват и подделку трафика в своей сети.--privileged— это четыре независимых ослабления, а не одно.- Он даёт все capabilities, все устройства host, отключает seccomp и открывает
/sysна запись. - Доступ к
/dev/sdaозначает чтение дисков host в обход файловой системы. SYS_ADMINпокрывает десятки операций и практически эквивалентна root.SYS_MODULEпозволяет выполнить код в ядре host — конец любой изоляции.SYS_PTRACEдаёт чтение памяти процессов в том же PID namespace.- Подбор привилегий идёт снизу вверх: от
--cap-drop=ALL, по одной, с проверкой. - Часто правильный ход — изменить операцию, а не выдать capability.
--deviceдаёт доступ к конкретному устройству вместо всех сразу.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: runtime privilege and capabilities | https://docs.docker.com/reference/cli/docker/container/run/#privileged | Состав --privileged, --cap-add, --device |
| Docker: security | https://docs.docker.com/engine/security/#linux-kernel-capabilities | Набор по умолчанию и обоснование |
Linux: capabilities(7) | https://man7.org/linux/man-pages/man7/capabilities.7.html | Полный список, наборы, семантика каждой |
Linux: ptrace(2) | https://man7.org/linux/man-pages/man2/ptrace.2.html | Требования SYS_PTRACE |
Linux: mount(2) | https://man7.org/linux/man-pages/man2/mount.2.html | Требование SYS_ADMIN |
| Docker: device cgroup rules | https://docs.docker.com/engine/containers/run/#specify-a-device | --device-cgroup-rule |
| CIS Docker Benchmark | https://www.cisecurity.org/benchmark/docker | Проверки на --privileged и capabilities |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Seccomp, AppArmor, SELinux
Главное оглавление