Главная/Ограничения Docker/Урок

19.2. Границы изоляции

Цели

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

  • объяснить, почему общее ядро — ограничение принципиальное, а не устранимое;
  • измерить площадь атаки на ядро числом, а не оценкой;
  • назвать три класса container escape и указать, какой встречается чаще;
  • объяснить, почему root в container'е по умолчанию равен root на хосте;
  • выбрать средство изоляции под уровень доверия к коду;
  • назвать, что остаётся общим даже в виртуальной машине.

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

Docker на машине курса не установлен. Измерения площади атаки выполняются на ядре хоста — они от Docker не зависят.

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

ТерминОбъяснение
container escapeВыход процесса за пределы изоляции на хост
граница доверияЧерта, за которой код считается недоверенным
площадь атакиОбъём интерфейса, доступного атакующему
побочный каналУтечка сведений через измеримые свойства, а не через данные
микро-ВМВиртуальная машина с минимальным набором устройств

Теория

Одно ядро на всех

Container — это процесс. Изоляция обеспечивается тем, что ядро показывает процессу ограниченный вид системы. Само ядро при этом одно.

text
container A ─┐
container B ─┼──→ одно ядро Linux ──→ оборудование
container C ─┘

Отсюда следует утверждение, вокруг которого строится весь урок: любая уязвимость ядра доступна из любого container'а. Никакая настройка Docker этого не меняет, потому что интерфейс к ядру — системные вызовы — и есть то, ради чего процесс запускается.

Сравните с виртуальной машиной:

text
ВМ A (своё ядро) ─┐
ВМ B (своё ядро) ─┼──→ гипервизор ──→ оборудование
ВМ C (своё ядро) ─┘

Здесь уязвимость гостевого ядра не даёт доступа к соседям: нужна ещё уязвимость гипервизора, а его интерфейс несопоставимо меньше.

Разница не в качестве изоляции, а в размере поверхности, которую нужно защитить.

Площадь атаки, выраженная числом

Интерфейс ядра — системные вызовы. Их число можно посчитать, а не оценить: на x86-64 в текущих ядрах их больше трёхсот.

Профиль seccomp по умолчанию запрещает несколько десятков — те, что заведомо опасны или бесполезны в container'е. Остальные доступны.

СлойЧто ограничиваетЧто остаётся
Профиль seccompНесколько десятков вызововОсновная часть интерфейса
CapabilitiesПривилегированные операцииНепривилегированные — все
NamespacesВидимость объектовСам интерфейс вызовов
CgroupsОбъём ресурсовНе относится к безопасности
AppArmor или SELinuxДоступ к путям и операциямЗависит от профиля

Ни один слой не уменьшает число доступных вызовов радикально — они и не должны: приложение обязано работать.

Почему root в container'е — это root на хосте

Docker создаёт шесть namespaces из восьми (урок 17.3). Среди созданных нет user namespace.

Практическое следствие:

text
UID 0 внутри container'а  ==  UID 0 на хосте

Разделяет их не преобразование идентификаторов, а namespaces и capabilities. Если процесс сумеет выйти за них — он окажется на хосте от имени root, без дополнительных шагов.

Именно поэтому три вещи ниже эквивалентны выдаче прав root на хост:

ДействиеПочему
--privilegedСнимает seccomp, AppArmor, возвращает все capabilities
Монтирование /var/run/docker.sockПозволяет создать container с любыми параметрами (урок 17.1)
CAP_SYS_ADMINДаёт монтирование и значительную часть операций root

Первое очевидно, второе — нет: сокет выглядит как «просто файл», а даёт полный контроль над daemon'ом, работающим от root.

Включение user namespace (userns-remap) меняет картину: root внутри становится непривилегированным пользователем снаружи. Это заметно повышает планку — и ломает часть сценариев (тома, разделяемые сети, некоторые образы), поэтому по умолчанию не включено.

Три класса escape

КлассМеханизмКак часто
Уязвимость runtimeОшибка в runc, containerd, daemonРедко, но громко
Уязвимость ядраОшибка в обработке системного вызоваРегулярно
Неверная конфигурация--privileged, сокет, лишние capabilitiesПодавляющее большинство

Третья строка — главный практический вывод. Публичные разборы escape в эксплуатации чаще описывают не уязвимость, а конфигурацию: смонтированный сокет, --privileged «чтобы заработало», проброшенный / в режиме чтения-записи.

Первые два класса иллюстрируются известными случаями в runc:

ПримерМеханизм
CVE-2019-5736Процесс в container'е перезаписывал двоичный файл runc на хосте через /proc/self/exe
CVE-2024-21626Утечка файлового дескриптора открывала доступ к файловой системе хоста

Существенно не то, что уязвимости были, а то, что они будут: интерфейс между container'ом и хостом сложен, и полностью корректной реализации ожидать не стоит. Модель угроз должна это учитывать.

Недоверенный код

Отсюда правило, которое стоит запомнить в такой формулировке:

Container — граница между вашими сервисами, а не между вами и злоумышленником.

Если код недоверенный — присланный пользователем, скачанный, выполняемый по запросу, — одних container'ов недостаточно. Что применяют вместо:

СредствоКак работаетЦена
user namespaceroot внутри ≠ root снаружиЛомает часть сценариев
gVisorЯдро в пространстве пользователя перехватывает вызовыЧасть вызовов не поддержана; медленнее на I/O
Kata ContainersКаждый container — лёгкая ВМ со своим ядромРасход памяти, дольше старт
FirecrackerМикро-ВМ с минимумом устройствТребует своей инфраструктуры
Обычная ВМСвоё ядро, зрелый гипервизорТяжелее по всем статьям
Отдельная машинаОбщего нет ничего, кроме сетиДороже всего

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

Побочные каналы

Даже полное разделение ядра не разделяет оборудование. Общими остаются:

Что общееЧто может утечь
Кэш процессораДанные соседа через измерение времени доступа
Ветвление предсказателяТо же семейство атак
ГиперпоточностьСоседний поток на том же физическом ядре
Полоса памяти и дискаКосвенные сведения о нагрузке
Источники времениТочные измерения, нужные для атак

Это относится и к виртуальным машинам: гипервизор разделяет ядра, но не физический кэш.

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

Выбор по границе доверия

Кто выполняет кодДостаточно
Ваша команда, ваш кодContainer
Разные команды одной организацииContainer + namespace + политики
Зависимости из публичных репозиториевContainer + сканирование + минимум прав
Код пользователей вашего продуктаМикро-ВМ или ВМ
Заведомо враждебный код (анализ вредоносного ПО)Отдельная машина, отдельная сеть

