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

12.4. Capabilities и privileged

Цели

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

  • назвать, что даёт каждая из 14 capabilities по умолчанию, и оценить риск;
  • перечислить, что именно включает --privileged, — не «все привилегии», а конкретный список;
  • объяснить последствия SYS_ADMIN, SYS_PTRACE, SYS_MODULE и DAC_OVERRIDE;
  • заменить --privileged точечными флагами и проверить, что приложение работает;
  • определить, какие capabilities реально нужны приложению, а не предполагать;
  • найти избыточные привилегии в чужой конфигурации.

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

Демонстрации выполняются на собственной учебной машине.

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

ТерминОбъяснение
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Да: точечные монтирования

В пяти случаях из шести точечные флаги решают задачу.

Порядок выбора привилегий

text
1. Запустить с --cap-drop=ALL
       │
       ├── работает? → готово
       │
       └── не работает → определить, какой вызов упал
                              │
                              └── добавить ОДНУ capability → повторить

Ключевая ошибка — начинать с --privileged и «потом сузить». Сужение не происходит: конфигурация работает, и её оставляют.

Правильный порядок: от нуля вверх, по одной, с проверкой на каждом шаге.

Как определить нужную capability

СимптомВероятная capability
Operation not permitted при mountSYS_ADMIN
Permission denied при привязке к порту < 1024NET_BIND_SERVICE
Operation not permitted при chownCHOWN
Operation not permitted при ptraceSYS_PTRACE
Ошибка при ip link, iptablesNET_ADMIN
Ошибка при создании сырого сокетаNET_RAW
Operation not permitted при settimeofdaySYS_TIME

Точный ответ даёт strace: он показывает, какой вызов вернул EPERM. Запускать его нужно из соседнего container'а с общим PID namespace, чтобы не менять исследуемый образ (урок 10.3).

--device вместо --privileged

bash
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

bash
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}')
"

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

text
═══ по умолчанию ═══
  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 и точечное добавление

bash
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

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

text
  конфигурация               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

bash
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

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

text
  конфигурация       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

bash
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

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

text
═══ устройства, видимые в разных режимах ═══
  по умолчанию: 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: чтение памяти соседнего процесса

bash
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

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

text
═══ запускаем процесс с секретом ═══
  держу секрет в памяти, 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: обход прав файловой системы

bash
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

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

text
═══ файл с правами 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 по симптому

bash
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['рекомендация']}\")
"

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

text
═══ определяем потребности приложения ═══
  ✓ привязка к порту 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).

Список «не хватает» — это перечень того, что нужно пересмотреть, а не выдать.

Аудит избыточных привилегий

bash
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

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

text
═══ поднимаем 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.

Последняя строка — единственная без замечаний.

bash
cd /tmp && rm -rf /tmp/caps

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

Задание. Замените --privileged точечными привилегиями и докажите эквивалентность.

Требования:

  1. Показать, что --privileged — это четыре независимых ослабления: capabilities, устройства, seccomp, /sys.
  2. Взять приложение, которому нужен --privileged, и определить минимальный набор флагов.
  3. Показать, что с точечными флагами приложение работает так же.
  4. Показать, что точечный вариант не даёт того, что даёт --privileged: доступа к диску host и отключения seccomp.
  5. Продемонстрировать риск SYS_PTRACE: чтение памяти соседнего процесса.
  6. Написать аудит, различающий три уровня проблем.

Подсказки

Подсказка 1

Для пункта 2 начинайте с --cap-drop=ALL и добавляйте по одной, проверяя после каждой.

Подсказка 2

Хороший кандидат на замену --privileged — приложение, монтирующее tmpfs: ему нужен только SYS_ADMIN.

Подсказка 3

Пункт 4 проверяется наличием /dev/sda и значением поля Seccomp в /proc/self/status.

Решение

Показать решение
bash
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"

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

text
═══ Требование 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) — тогда привилегии не нужны вовсе.

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

bash
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Смотрят только CapEffsetuid-программа может вернуть capability

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

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

  1. Назовите две самые опасные capabilities из набора по умолчанию и объясните почему.
  2. Что именно включает --privileged? Перечислите четыре составляющие.
  3. Почему SYS_ADMIN считается практически эквивалентной root?
  4. Что даёт SYS_PTRACE в общем PID namespace?
  5. Чем --device отличается от --privileged по доступу к устройствам?

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

  1. Как определить минимальный набор capabilities для приложения?
  2. Как заменить потребность в NET_BIND_SERVICE?
  3. Как найти container'ы с избыточными привилегиями?

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

  1. Операция возвращает EPERM. Как узнать, какая capability нужна?
  2. В конфигурации найден --privileged. Порядок действий?

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

  1. Docker оставляет 14 capabilities из 41; среди них DAC_OVERRIDE и NET_RAW — высокого риска.
  2. DAC_OVERRIDE обходит проверку прав файловой системы целиком.
  3. NET_RAW даёт сырые сокеты: перехват и подделку трафика в своей сети.
  4. --privileged — это четыре независимых ослабления, а не одно.
  5. Он даёт все capabilities, все устройства host, отключает seccomp и открывает /sys на запись.
  6. Доступ к /dev/sda означает чтение дисков host в обход файловой системы.
  7. SYS_ADMIN покрывает десятки операций и практически эквивалентна root.
  8. SYS_MODULE позволяет выполнить код в ядре host — конец любой изоляции.
  9. SYS_PTRACE даёт чтение памяти процессов в том же PID namespace.
  10. Подбор привилегий идёт снизу вверх: от --cap-drop=ALL, по одной, с проверкой.
  11. Часто правильный ход — изменить операцию, а не выдать capability.
  12. --device даёт доступ к конкретному устройству вместо всех сразу.

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

ИсточникСсылкаЧто подтверждает
Docker: runtime privilege and capabilitieshttps://docs.docker.com/reference/cli/docker/container/run/#privilegedСостав --privileged, --cap-add, --device
Docker: securityhttps://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 ruleshttps://docs.docker.com/engine/containers/run/#specify-a-device--device-cgroup-rule
CIS Docker Benchmarkhttps://www.cisecurity.org/benchmark/dockerПроверки на --privileged и capabilities

Навигация

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

Markdown на GitHub ↗