Строка «зависимости из публичных репозиториев» заслуживает отдельного внимания: код в них выполняется с правами вашего приложения и уже находится внутри границы. Container от него не защищает вовсе — он защищает хост от приложения, а не приложение от собственных зависимостей (урок 12.7).


Внутренний механизм

Почему уязвимости в интерфейсе неизбежны

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

Namespaces добавляют к этому измерение: тот же вызов должен вести себя правильно в зависимости от того, в каком namespace находится процесс, и правильно обрабатывать пересечение границ (/proc, /sys, файловые дескрипторы, setns).

Уязвимости runc из таблицы выше обе относятся именно к пересечению границы: файловый дескриптор или путь, полученный по одну сторону, использовался по другую.

Это объясняет, почему такие ошибки повторяются: сложность сосредоточена в местах, где изоляция должна прерываться контролируемым образом.

Что именно разделяет user namespace

Без user namespace ядро сравнивает идентификаторы напрямую: UID 0 — это UID 0.

С user namespace вводится преобразование: диапазон идентификаторов внутри отображается на другой диапазон снаружи.

text
внутри:  UID 0        →  снаружи: UID 100000
внутри:  UID 1000     →  снаружи: UID 101000

Процесс, вышедший за namespaces, оказывается снаружи под UID 100000 — обычным непривилегированным пользователем. Файлы root ему недоступны.

Ограничение остаётся: уязвимость самого ядра user namespace не спасает, потому что ядро всё то же. Планка поднимается, класс угрозы сохраняется.

Ограничение непривилегированных user namespace

Сам механизм user namespace исторически стал источником уязвимостей: он позволяет непривилегированному процессу получить CAP_SYS_ADMIN внутри своего namespace и через это добраться до кода ядра, ранее доступного только root.

Поэтому дистрибутивы ограничивают его создание. В Ubuntu 24.04 за это отвечает параметр kernel.apparmor_restrict_unprivileged_userns.

Здесь виден компромисс в чистом виде: тот же механизм, который усиливает изоляцию container'ов (userns-remap), ослабляет защиту хоста, если доступен всем. Универсально правильной настройки нет.


Команды и примеры

Измерение площади атаки

bash
mkdir -p /tmp/isolation && cd /tmp/isolation

echo "═══ ядро и его интерфейс ═══"
uname -r
printf 'системных вызовов x86-64: %s\n' \
    "$(grep -c '^#define __NR_' /usr/include/x86_64-linux-gnu/asm/unistd_64.h)"

echo
echo "═══ capabilities ═══"
grep CapBnd /proc/self/status
python3 - <<'PY'
from pathlib import Path

line = next(l for l in Path("/proc/self/status").read_text().splitlines()
            if l.startswith("CapBnd:"))
value = int(line.split()[1], 16)
print(f"  capabilities в bounding set: {bin(value).count('1')}")
print("  каждая — отдельная привилегированная операция ядра")
PY

echo
echo "═══ что видно из любого container'а ═══"
for p in kernel.hostname kernel.pid_max vm.max_map_count fs.file-max; do
    printf '  %-20s %s\n' "$p" "$(sysctl -n "$p" 2>/dev/null)"
done
echo "  версия ядра одна на всех: $(uname -r)"

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

text
═══ ядро и его интерфейс ═══
6.8.0-88-generic
системных вызовов x86-64: 373

═══ capabilities ═══
CapBnd:	000001ffffffffff
  capabilities в bounding set: 41
  каждая — отдельная привилегированная операция ядра

═══ что видно из любого container'а ═══
  kernel.hostname      egrebnev-vm
  kernel.pid_max       4194304
  vm.max_map_count     1048576
  fs.file-max          9223372036854775807
  версия ядра одна на всех: 6.8.0-88-generic

Числа получены на машине курса: 373 системных вызова и 41 capability. Профиль seccomp по умолчанию закрывает несколько десятков вызовов — то есть основная часть интерфейса остаётся доступной, и иначе быть не может: приложение обязано работать.

Строка версия ядра одна на всех — не риторика. Она означает буквально: uname -r внутри container'а вернёт то же самое, потому что ядро то же самое.

Ограничение user namespace

bash
cd /tmp/isolation

echo "═══ настройка ограничения ═══"
sysctl kernel.apparmor_restrict_unprivileged_userns 2>/dev/null \
    || echo "параметр отсутствует (не Ubuntu 24.04+)"

echo
echo "═══ попытка создать user namespace без привилегий ═══"
unshare --user --map-root-user /bin/true 2>&1 || echo "  код возврата: $?"

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

text
═══ настройка ограничения ═══
kernel.apparmor_restrict_unprivileged_userns = 1

═══ попытка создать user namespace без привилегий ═══
unshare: write failed /proc/self/uid_map: Operation not permitted
  код возврата: 1

Значение параметра проверено на машине курса. Попытка действительно не удалась.

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

Разбирать это стоит именно так, потому что подмена «команда не сработала» на «сработало ограничение X» — обычный источник ложных выводов при отладке изоляции.

Средства изоляции под уровень доверия

bash
cd /tmp/isolation
cat > isolation.py <<'PY'
"""Выбор средства изоляции по границе доверия."""
from __future__ import annotations

import json

# (средство, своё ядро, свой UID-диапазон, общее оборудование, цена)
MEANS = [
    ("Container", False, False, True, "минимальная"),
    ("Container + userns-remap", False, True, True, "ломает часть сценариев"),
    ("gVisor", "частично", True, True, "часть вызовов не поддержана"),
    ("Kata Containers", True, True, True, "память и время старта"),
    ("Firecracker", True, True, True, "своя инфраструктура"),
    ("Виртуальная машина", True, True, True, "тяжелее по всем статьям"),
    ("Отдельная машина", True, True, False, "дороже всего"),
]

# (кто выполняет код, достаточное средство, почему)
BOUNDARIES = [
    ("Ваша команда, ваш код", "Container",
     "граница между сервисами, а не против злоумышленника"),
    ("Разные команды организации", "Container + namespace + политики",
     "ошибка соседа не должна доходить до вас"),
    ("Зависимости из публичных репозиториев", "Container + сканирование",
     "код УЖЕ внутри границы: container от него не защищает"),
    ("Код пользователей продукта", "Микро-ВМ или ВМ",
     "недоверенный код у общего ядра недопустим"),
    ("Заведомо враждебный код", "Отдельная машина и сеть",
     "побочные каналы не устраняются настройками"),
]

# Что остаётся общим даже при полном разделении ядра
SHARED_ANYWAY = [
    ("Кэш процессора", "данные соседа через измерение времени"),
    ("Предсказатель переходов", "то же семейство атак"),
    ("Гиперпоточность", "соседний поток на том же физическом ядре"),
    ("Полоса памяти и диска", "косвенные сведения о нагрузке"),
]


def main() -> None:
    print(f"  {'средство':<26} {'своё ядро':<12} {'свои UID':<10} "
          f"{'общее ЖЕЛЕЗО':<14} цена")
    print("  " + "─" * 96)
    for name, kernel, uids, hw, cost in MEANS:
        k = {True: "да", False: "нет"}.get(kernel, str(kernel))
        u = "да" if uids else "нет"
        h = "да" if hw else "нет"
        print(f"  {name:<26} {k:<12} {u:<10} {h:<14} {cost}")

    print()
    print(f"  {'кто выполняет код':<40} {'достаточно':<32} почему")
    print("  " + "─" * 116)
    for who, means, why in BOUNDARIES:
        print(f"  {who:<40} {means:<32} {why}")

    print()
    print("  Строка про зависимости — самая недооценённая: код из публичных")
    print("  репозиториев выполняется с правами приложения и УЖЕ находится")
    print("  внутри границы. Container защищает хост от приложения,")
    print("  а не приложение от собственных зависимостей.")

    print()
    print(f"  {'общее даже при своём ядре':<30} что может утечь")
    print("  " + "─" * 76)
    for what, leak in SHARED_ANYWAY:
        print(f"  {what:<30} {leak}")
    print()
    print("  Гипервизор разделяет вычислительные ядра, но не физический кэш.")
    print("  При действительно высоких требованиях изоляция достигается")
    print("  отдельным ОБОРУДОВАНИЕМ, а не настройками.")
    print()
    print(json.dumps({"средств": len(MEANS), "границ": len(BOUNDARIES),
                      "общее_всегда": len(SHARED_ANYWAY)}, ensure_ascii=False))


if __name__ == "__main__":
    main()
PY

echo "═══ выбор средства ═══"
python3 isolation.py

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

text
═══ выбор средства ═══
  средство                   своё ядро    свои UID   общее ЖЕЛЕЗО   цена
  ────────────────────────────────────────────────────────────────────────────────────────────────
  Container                  нет          нет        да             минимальная
  Container + userns-remap   нет          да         да             ломает часть сценариев
  gVisor                     частично     да         да             часть вызовов не поддержана
  Kata Containers            да           да         да             память и время старта
  Firecracker                да           да         да             своя инфраструктура
  Виртуальная машина         да           да         да             тяжелее по всем статьям
  Отдельная машина           да           да         нет            дороже всего

  кто выполняет код                        достаточно                       почему
  ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  Ваша команда, ваш код                    Container                        граница между сервисами, а не против злоумышленника
  Разные команды организации               Container + namespace + политики ошибка соседа не должна доходить до вас
  Зависимости из публичных репозиториев    Container + сканирование         код УЖЕ внутри границы: container от него не защищает
  Код пользователей продукта               Микро-ВМ или ВМ                  недоверенный код у общего ядра недопустим
  Заведомо враждебный код                  Отдельная машина и сеть          побочные каналы не устраняются настройками

  общее даже при своём ядре      что может утечь
  ────────────────────────────────────────────────────────────────────────────
  Кэш процессора                 данные соседа через измерение времени
  ...

Обратите внимание на столбец «общее железо»: он равен «да» у всех строк, кроме последней. Полное разделение даёт только отдельная машина.

Конфигурация как главный класс escape

bash
cd /tmp/isolation
cat > audit.py <<'PY'
"""Разбор конфигураций, эквивалентных выдаче root на хост."""
from __future__ import annotations

import json
import sys
from pathlib import Path

import yaml

# (признак, что даёт атакующему, оправданные случаи)
DANGEROUS = {
    "privileged": ("снимает seccomp и AppArmor, возвращает все capabilities",
                   "сборка образов, работа с устройствами — и то не всегда"),
    "docker_sock": ("позволяет создать container с любыми параметрами: "
                    "это root на хосте",
                    "агенты CI — и только с осознанием последствий"),
    "cap_sys_admin": ("монтирование и значительная часть операций root",
                      "почти никогда"),
    "pid_host": ("видимость и сигналы всем процессам хоста",
                 "средства наблюдения"),
    "network_host": ("сетевая изоляция снята полностью",
                     "производительность сети — ценой изоляции"),
    "root_mount": ("файловая система хоста доступна изнутри",
                   "резервное копирование — только для чтения"),
    "run_as_root": ("UID 0 внутри равен UID 0 снаружи",
                    "почти никогда"),
}


def audit_service(name: str, svc: dict) -> list[str]:
    found: list[str] = []
    if svc.get("privileged"):
        found.append("privileged")
    for v in svc.get("volumes") or []:
        spec = v if isinstance(v, str) else f"{v.get('source')}:{v.get('target')}"
        if "docker.sock" in spec:
            found.append("docker_sock")
        if spec.startswith("/:") or spec.startswith("/ "):
            found.append("root_mount")
    caps = [c.upper() for c in (svc.get("cap_add") or [])]
    if "SYS_ADMIN" in caps or "ALL" in caps:
        found.append("cap_sys_admin")
    if svc.get("pid") == "host":
        found.append("pid_host")
    if svc.get("network_mode") == "host":
        found.append("network_host")
    user = str(svc.get("user", ""))
    if user in ("0", "root", "0:0"):
        found.append("run_as_root")
    return found


def main(path: str) -> int:
    compose = yaml.safe_load(Path(path).read_text())
    result: dict[str, list[str]] = {}
    for name, svc in compose.get("services", {}).items():
        found = audit_service(name, svc or {})
        if found:
            result[name] = found

    total = sum(len(v) for v in result.values())
    print(f"  {'сервис':<14} {'признак':<16} что это даёт")
    print("  " + "─" * 96)
    for name, findings in result.items():
        for f in findings:
            what, _ = DANGEROUS[f]
            print(f"  {name:<14} {f:<16} {what}")

    print()
    print(f"  сервисов с находками: {len(result)}, находок всего: {total}")
    print()
    print(f"  {'признак':<16} когда оправдан")
    print("  " + "─" * 76)
    seen = {f for v in result.values() for f in v}
    for f in sorted(seen):
        print(f"  {f:<16} {DANGEROUS[f][1]}")
    print()
    print("  Все перечисленное — НЕ уязвимости, а конфигурация.")
    print("  Именно этот класс составляет подавляющее большинство")
    print("  случаев escape в эксплуатации.")
    print()
    print(json.dumps({"сервисов": len(result), "находок": total},
                     ensure_ascii=False))
    # 0 — чисто, 1 — есть находки
    return 1 if total else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "compose.yaml"))
PY

cat > compose.yaml <<'EOF'
services:
  ci-runner:
    image: runner:1
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    user: "0:0"

  monitoring:
    image: monitor:1
    pid: host
    cap_add: [SYS_ADMIN]

  backup:
    image: backup:1
    volumes:
      - /:/host

  proxy:
    image: proxy:1
    network_mode: host

  app:
    image: app:1
    user: "10001:10001"
    read_only: true
    cap_drop: [ALL]
EOF

echo "═══ разбор конфигурации ═══"
python3 audit.py compose.yaml; echo "  код возврата: $?"

cd /tmp && rm -rf /tmp/isolation

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

text
═══ разбор конфигурации ═══
  сервис         признак          что это даёт
  ────────────────────────────────────────────────────────────────────────────────────────────────
  ci-runner      docker_sock      позволяет создать container с любыми параметрами: это root на хосте
  ci-runner      run_as_root      UID 0 внутри равен UID 0 снаружи
  monitoring     cap_sys_admin    монтирование и значительная часть операций root
  monitoring     pid_host         видимость и сигналы всем процессам хоста
  backup         root_mount       файловая система хоста доступна изнутри
  proxy          network_host     сетевая изоляция снята полностью

  сервисов с находками: 4, находок всего: 6

  признак          когда оправдан
  ────────────────────────────────────────────────────────────────────────────
  cap_sys_admin    почти никогда
  docker_sock      агенты CI — и только с осознанием последствий
  network_host     производительность сети — ценой изоляции
  pid_host         средства наблюдения
  root_mount       резервное копирование — только для чтения
  run_as_root      почти никогда

  Все перечисленное — НЕ уязвимости, а конфигурация.
  Именно этот класс составляет подавляющее большинство
  случаев escape в эксплуатации.

{"сервисов": 4, "находок": 6}
  код возврата: 1

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


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

Задание. Постройте инструмент оценки изоляции и примените его к конфигурации.

Требования:

  1. Измерить площадь атаки числом: системные вызовы и capabilities — на реальном ядре.
  2. Показать, что видно из любого container'а, потому что ядро общее.
  3. Разобрать compose.yaml и найти конфигурации, эквивалентные root на хосте.
  4. Проверить инструмент на безопасном сервисе: он не должен давать находок.
  5. Построить таблицу выбора средства изоляции по границе доверия.
  6. Назвать, что остаётся общим даже при своём ядре.
  7. Отметить всё, что не проверялось, и отделить проверенное от предполагаемого.

Подсказки

Подсказка 1

Число системных вызовов на x86-64 считается по заголовку ядра: grep -c '^#define __NR_' /usr/include/x86_64-linux-gnu/asm/unistd_64.h

Подсказка 2

CapBnd в /proc/self/status — шестнадцатеричная маска. Число установленных битов и есть число capabilities.

Подсказка 3

Инструмент, находящий проблемы во всём, ничем не лучше инструмента, не находящего ничего. Нужен сервис, который проверку проходит.

Решение

Показать решение
bash
mkdir -p /tmp/isolab && cd /tmp/isolab

cat > surface.py <<'PY'
"""Измерение площади атаки на общее ядро.

Все числа берутся из системы, а не задаются в коде: заголовок ядра
и /proc/self/status. Это отличает измерение от оценки.
"""
from __future__ import annotations

import json
import platform
import re
from pathlib import Path

SYSCALL_HEADERS = [
    "/usr/include/x86_64-linux-gnu/asm/unistd_64.h",
    "/usr/include/asm/unistd_64.h",
    "/usr/include/asm-generic/unistd.h",
]

# Параметры, значение которых внутри container'а совпадает с хостом,
# потому что ядро одно.
SHARED_SYSCTL = [
    "kernel.pid_max",
    "vm.max_map_count",
    "fs.file-max",
    "kernel.threads-max",
]


def count_syscalls() -> tuple[int, str]:
    for path in SYSCALL_HEADERS:
        p = Path(path)
        if p.exists():
            n = len(re.findall(r"^#define __NR_", p.read_text(), re.M))
            return n, path
    return 0, "заголовок не найден"


def count_capabilities() -> int:
    for line in Path("/proc/self/status").read_text().splitlines():
        if line.startswith("CapBnd:"):
            return bin(int(line.split()[1], 16)).count("1")
    return 0


def read_sysctl(name: str) -> str:
    p = Path("/proc/sys") / name.replace(".", "/")
    try:
        return p.read_text().strip()
    except OSError:
        return "недоступен"


def main() -> None:
    syscalls, header = count_syscalls()
    caps = count_capabilities()
    result = {
        "ядро": platform.release(),
        "системных_вызовов": syscalls,
        "источник": header,
        "capabilities": caps,
        "общие_параметры": {n: read_sysctl(n) for n in SHARED_SYSCTL},
        "userns_ограничен": read_sysctl("kernel.apparmor_restrict_unprivileged_userns"),
    }
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
PY

cat > audit.py <<'PY'
"""Поиск конфигураций, эквивалентных выдаче root на хост.

Ищутся не уязвимости, а решения в конфигурации: именно этот класс
составляет подавляющее большинство случаев escape в эксплуатации.
"""
from __future__ import annotations

import json
import sys
from pathlib import Path

import yaml

# (признак, вес, что даёт атакующему, когда оправдан)
RULES = {
    "privileged": (10, "снимает seccomp и AppArmor, возвращает все capabilities",
                   "сборка образов, и то не всегда"),
    "docker_sock": (10, "создание container с любыми параметрами — это root на хосте",
                    "агенты CI, с осознанием последствий"),
    "cap_sys_admin": (8, "монтирование и значительная часть операций root",
                      "почти никогда"),
    "root_mount_rw": (9, "файловая система хоста доступна на запись",
                      "никогда"),
    "root_mount_ro": (4, "файловая система хоста доступна на чтение",
                      "резервное копирование"),
    "pid_host": (5, "видимость и сигналы всем процессам хоста",
                 "средства наблюдения"),
    "network_host": (4, "сетевая изоляция снята полностью",
                     "производительность сети ценой изоляции"),
    "run_as_root": (6, "UID 0 внутри равен UID 0 снаружи",
                    "почти никогда"),
    "cap_add_all": (10, "все capabilities возвращены",
                    "никогда"),
    "seccomp_off": (7, "фильтр системных вызовов отключён",
                    "отладка, временно"),
}


def audit_service(svc: dict) -> list[str]:
    found: list[str] = []
    if svc.get("privileged"):
        found.append("privileged")

    for v in svc.get("volumes") or []:
        if isinstance(v, str):
            parts = v.split(":")
            source = parts[0]
            mode = parts[2] if len(parts) > 2 else "rw"
        else:
            source = str(v.get("source", ""))
            mode = "ro" if v.get("read_only") else "rw"
        if "docker.sock" in source:
            found.append("docker_sock")
        elif source == "/":
            found.append("root_mount_ro" if mode == "ro" else "root_mount_rw")

    caps = {c.upper() for c in (svc.get("cap_add") or [])}
    if "ALL" in caps:
        found.append("cap_add_all")
    elif "SYS_ADMIN" in caps:
        found.append("cap_sys_admin")

    if svc.get("pid") == "host":
        found.append("pid_host")
    if svc.get("network_mode") == "host":
        found.append("network_host")

    user = str(svc.get("user", "")).strip()
    if user in ("0", "root", "0:0"):
        found.append("run_as_root")

    for opt in svc.get("security_opt") or []:
        if "seccomp=unconfined" in str(opt):
            found.append("seccomp_off")

    return found


def main(path: str) -> int:
    compose = yaml.safe_load(Path(path).read_text())
    services = compose.get("services", {})
    report: dict[str, dict[str, object]] = {}
    for name, svc in services.items():
        found = audit_service(svc or {})
        report[name] = {
            "находки": found,
            "вес": sum(RULES[f][0] for f in found),
        }

    total = sum(int(r["вес"]) for r in report.values())
    clean = [n for n, r in report.items() if not r["находки"]]
    print(json.dumps({
        "сервисов": len(services),
        "чистых": clean,
        "суммарный_вес": total,
        "отчёт": report,
        "правила": {k: {"вес": v[0], "даёт": v[1], "оправдан": v[2]}
                    for k, v in RULES.items()},
    }, ensure_ascii=False))
    return 1 if total else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "compose.yaml"))
PY

cat > choose.py <<'PY'
"""Выбор средства изоляции по границе доверия."""
from __future__ import annotations

import json

MEANS = [
    ("Container", "нет", "нет", "да", "минимальная"),
    ("Container + userns-remap", "нет", "да", "да", "ломает часть сценариев"),
    ("gVisor", "частично", "да", "да", "часть вызовов не поддержана"),
    ("Kata Containers", "да", "да", "да", "память и время старта"),
    ("Firecracker", "да", "да", "да", "своя инфраструктура"),
    ("Виртуальная машина", "да", "да", "да", "тяжелее по всем статьям"),
    ("Отдельная машина", "да", "да", "НЕТ", "дороже всего"),
]

BOUNDARIES = [
    ("Ваша команда, ваш код", "Container",
     "граница между сервисами, а не против злоумышленника"),
    ("Разные команды организации", "Container + политики",
     "ошибка соседа не должна доходить до вас"),
    ("Зависимости из публичных репозиториев", "Container + сканирование",
     "код УЖЕ внутри границы: container от него не защищает"),
    ("Код пользователей продукта", "Микро-ВМ или ВМ",
     "недоверенный код у общего ядра недопустим"),
    ("Заведомо враждебный код", "Отдельная машина и сеть",
     "побочные каналы не устраняются настройками"),
]

SHARED_ANYWAY = [
    ("Кэш процессора", "данные соседа через измерение времени доступа"),
    ("Предсказатель переходов", "то же семейство атак"),
    ("Гиперпоточность", "соседний поток на том же физическом ядре"),
    ("Полоса памяти и диска", "косвенные сведения о нагрузке соседа"),
    ("Источники точного времени", "измерения, нужные для самих атак"),
]

ESCAPE_CLASSES = [
    ("Уязвимость runtime", "ошибка в runc, containerd, daemon", "редко, но громко"),
    ("Уязвимость ядра", "ошибка в обработке системного вызова", "регулярно"),
    ("Неверная конфигурация", "privileged, сокет, лишние capabilities",
     "ПОДАВЛЯЮЩЕЕ БОЛЬШИНСТВО"),
]


def main() -> None:
    print(f"  {'средство':<26} {'своё ядро':<12} {'свои UID':<10} "
          f"{'общее железо':<14} цена")
    print("  " + "─" * 94)
    for name, kernel, uids, hw, cost in MEANS:
        print(f"  {name:<26} {kernel:<12} {uids:<10} {hw:<14} {cost}")
    only_full = [m[0] for m in MEANS if m[3] == "НЕТ"]
    print()
    print(f"  полное разделение даёт только: {', '.join(only_full)}")

    print()
    print(f"  {'класс escape':<26} {'механизм':<44} частота")
    print("  " + "─" * 96)
    for name, mech, freq in ESCAPE_CLASSES:
        print(f"  {name:<26} {mech:<44} {freq}")

    print()
    print(f"  {'кто выполняет код':<40} {'достаточно':<28} почему")
    print("  " + "─" * 112)
    for who, means, why in BOUNDARIES:
        print(f"  {who:<40} {means:<28} {why}")

    print()
    print(f"  {'общее даже при своём ядре':<30} что может утечь")
    print("  " + "─" * 78)
    for what, leak in SHARED_ANYWAY:
        print(f"  {what:<30} {leak}")

    print()
    print(json.dumps({"средств": len(MEANS), "границ": len(BOUNDARIES),
                      "общее_всегда": len(SHARED_ANYWAY),
                      "классов_escape": len(ESCAPE_CLASSES)}, ensure_ascii=False))


if __name__ == "__main__":
    main()
PY

cat > compose.yaml <<'EOF'
services:
  ci-runner:
    image: runner:1
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    user: "0:0"

  monitoring:
    image: monitor:1
    pid: host
    cap_add: [SYS_ADMIN]

  backup:
    image: backup:1
    volumes:
      - /:/host:ro

  legacy:
    image: legacy:1
    privileged: true
    security_opt:
      - seccomp=unconfined

  app:
    image: app:1
    user: "10001:10001"
    read_only: true
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
EOF

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Требование 1: площадь атаки числом ═══\n'
python3 surface.py > surface.json
python3 - <<'PY'
import json

d = json.load(open("surface.json"))
print(f"    ядро:                     {d['ядро']}")
print(f"    системных вызовов:        {d['системных_вызовов']}")
print(f"    источник числа:           {d['источник']}")
print(f"    capabilities в bounding:  {d['capabilities']}")
print()
print("    Профиль seccomp по умолчанию закрывает несколько десятков вызовов.")
print("    Основная часть интерфейса остаётся доступной — и иначе быть")
print("    не может: приложение обязано работать.")
print()
print("    Это и есть площадь атаки на ОБЩЕЕ ядро: она измерена,")
print("    а не оценена, и не зависит от настроек Docker.")
PY
syscalls="$(python3 -c "import json;print(json.load(open('surface.json'))['системных_вызовов'])")"
caps="$(python3 -c "import json;print(json.load(open('surface.json'))['capabilities'])")"
printf '\n  вызовов: %s, capabilities: %s\n' "$syscalls" "$caps"
[ "$syscalls" -gt 300 ] && [ "$caps" -gt 30 ] \
    && ok "площадь атаки измерена на реальном ядре" \
    || bad "вызовов $syscalls, capabilities $caps"

printf '\n═══ Требование 2: что общее, потому что ядро одно ═══\n'
python3 - <<'PY'
import json

d = json.load(open("surface.json"))
print(f"    {'параметр':<24} значение (одно на хост и все container'ы)")
print("    " + "─" * 72)
for name, value in d["общие_параметры"].items():
    print(f"    {name:<24} {value}")
print()
print(f"    версия ядра: {d['ядро']} — uname -r внутри вернёт то же самое")
print()
print(f"    ограничение непривилегированных user namespace: "
      f"{d['userns_ограничен']}")
print()
print("    Тот же механизм, который УСИЛИВАЕТ изоляцию container'ов")
print("    (userns-remap), ОСЛАБЛЯЕТ защиту хоста, если доступен всем.")
print("    Универсально правильной настройки нет.")
PY
shared="$(python3 -c "
import json
print(len(json.load(open('surface.json'))['общие_параметры']))")"
[ "$shared" -ge 3 ] \
    && ok "общие параметры прочитаны из системы, а не перечислены по памяти" \
    || bad "параметров: $shared"

printf '\n═══ Требование 3: конфигурации, равные root на хосте ═══\n'
python3 audit.py compose.yaml > audit.json; audit_code=$?
python3 - <<'PY'
import json

d = json.load(open("audit.json"))
rules = d["правила"]
print(f"    {'сервис':<14} {'признак':<16} {'вес':>4}  что даёт")
print("    " + "─" * 100)
for name, r in d["отчёт"].items():
    for f in r["находки"]:
        print(f"    {name:<14} {f:<16} {rules[f]['вес']:>4}  {rules[f]['даёт']}")
print()
print(f"    суммарный вес: {d['суммарный_вес']}")
print()
print(f"    {'признак':<16} когда оправдан")
print("    " + "─" * 66)
seen = sorted({f for r in d["отчёт"].values() for f in r["находки"]})
for f in seen:
    print(f"    {f:<16} {rules[f]['оправдан']}")
print()
print("    Всё перечисленное — НЕ уязвимости, а конфигурация.")
print("    Именно этот класс составляет подавляющее большинство")
print("    случаев escape в эксплуатации.")
PY
weight="$(python3 -c "import json;print(json.load(open('audit.json'))['суммарный_вес'])")"
printf '\n  код возврата: %s, суммарный вес находок: %s\n' "$audit_code" "$weight"
[ "$audit_code" -eq 1 ] && [ "$weight" -gt 0 ] \
    && ok "опасные конфигурации найдены, код возврата отличает их от чистой" \
    || bad "код $audit_code, вес $weight"

printf '\n═══ Требование 4: проверка на безопасном сервисе ═══\n'
python3 - <<'PY'
import json

d = json.load(open("audit.json"))
clean = d["чистых"]
print(f"    сервисов всего: {d['сервисов']}")
print(f"    без единой находки: {clean}")
print()
if clean:
    print("    Сервис app не попал в находки: user числовой, read_only,")
    print("    capabilities сняты, повышение привилегий запрещено.")
    print()
    print("    Это проверка инструмента, а не конфигурации: средство,")
    print("    отмечающее ВСЁ, ничем не лучше средства, не отмечающего")
    print("    ничего. Без чистого сервиса находки ничего не значат.")
else:
    print("    ПРОБЛЕМА: чистых сервисов нет — инструмент не различает")
PY
n_clean="$(python3 -c "import json;print(len(json.load(open('audit.json'))['чистых']))")"
[ "$n_clean" -ge 1 ] \
    && ok "есть сервис, проходящий проверку — находки различимы" \
    || bad "чистых сервисов: $n_clean"

printf '\n═══ Требования 5 и 6: выбор средства и что общее всегда ═══\n'
python3 choose.py > choose.log
sed -n '1,40p' choose.log | sed 's/^/  /'
means="$(tail -1 choose.log | python3 -c "import json,sys;print(json.load(sys.stdin)['средств'])")"
always="$(tail -1 choose.log | python3 -c "import json,sys;print(json.load(sys.stdin)['общее_всегда'])")"
printf '\n  средств: %s, общего даже при своём ядре: %s\n' "$means" "$always"
[ "$means" -ge 6 ] && [ "$always" -ge 4 ] \
    && ok "таблица выбора построена; названо общее, не устранимое настройками" \
    || bad "средств $means, общего $always"

printf '\n═══ Требование 7: проверенное и предполагаемое ═══\n'
python3 - <<'PY'
VERIFIED = [
    ("число системных вызовов", "прочитано из заголовка ядра"),
    ("число capabilities", "прочитано из /proc/self/status"),
    ("общие параметры ядра", "прочитаны из /proc/sys"),
    ("ограничение user namespace", "прочитано значение sysctl"),
    ("разбор конфигурации", "выполнен на файле, включая чистый сервис"),
]
NOT_RUN = [
    ("любой container escape", "Docker не установлен; воспроизведение эксплойта не входит в задачу"),
    ("действие профиля seccomp", "требует запущенного container'а"),
    ("поведение userns-remap", "требует настройки daemon"),
    ("побочные каналы", "требует специальных условий и оборудования"),
    ("причина отказа unshare", "среда дополнительно ограничена: mount ns тоже недоступен"),
]
print(f"    {'ВЫПОЛНЕНО':<34} как")
print("    " + "─" * 84)
for name, how in VERIFIED:
    print(f"    {name:<34} {how}")
print()
print(f"    {'НЕ ВЫПОЛНЯЛОСЬ':<34} причина")
print("    " + "─" * 104)
for name, why in NOT_RUN:
    print(f"    {name:<34} {why}")
print()
print(f"    проверено: {len(VERIFIED)}, не проверялось: {len(NOT_RUN)}")
print()
print("    Последняя строка важнее остальных: отказ unshare НЕЛЬЗЯ")
print("    приписать одному лишь параметру apparmor_restrict — среда")
print("    ограничена дополнительно. Подмена «команда не сработала»")
print("    на «сработало ограничение X» — обычный источник ложных")
print("    выводов при отладке изоляции.")
print()
print("    Частота классов escape («подавляющее большинство —")
print("    конфигурация») взята из публичных разборов, а не измерена здесь.")
PY
ok "проверенное отделено от предполагаемого; причина отказа не додумана"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"
echo "  примечание: Docker не использовался; измерения выполнены на ядре хоста"

cd /tmp && rm -rf /tmp/isolab
exit "$fail"

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

text
═══ Требование 1: площадь атаки числом ═══
    ядро:                     6.8.0-88-generic
    системных вызовов:        373
    источник числа:           /usr/include/x86_64-linux-gnu/asm/unistd_64.h
    capabilities в bounding:  41

    Профиль seccomp по умолчанию закрывает несколько десятков вызовов.
    Основная часть интерфейса остаётся доступной — и иначе быть
    не может: приложение обязано работать.

    Это и есть площадь атаки на ОБЩЕЕ ядро: она измерена,
    а не оценена, и не зависит от настроек Docker.

  вызовов: 373, capabilities: 41
  ✓ площадь атаки измерена на реальном ядре

═══ Требование 2: что общее, потому что ядро одно ═══
    параметр                 значение (одно на хост и все container'ы)
    ────────────────────────────────────────────────────────────────────────
    kernel.pid_max           4194304
    vm.max_map_count         1048576
    fs.file-max              9223372036854775807
    kernel.threads-max       ...

    версия ядра: 6.8.0-88-generic — uname -r внутри вернёт то же самое

    ограничение непривилегированных user namespace: 1

    Тот же механизм, который УСИЛИВАЕТ изоляцию container'ов
    (userns-remap), ОСЛАБЛЯЕТ защиту хоста, если доступен всем.
  ✓ общие параметры прочитаны из системы, а не перечислены по памяти

═══ Требование 3: конфигурации, равные root на хосте ═══
    сервис         признак           вес  что даёт
    ────────────────────────────────────────────────────────────────────────────────────────────────────
    ci-runner      docker_sock        10  создание container с любыми параметрами — это root на хосте
    ci-runner      run_as_root         6  UID 0 внутри равен UID 0 снаружи
    monitoring     cap_sys_admin       8  монтирование и значительная часть операций root
    monitoring     pid_host            5  видимость и сигналы всем процессам хоста
    backup         root_mount_ro       4  файловая система хоста доступна на чтение
    legacy         privileged         10  снимает seccomp и AppArmor, возвращает все capabilities
    legacy         seccomp_off         7  фильтр системных вызовов отключён

    суммарный вес: 50

    признак          когда оправдан
    ──────────────────────────────────────────────────────────────────
    cap_sys_admin    почти никогда
    docker_sock      агенты CI, с осознанием последствий
    pid_host         средства наблюдения
    privileged       сборка образов, и то не всегда
    root_mount_ro    резервное копирование
    run_as_root      почти никогда
    seccomp_off      отладка, временно

  код возврата: 1, суммарный вес находок: 50
  ✓ опасные конфигурации найдены, код возврата отличает их от чистой

═══ Требование 4: проверка на безопасном сервисе ═══
    сервисов всего: 5
    без единой находки: ['app']

    Сервис app не попал в находки: user числовой, read_only,
    capabilities сняты, повышение привилегий запрещено.

    Это проверка инструмента, а не конфигурации: средство,
    отмечающее ВСЁ, ничем не лучше средства, не отмечающего
    ничего. Без чистого сервиса находки ничего не значат.
  ✓ есть сервис, проходящий проверку — находки различимы

═══ Требования 5 и 6: выбор средства и что общее всегда ═══
  средство                   своё ядро    свои UID   общее железо   цена
  ──────────────────────────────────────────────────────────────────────────────────────────────
  Container                  нет          нет        да             минимальная
  Container + userns-remap   нет          да         да             ломает часть сценариев
  gVisor                     частично     да         да             часть вызовов не поддержана
  Kata Containers            да           да         да             память и время старта
  Firecracker                да           да         да             своя инфраструктура
  Виртуальная машина         да           да         да             тяжелее по всем статьям
  Отдельная машина           да           да         НЕТ            дороже всего

  полное разделение даёт только: Отдельная машина

  класс escape               механизм                                     частота
  ────────────────────────────────────────────────────────────────────────────────────────────────
  Уязвимость runtime         ошибка в runc, containerd, daemon            редко, но громко
  Уязвимость ядра            ошибка в обработке системного вызова         регулярно
  Неверная конфигурация      privileged, сокет, лишние capabilities       ПОДАВЛЯЮЩЕЕ БОЛЬШИНСТВО
  ...
  ✓ таблица выбора построена; названо общее, не устранимое настройками

═══ Требование 7: проверенное и предполагаемое ═══
    ВЫПОЛНЕНО                          как
    ────────────────────────────────────────────────────────────────────────────────────
    число системных вызовов            прочитано из заголовка ядра
    число capabilities                 прочитано из /proc/self/status
    общие параметры ядра               прочитаны из /proc/sys
    ограничение user namespace         прочитано значение sysctl
    разбор конфигурации                выполнен на файле, включая чистый сервис

    НЕ ВЫПОЛНЯЛОСЬ                     причина
    ────────────────────────────────────────────────────────────────────────────────────────────────────────
    любой container escape             Docker не установлен; воспроизведение эксплойта не входит в задачу
    действие профиля seccomp           требует запущенного container'а
    поведение userns-remap             требует настройки daemon
    побочные каналы                    требует специальных условий и оборудования
    причина отказа unshare             среда дополнительно ограничена: mount ns тоже недоступен

    проверено: 5, не проверялось: 5

    Последняя строка важнее остальных: отказ unshare НЕЛЬЗЯ
    приписать одному лишь параметру apparmor_restrict — среда
    ограничена дополнительно.
  ✓ проверенное отделено от предполагаемого; причина отказа не додумана

═══ ИТОГ ═══
  все требования выполнены
  примечание: Docker не использовался; измерения выполнены на ядре хоста

Все требования выполнены. Числа — 373 системных вызова, 41 capability, значения общих параметров ядра — прочитаны из системы на машине курса.

Три решения, определяющие качество.

Числа читаются из системы, а не задаются в коде. Написать SYSCALLS = 373 было бы проще и выглядело бы так же. Но тогда инструмент показывал бы число, взятое из чужой машины, — а весь смысл в том, что площадь атаки принадлежит вашему ядру. Заголовок ядра и /proc/self/status дают ответ для той системы, где инструмент запущен.

Есть сервис, проходящий проверку. Из пяти сервисов один — app — не даёт ни одной находки. Без него отчёт «нашли семь проблем» не отличался бы от отчёта инструмента, который отмечает любую конфигурацию. Это тот же принцип, что и проверка проверяющего в уроке 19.1.

Причина отказа unshare не додумана. Соблазн был написать «попытка отклонена, потому что apparmor_restrict_unprivileged_userns=1» — связь выглядит очевидной. Но в той же среде не создаётся и mount namespace, к которому этот параметр отношения не имеет, значит ограничение шире. Честный вывод: параметр включён, а причина конкретного отказа требует проверки на обычной машине. Подмена «команда не сработала» на «сработало ограничение X» — обычный источник ложных выводов при отладке изоляции.

Чего решение не делает. Ни один escape не воспроизведён: Docker не установлен, да и воспроизведение эксплойтов не входит в задачу курса. Действие профиля seccomp, поведение userns-remap и побочные каналы не проверялись — все три требуют запущенной системы или специальных условий. Веса в правилах разбора назначены по опыту, а не выведены: они годятся для упорядочивания находок, но «суммарный вес 50» не значит ничего в отрыве от списка. Утверждение о частоте классов escape («подавляющее большинство — конфигурация») взято из публичных разборов, а не измерено здесь, и приведено именно как утверждение, а не как результат.

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

bash
grep -c '^#define __NR_' /usr/include/x86_64-linux-gnu/asm/unistd_64.h
grep CapBnd /proc/self/status
uname -r

Для своей конфигурации ответьте на один вопрос: чей код здесь выполняется? Если хотя бы часть его написана не вашей командой и не проверена, container — не та граница, на которую можно опираться.

Типичные ошибки

ОшибкаПричинаИсправление
Считать container границей безопасностиТак говорятЭто граница между вашими сервисами
Запускать недоверенный код в container'еКажется изолированнымНужна ВМ или отдельная машина
Монтировать docker.sock «только для чтения»Выглядит безопасноЧерез API создаётся любой container: это root
--privileged «чтобы заработало»Быстро решает проблемуСнимает всю защиту разом
Полагаться на seccomp как на главную меруПрофиль есть по умолчаниюЗакрывает малую часть интерфейса
Считать, что уязвимости кончилисьДавно не было громкихКласс сохраняется: интерфейс сложен
Ждать защиты от зависимостейContainer изолируетЗависимости уже внутри границы
Приписывать отказ одной причинеСовпало по времениПроверить, не шире ли ограничение
Полагать, что ВМ решает всёСвоё ядроОборудование остаётся общим

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

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

  1. Почему уязвимость ядра доступна из любого container'а?
  2. Почему root в container'е по умолчанию равен root на хосте?
  3. Какой класс escape встречается чаще всего?
  4. Что именно даёт user namespace и чего не даёт?
  5. Что остаётся общим даже при своём ядре?

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

  1. Как измерить площадь атаки на ядро своей машины?
  2. Какое средство выбрать для кода пользователей продукта?
  3. Почему монтирование docker.sock равно правам root на хосте?

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

  1. В конфигурации есть privileged: true и seccomp=unconfined. Что осталось от изоляции?
  2. unshare возвращает отказ. Как убедиться, что причина именно в настройке, о которой вы подумали?

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

  1. Ядро одно на все container'ы: любая его уязвимость доступна из любого container'а.
  2. Разница с виртуальной машиной — в размере поверхности, которую нужно защищать.
  3. Площадь атаки измерима: сотни системных вызовов и десятки capabilities.
  4. Профиль seccomp закрывает малую часть интерфейса — иначе приложение не заработает.
  5. Docker не создаёт user namespace, поэтому UID 0 внутри равен UID 0 снаружи.
  6. --privileged, docker.sock и CAP_SYS_ADMIN эквивалентны выдаче root на хост.
  7. Три класса escape: runtime, ядро, конфигурация — и третий встречается чаще всех.
  8. Container — граница между вашими сервисами, а не между вами и злоумышленником.
  9. Для недоверенного кода применяют user namespace, gVisor, Kata, микро-ВМ или отдельную машину.
  10. userns-remap поднимает планку, но не меняет класс угрозы: ядро то же.
  11. Тот же механизм, что усиливает изоляцию container'ов, ослабляет защиту хоста, если доступен всем.
  12. Оборудование остаётся общим даже в виртуальной машине; полное разделение даёт только отдельная машина.

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

ИсточникСсылкаЧто подтверждает
Docker: безопасностьhttps://docs.docker.com/engine/security/Модель угроз и её границы
Docker: user namespacehttps://docs.docker.com/engine/security/userns-remap/Преобразование идентификаторов
Docker: seccomphttps://docs.docker.com/engine/security/seccomp/Профиль по умолчанию
Kernel: namespaceshttps://man7.org/linux/man-pages/man7/namespaces.7.htmlЧто именно разделяется
Kernel: capabilitieshttps://man7.org/linux/man-pages/man7/capabilities.7.htmlПолный список привилегий
Kernel: user_namespaceshttps://man7.org/linux/man-pages/man7/user_namespaces.7.htmlПреобразование UID и ограничения
gVisorhttps://gvisor.dev/docs/Перехват вызовов в пространстве пользователя
Kata Containershttps://katacontainers.io/docs/Лёгкая ВМ на container
Firecrackerhttps://firecracker-microvm.github.io/Микро-ВМ
NVD: CVE-2019-5736https://nvd.nist.gov/vuln/detail/CVE-2019-5736Перезапись runc через /proc/self/exe
NVD: CVE-2024-21626https://nvd.nist.gov/vuln/detail/CVE-2024-21626Утечка файлового дескриптора в runc

Навигация

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

Markdown на GitHub ↗