12.3. Пользователи и namespaces
Цели
После этого материала вы сможете:
- объяснить, что может и чего не может root внутри container'а;
- объяснить механизм user namespace и отображение UID;
- настроить
userns-remapи назвать, что при этом ломается; - сравнить три подхода —
USER,userns-remap, rootless — по модели безопасности; - выбрать подход под свой сценарий и обосновать выбор;
- проверить, какая модель действует на конкретной системе.
Предварительные знания
Ключевые термины
| Термин | Объяснение |
|---|---|
user namespace | Namespace, отображающий UID container'а в UID host |
subuid / subgid | Диапазоны UID и GID, выделенные пользователю для отображения |
userns-remap | Режим демона: все container'ы работают в отдельном user namespace |
rootless | Режим, где сам демон работает от непривилегированного пользователя |
capability | Отдельная привилегия root (урок 2.5) |
Теория
Что такое root в container
По умолчанию user namespace не изолирован (урок 12.1): UID 0 в container — это UID 0 на host.
Но полномочия ограничены другими механизмами:
| Ограничение | Что отнимает у root |
|---|---|
| Capabilities (14 из 41) | SYS_ADMIN, SYS_MODULE, SYS_TIME и другие |
| seccomp | Около 44 системных вызовов |
| Mount namespace | Видит только свою файловую систему |
| Read-only корень | Запись, если задан |
| AppArmor / SELinux | Доступ к путям и объектам |
Отсюда точная формулировка: root в container — это UID 0 с усечённым набором полномочий.
Что он может:
| Возможность | Последствие |
|---|---|
| Читать и писать любые файлы в container'е | Изменить приложение, конфигурацию |
| Устанавливать пакеты | Принести инструменты |
| Привязываться к портам ниже 1024 | При наличии NET_BIND_SERVICE |
| Изменять владельца файлов в смонтированном каталоге | Создать файлы root на host (урок 7.5) |
| Использовать оставшиеся capabilities | Зависит от набора |
Чего не может при настройках по умолчанию:
| Ограничение | Механизм |
|---|---|
| Загрузить модуль ядра | Нет SYS_MODULE |
| Изменить системное время | Нет SYS_TIME |
| Смонтировать произвольную файловую систему | Нет SYS_ADMIN |
| Обратиться к устройствам host | Нет доступа к /dev |
| Выполнить заблокированный системный вызов | seccomp |
Почему root в container всё равно плохо
Три причины, в порядке важности:
1. Уязвимость ядра эксплуатируется проще от UID 0. Многие известные способы побега требуют именно root в container'е.
2. Файлы в смонтированных каталогах. Процесс от UID 0 создаёт на host файлы, принадлежащие root (урок 7.5).
3. Ошибка в конфигурации становится критичной. Забытый --privileged, лишняя capability, смонтированный /etc — от root последствия несравнимо тяжелее.
Отсюда практика: USER в образе — обязательный минимум, независимо от других мер.
User namespace: механизм
User namespace отображает диапазон UID container'а в другой диапазон на host:
container host
───────── ────
UID 0 ──► UID 100000
UID 1 ──► UID 100001
UID 1000 ──► UID 101000
... ...
UID 65535 ──► UID 165535
Процесс в container видит себя как root и обладает всеми capabilities внутри своего namespace. На host это UID 100000 — обычный непривилегированный пользователь.
Отображение задаётся файлами:
/etc/subuid dockremap:100000:65536
/etc/subgid dockremap:100000:65536
Запись означает: пользователю dockremap выделены 65536 UID начиная со 100000.
userns-remap
Режим демона, включающий user namespace для всех container'ов:
{
"userns-remap": "default"
}
Значение default создаёт пользователя dockremap автоматически.
| Что даёт | Что ломает |
|---|---|
| root в container ≠ root на host | Volumes принадлежат смещённым UID |
| Побег даёт непривилегированный UID | --privileged несовместим |
| Работает для всех container'ов сразу | --pid=host, --net=host ограничены |
| Не требует переписывания образов | Существующие volumes нужно мигрировать |
Строка про volumes — главная практическая сложность. После включения userns-remap файлы в существующих volumes принадлежат UID 0, а процесс в container'е — UID 100000. Доступ пропадает.
Отключить отображение для отдельного container'а:
docker run --userns=host ...
Это возвращает прежнее поведение — и прежние риски. Флаг нужен, например, для container'ов, которым требуется --privileged.
Rootless Docker
Здесь в отдельном user namespace работает сам демон:
rootful: dockerd (root) ──► container (UID 0 = root на host)
rootless: dockerd (evg) ──► container (UID 0 = UID 100000 на host)
│
└── сам демон не имеет привилегий
| Свойство | Значение |
|---|---|
| Демон работает от | Обычного пользователя |
| Компрометация демона даёт | Права этого пользователя |
| Доступ к сокету даёт | Права этого пользователя (урок 12.2) |
| Порты ниже 1024 | Требуют настройки |
| Сетевая производительность | Ниже: используется пространство пользователя |
| Часть возможностей | Недоступна |
Rootless — самая сильная из трёх моделей, потому что снимает главный вектор: доступ к сокету перестаёт быть доступом к root.
Сравнение трёх подходов
USER в образе | userns-remap | Rootless | |
|---|---|---|---|
| Что защищает | Процесс не root в container | UID container ≠ UID host | Демон не root |
| root в container возможен | Нет (по умолчанию) | Да, но безвредный | Да, но безвредный |
| Побег даёт | UID приложения на host | Смещённый UID | Смещённый UID |
| Доступ к сокету даёт | root на host | root на host | Права пользователя |
| Сложность внедрения | Низкая | Средняя | Высокая |
| Совместимость | Полная | Ограниченная | Ограниченная |
| Кто настраивает | Автор образа | Администратор узла | Пользователь |
Они не взаимоисключающие. USER в образе применяют всегда; userns-remap или rootless — дополнительно, по результатам модели угроз.
Рекомендация по сценариям:
| Сценарий | Подход |
|---|---|
| Машина разработчика | USER плюс rootless |
| Одиночный сервер, свои сервисы | USER плюс userns-remap |
| CI-агент | Rootless либо сборка без демона |
| Многопользовательский узел | Rootless обязателен |
| Недоверенный код | Ничего из перечисленного не достаточно (урок 12.1) |
Внутренний механизм
Как ядро отображает UID
При создании user namespace ядро читает /proc/<pid>/uid_map:
0 100000 65536
│ │ └── размер диапазона
│ └───────── первый UID на host
└────────────── первый UID в namespace
Запись означает: UID 0–65535 внутри соответствуют UID 100000–165535 снаружи.
Проверить изнутри container'а:
cat /proc/self/uid_map
Строка 0 0 4294967295 означает, что отображения нет: namespace совпадает с host.
Почему capabilities внутри namespace «полные»
Процесс, создавший user namespace, получает все capabilities в нём. Это не привилегии на host: capability действует только над объектами, принадлежащими этому namespace.
Например, CAP_SYS_ADMIN внутри позволяет монтировать файловые системы в своей mount namespace, но не трогать монтирования host.
Именно на этом основана безопасность rootless-режима: демон обладает полномочиями в своём namespace и никакими — снаружи.
Команды и примеры
Что может root в container
mkdir -p /tmp/users && cd /tmp/users
cat > probe.py <<'PY'
"""Что доступно процессу: UID, capabilities, привилегированные операции."""
from __future__ import annotations
import ctypes
import ctypes.util
import json
import os
from pathlib import Path
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", 16: "SYS_MODULE", 17: "SYS_RAWIO", 18: "SYS_CHROOT",
19: "SYS_PTRACE", 21: "SYS_ADMIN", 22: "SYS_BOOT", 23: "SYS_NICE",
24: "SYS_RESOURCE", 25: "SYS_TIME", 27: "MKNOD", 29: "AUDIT_WRITE", 31: "SETFCAP",
}
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() -> list[str]:
mask = int(status("CapEff"), 16)
return [n for b, n in sorted(CAP_NAMES.items()) if mask & (1 << b)]
def uid_map() -> str:
try:
return Path("/proc/self/uid_map").read_text().strip()
except OSError:
return "нет"
def try_mount() -> str:
"""Монтирование требует CAP_SYS_ADMIN."""
libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
os.makedirs("/tmp/mnt-probe", exist_ok=True)
res = libc.mount(b"tmpfs", b"/tmp/mnt-probe", b"tmpfs", 0, None)
if res == 0:
libc.umount(b"/tmp/mnt-probe")
return "разрешено"
return f"отказ (errno {ctypes.get_errno()})"
def try_settime() -> str:
"""Изменение времени требует CAP_SYS_TIME."""
libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
class Timespec(ctypes.Structure):
_fields_ = [("tv_sec", ctypes.c_long), ("tv_nsec", ctypes.c_long)]
ts = Timespec()
libc.clock_gettime(0, ctypes.byref(ts))
res = libc.clock_settime(0, ctypes.byref(ts))
return "разрешено" if res == 0 else f"отказ (errno {ctypes.get_errno()})"
def try_module() -> str:
"""Загрузка модуля требует CAP_SYS_MODULE."""
try:
Path("/proc/sys/kernel/modprobe").read_text()
return "чтение доступно; загрузка требует SYS_MODULE"
except OSError as exc:
return f"отказ: {exc.strerror}"
print(json.dumps({
"uid": os.getuid(),
"euid": os.geteuid(),
"uid_map": uid_map(),
"capabilities": caps(),
"capabilities_всего": len(caps()),
"монтирование tmpfs": try_mount(),
"изменение времени": try_settime(),
"модули ядра": try_module(),
}, ensure_ascii=False, indent=2))
PY
echo "═══ root в container по умолчанию ═══"
docker run --rm -v "$PWD/probe.py:/p.py:ro" python:3.13-slim python /p.py
Ожидаемый вывод:
═══ root в container по умолчанию ═══
{
"uid": 0,
"euid": 0,
"uid_map": "0 0 4294967295",
"capabilities": [
"CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "KILL", "SETGID", "SETUID",
"SETPCAP", "NET_BIND_SERVICE", "NET_RAW", "SYS_CHROOT", "MKNOD",
"AUDIT_WRITE", "SETFCAP"
],
"capabilities_всего": 14,
"монтирование tmpfs": "отказ (errno 1)",
"изменение времени": "отказ (errno 1)",
"модули ядра": "чтение доступно; загрузка требует SYS_MODULE"
}
Разбор строки uid_map: 0 0 4294967295 означает, что UID 0 в container отображается в UID 0 на host — отображения нет.
При этом монтирование и изменение времени запрещены: errno 1 — это EPERM. Ограничивают не namespace, а отсутствие SYS_ADMIN и SYS_TIME в наборе из 14 capabilities.
Это и есть точная картина: UID 0 с усечёнными полномочиями.
Что меняет USER
cd /tmp/users
echo "═══ тот же образ, но --user 10001 ═══"
docker run --rm --user 10001:10001 -v "$PWD/probe.py:/p.py:ro" \
python:3.13-slim python /p.py | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\" uid: {d['uid']}, capabilities: {d['capabilities_всего']}\")
print(f\" список: {d['capabilities'] or 'пусто'}\")
print(f\" монтирование: {d['монтирование tmpfs']}\")
"
echo "═══ и с --cap-drop=ALL от root ═══"
docker run --rm --cap-drop=ALL -v "$PWD/probe.py:/p.py:ro" \
python:3.13-slim python /p.py | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\" uid: {d['uid']}, capabilities: {d['capabilities_всего']}\")
print(f\" монтирование: {d['монтирование tmpfs']}\")
"
Ожидаемый вывод:
═══ тот же образ, но --user 10001 ═══
uid: 10001, capabilities: 0
список: пусто
монтирование: отказ (errno 1)
═══ и с --cap-drop=ALL от root ═══
uid: 0, capabilities: 0
монтирование: отказ (errno 1)
Оба варианта дают пустой набор capabilities, но разными путями.
При --user 10001 ядро очищает effective-набор при переходе на непривилегированного пользователя. При --cap-drop=ALL набор очищает Docker, а UID остаётся нулевым.
Практическая разница: во втором случае процесс формально root, и файлы в смонтированных каталогах создаются от root (урок 7.5). Первый вариант этого лишён — ещё один довод за USER в образе.
Файлы на host: с root и без
cd /tmp/users
mkdir -p shared && chmod 777 shared
echo "═══ файл, созданный root в container ═══"
docker run --rm -v "$PWD/shared:/data" alpine:3.21 \
sh -c 'echo x > /data/from-root.txt'
ls -ln shared/from-root.txt | awk '{print " владелец: " $3 ":" $4}'
echo "═══ файл, созданный UID 10001 ═══"
docker run --rm --user 10001:10001 -v "$PWD/shared:/data" alpine:3.21 \
sh -c 'echo x > /data/from-user.txt'
ls -ln shared/from-user.txt | awk '{print " владелец: " $3 ":" $4}'
echo "═══ удаление обычным пользователем ═══"
rm -f shared/from-user.txt 2>&1 && echo " from-user.txt: удалён" || echo " from-user.txt: отказ"
rm -f shared/from-root.txt 2>&1 | tail -1 | sed 's/^/ from-root.txt: /' \
|| echo " from-root.txt: удалён"
sudo rm -rf shared 2>/dev/null || rm -rf shared
Ожидаемый вывод:
═══ файл, созданный root в container ═══
владелец: 0:0
═══ файл, созданный UID 10001 ═══
владелец: 10001:10001
═══ удаление обычным пользователем ═══
from-user.txt: удалён
from-root.txt: rm: cannot remove 'shared/from-root.txt': Permission denied
Прямое следствие отсутствия user namespace: UID 0 в container — это UID 0 на host, и созданный файл принадлежит настоящему root.
Как выглядит отображение UID
cd /tmp/users
echo "═══ uid_map на host ═══"
cat /proc/self/uid_map | sed 's/^/ /'
echo "═══ uid_map в обычном container ═══"
docker run --rm alpine:3.21 cat /proc/self/uid_map | sed 's/^/ /'
echo "═══ как читать ═══"
cat <<'TXT'
формат: <первый UID внутри> <первый UID снаружи> <размер диапазона>
"0 0 4294967295" отображения нет: UID совпадают
"0 100000 65536" UID 0-65535 внутри = UID 100000-165535 снаружи
TXT
echo "═══ настройки subuid на этой машине ═══"
if [ -f /etc/subuid ]; then
head -3 /etc/subuid | sed 's/^/ /'
else
echo " /etc/subuid отсутствует"
fi
Ожидаемый вывод:
═══ uid_map на host ═══
0 0 4294967295
═══ uid_map в обычном container ═══
0 0 4294967295
═══ как читать ═══
формат: <первый UID внутри> <первый UID снаружи> <размер диапазона>
"0 0 4294967295" отображения нет: UID совпадают
"0 100000 65536" UID 0-65535 внутри = UID 100000-165535 снаружи
═══ настройки subuid на этой машине ═══
evg:100000:65536
Одинаковый uid_map на host и в container — прямое доказательство, что user namespace не изолирован.
Строка в /etc/subuid показывает выделенный пользователю диапазон: он понадобится для rootless-режима.
Настройка userns-remap
cd /tmp/users
cat > setup-userns.md <<'TXT'
# Включение userns-remap
## 1. Конфигурация демона
/etc/docker/daemon.json:
{
"userns-remap": "default"
}
Значение "default" создаёт пользователя dockremap автоматически.
Можно указать существующего пользователя: "userns-remap": "myuser".
## 2. Перезапуск демона
sudo systemctl restart docker
## 3. Проверка
docker info --format '{{.SecurityOptions}}'
# должно появиться: name=userns
docker run --rm alpine cat /proc/self/uid_map
# должно быть: 0 100000 65536 (вместо 0 0 4294967295)
## 4. Что сломается
| Проблема | Причина | Решение |
|---|---|---|
| Существующие volumes недоступны | Файлы принадлежат UID 0, процесс — 100000 | chown -R 100000:100000 или пересоздать |
| --privileged не работает | Несовместим с userns | --userns=host для этого container |
| --pid=host, --net=host ограничены | Требуют совпадения namespace | --userns=host |
| Образы скачиваются заново | Отдельное хранилище для remap | Ожидаемо, разово |
## 5. Отключение для отдельного container
docker run --userns=host ...
Возвращает прежнее поведение и прежние риски. Применять точечно.
## 6. Проверка после включения
# файл, созданный root в container, на host принадлежит 100000
docker run --rm -v /tmp/probe:/data alpine sh -c 'touch /data/f'
ls -ln /tmp/probe/f # владелец 100000, а не 0
TXT
echo "═══ инструкция подготовлена ═══"
head -12 setup-userns.md | sed 's/^/ /'
echo
echo "═══ текущее состояние демона ═══"
if docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q userns; then
echo " userns-remap ВКЛЮЧЁН"
docker run --rm alpine:3.21 cat /proc/self/uid_map | sed 's/^/ uid_map: /'
else
echo " userns-remap выключен"
echo " (включение требует правки daemon.json и перезапуска демона)"
fi
Ожидаемый вывод:
═══ инструкция подготовлена ═══
# Включение userns-remap
## 1. Конфигурация демона
/etc/docker/daemon.json:
{
"userns-remap": "default"
}
Значение "default" создаёт пользователя dockremap автоматически.
Можно указать существующего пользователя: "userns-remap": "myuser".
═══ текущее состояние демона ═══
userns-remap выключен
(включение требует правки daemon.json и перезапуска демона)
Включение userns-remap меняет поведение всех container'ов на узле и требует перезапуска демона — поэтому в уроке приводится инструкция, а не выполняется автоматически.
Симуляция отображения UID
Механизм user namespace можно наблюдать без перенастройки демона:
cd /tmp/users
echo "═══ создаём user namespace вручную ═══"
if command -v unshare > /dev/null 2>&1; then
echo " на host:"
printf ' UID: %s, uid_map: %s\n' "$(id -u)" "$(cat /proc/self/uid_map | tr -s ' ')"
echo " внутри нового user namespace:"
unshare --user --map-root-user sh -c '
printf " UID: %s (внутри кажемся root)\n" "$(id -u)"
printf " uid_map: %s\n" "$(cat /proc/self/uid_map | tr -s " ")"
printf " capabilities: %s\n" "$(grep CapEff /proc/self/status | awk "{print \$2}")"
printf " чтение /etc/shadow: "
cat /etc/shadow > /dev/null 2>&1 && echo "разрешено" || echo "ОТКАЗ"
' 2>/dev/null || echo " (unshare требует поддержки в ядре)"
else
echo " утилита unshare недоступна"
fi
echo "═══ что это показывает ═══"
cat <<'TXT'
Внутри namespace процесс видит себя как root (UID 0) и имеет
полный набор capabilities. Но uid_map показывает отображение
на реальный UID, и файлы root по-прежнему недоступны.
Это ровно та модель, которую даёт userns-remap и rootless:
root внутри — обычный пользователь снаружи.
TXT
Ожидаемый вывод:
═══ создаём user namespace вручную ═══
на host:
UID: 1000, uid_map: 0 0 4294967295
внутри нового user namespace:
UID: 0 (внутри кажемся root)
uid_map: 0 1000 1
capabilities: 000001ffffffffff
чтение /etc/shadow: ОТКАЗ
═══ что это показывает ═══
Внутри namespace процесс видит себя как root (UID 0) и имеет
полный набор capabilities. Но uid_map показывает отображение
на реальный UID, и файлы root по-прежнему недоступны.
Это ровно та модель, которую даёт userns-remap и rootless:
root внутри — обычный пользователь снаружи.
Три строки объясняют весь механизм.
UID: 0 — процесс считает себя root.
uid_map: 0 1000 1 — UID 0 внутри соответствует UID 1000 снаружи.
capabilities: 000001ffffffffff — полный набор, но только в этом namespace: /etc/shadow по-прежнему недоступен, потому что файл принадлежит настоящему root, а namespace его UID не отображает.
Сравнение трёх моделей
cd /tmp/users
cat > compare.py <<'PY'
"""Сравнение подходов к ограничению привилегий."""
from __future__ import annotations
MODELS = {
"USER в образе": {
"что защищает": "процесс не root внутри container",
"root внутри возможен": "нет",
"побег даёт": "UID приложения на host",
"сокет даёт": "root на host",
"сложность": 1,
"совместимость": 5,
"кто настраивает": "автор образа",
},
"userns-remap": {
"что защищает": "UID container отображён в другой диапазон",
"root внутри возможен": "да, но безвредный",
"побег даёт": "смещённый UID (100000+)",
"сокет даёт": "root на host",
"сложность": 3,
"совместимость": 3,
"кто настраивает": "администратор узла",
},
"rootless": {
"что защищает": "сам демон не имеет привилегий",
"root внутри возможен": "да, но безвредный",
"побег даёт": "смещённый UID",
"сокет даёт": "права пользователя",
"сложность": 4,
"совместимость": 3,
"кто настраивает": "пользователь",
},
}
FIELDS = ["что защищает", "root внутри возможен", "побег даёт",
"сокет даёт", "кто настраивает"]
print(f" {'свойство':<24} " + " ".join(f"{name:<26}" for name in MODELS))
print(" " + "─" * 104)
for field in FIELDS:
row = f" {field:<24} "
row += " ".join(f"{MODELS[name][field]:<26}" for name in MODELS)
print(row[:106])
print()
print(f" {'сложность (1-5)':<24} " +
" ".join(f"{str(MODELS[n]['сложность']):<26}" for n in MODELS))
print(f" {'совместимость (1-5)':<24} " +
" ".join(f"{str(MODELS[n]['совместимость']):<26}" for n in MODELS))
print()
print(" Ключевая строка — «сокет даёт»: только rootless снимает этот вектор.")
print(" Подходы НЕ взаимоисключающие: USER применяют всегда,")
print(" остальное — дополнительно по модели угроз.")
PY
python3 compare.py
Ожидаемый вывод:
свойство USER в образе userns-remap rootless
────────────────────────────────────────────────────────────────────────────────────────────────────────
что защищает процесс не root внутри con UID container отображён в сам демон не имеет привиле
root внутри возможен нет да, но безвредный да, но безвредный
побег даёт UID приложения на host смещённый UID (100000+) смещённый UID
сокет даёт root на host root на host права пользователя
кто настраивает автор образа администратор узла пользователь
сложность (1-5) 1 3 4
совместимость (1-5) 5 3 3
Ключевая строка — «сокет даёт»: только rootless снимает этот вектор.
Подходы НЕ взаимоисключающие: USER применяют всегда,
остальное — дополнительно по модели угроз.
Строка «сокет даёт» — итог сравнения. USER и userns-remap ограничивают процесс в container'е, но не меняют того, что даёт доступ к сокету (урок 12.2).
Проверка действующей модели
cd /tmp/users
cat > check-model.sh <<'SH'
#!/usr/bin/env bash
# Определяет, какая модель ограничения привилегий действует на системе.
set -uo pipefail
printf '\n── Демон ──\n'
if docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q rootless; then
printf ' режим: ROOTLESS\n'
printf ' сокет даёт: права вашего пользователя\n'
mode="rootless"
elif docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q userns; then
printf ' режим: rootful + userns-remap\n'
printf ' сокет даёт: root на host\n'
mode="userns"
else
printf ' режим: rootful (демон работает от root)\n'
printf ' сокет даёт: root на host\n'
mode="rootful"
fi
printf '\n── Отображение UID ──\n'
map="$(docker run --rm alpine:3.21 cat /proc/self/uid_map 2>/dev/null | tr -s ' ' | sed 's/^ //')"
printf ' uid_map в container: %s\n' "$map"
case "$map" in
"0 0 "*) printf ' → отображения нет: UID 0 внутри = UID 0 на host\n' ;;
*) printf ' → отображение действует\n' ;;
esac
printf '\n── Проверка: файл от root в container ──\n'
probe="$(mktemp -d)"
docker run --rm -v "$probe:/data" alpine:3.21 sh -c 'echo x > /data/f' 2>/dev/null
owner="$(stat -c '%u:%g' "$probe/f" 2>/dev/null || echo '?')"
printf ' владелец на host: %s\n' "$owner"
case "$owner" in
"0:0") printf ' → UID 0 в container = настоящий root\n' ;;
"?") printf ' → проверка не удалась\n' ;;
*) printf ' → UID отображён, root в container безвреден\n' ;;
esac
rm -rf "$probe" 2>/dev/null || sudo rm -rf "$probe" 2>/dev/null
printf '\n── Рекомендации ──\n'
case "$mode" in
rootful)
printf ' 1. USER в образе — обязательно (сейчас единственная защита)\n'
printf ' 2. Рассмотреть userns-remap для узла\n'
printf ' 3. Rootless — если возможно по совместимости\n'
printf ' 4. Не монтировать сокет никуда\n'
;;
userns)
printf ' 1. USER в образе — всё равно применять\n'
printf ' 2. Сокет по-прежнему опасен: не монтировать\n'
;;
rootless)
printf ' 1. USER в образе — всё равно применять\n'
printf ' 2. Основные векторы закрыты\n'
;;
esac
SH
chmod +x check-model.sh
./check-model.sh
cd /tmp && rm -rf /tmp/users
Ожидаемый вывод:
── Демон ──
режим: rootful (демон работает от root)
сокет даёт: root на host
── Отображение UID ──
uid_map в container: 0 0 4294967295
→ отображения нет: UID 0 внутри = UID 0 на host
── Проверка: файл от root в container ──
владелец на host: 0:0
→ UID 0 в container = настоящий root
── Рекомендации ──
1. USER в образе — обязательно (сейчас единственная защита)
2. Рассмотреть userns-remap для узла
3. Rootless — если возможно по совместимости
4. Не монтировать сокет никуда
Три проверки дают согласованный ответ, и скрипт выдаёт рекомендации, соответствующие действующей модели. Это удобнее общих советов: они зависят от того, что уже настроено.
Практическое упражнение
Задание. Определите действующую модель привилегий и обоснуйте выбор подхода.
Требования:
- Показать, что может и чего не может root в container: capabilities, монтирование, изменение времени.
- Показать разницу между
--user 10001и--cap-drop=ALLот root: в обоих случаях capabilities пусты, но владелец создаваемых файлов различается. - Показать механизм user namespace через
unshare: root внутри, обычный UID снаружи. - Определить действующую модель на системе тремя независимыми проверками.
- Составить таблицу сравнения трёх подходов с обоснованием выбора для конкретного сценария.
- Записать решение с условием пересмотра.
Подсказки
Подсказка 1
Для пункта 1 привилегированные операции проверяются через ctypes и libc, а не через утилиты — так надёжнее.
Подсказка 2
Пункт 2 проверяется владельцем файла в смонтированном каталоге.
Подсказка 3
Пункт 4: docker info, uid_map в container и владелец созданного файла должны давать согласованный ответ.
Решение
Показать решение
mkdir -p /tmp/userns/{probe,shared} && cd /tmp/userns
cat > probe/capabilities.py <<'PY'
"""Проверка привилегий процесса: capabilities и привилегированные операции."""
from __future__ import annotations
import ctypes
import ctypes.util
import json
import os
from pathlib import Path
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",
}
_libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
def status(field: str) -> str:
try:
for line in Path("/proc/self/status").read_text().splitlines():
if line.startswith(field + ":"):
return line.split(maxsplit=1)[1].strip()
except OSError:
pass
return "?"
def caps_list() -> list[str]:
raw = status("CapEff")
if raw == "?":
return []
mask = int(raw, 16)
return [n for b, n in sorted(CAP_NAMES.items()) if mask & (1 << b)]
def op_mount() -> str:
"""Требует CAP_SYS_ADMIN."""
target = "/tmp/_mnt_probe"
try:
os.makedirs(target, exist_ok=True)
except OSError as exc:
return f"нет доступа к /tmp: {exc.strerror}"
ctypes.set_errno(0)
if _libc.mount(b"tmpfs", target.encode(), b"tmpfs", 0, None) == 0:
_libc.umount(target.encode())
return "РАЗРЕШЕНО"
return f"отказ (errno {ctypes.get_errno()})"
def op_settime() -> str:
"""Требует CAP_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)
if _libc.clock_settime(0, ctypes.byref(ts)) == 0:
return "РАЗРЕШЕНО"
return f"отказ (errno {ctypes.get_errno()})"
def op_chroot() -> str:
"""Требует CAP_SYS_CHROOT."""
ctypes.set_errno(0)
pid = os.fork()
if pid == 0:
rc = _libc.chroot(b"/tmp")
os._exit(0 if rc == 0 else 1)
_, st = os.waitpid(pid, 0)
return "РАЗРЕШЕНО" if os.waitstatus_to_exitcode(st) == 0 else "отказ"
def op_read_shadow() -> str:
try:
Path("/etc/shadow").read_bytes()
return "РАЗРЕШЕНО"
except OSError as exc:
return f"отказ: {exc.strerror}"
if __name__ == "__main__":
print(json.dumps({
"uid": os.getuid(),
"euid": os.geteuid(),
"uid_map": Path("/proc/self/uid_map").read_text().strip()
if Path("/proc/self/uid_map").exists() else "нет",
"cap_eff": status("CapEff"),
"capabilities": caps_list(),
"всего_capabilities": len(caps_list()),
"операции": {
"монтирование (SYS_ADMIN)": op_mount(),
"изменение времени (SYS_TIME)": op_settime(),
"chroot (SYS_CHROOT)": op_chroot(),
"чтение /etc/shadow": op_read_shadow(),
},
}, ensure_ascii=False, indent=2))
PY
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
run_probe() { docker run --rm "$@" -v "$PWD/probe/capabilities.py:/p.py:ro" \
python:3.13-slim python /p.py 2>/dev/null; }
field() { python3 -c "
import json, sys
d = json.load(sys.stdin)
keys = '$1'.split('.')
v = d
for k in keys:
v = v[k]
print(v)
"; }
printf '\n═══ Пункт 1: что может root в container ═══\n'
out_root="$(run_probe)"
echo "$out_root" | python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\" uid={d['uid']} capabilities={d['всего_capabilities']}\")
print(f\" uid_map: {' '.join(d['uid_map'].split())}\")
for op, res in d['операции'].items():
print(f' {op:<30} {res}')
"
caps_root="$(echo "$out_root" | field "всего_capabilities")"
mount_root="$(echo "$out_root" | field "операции.монтирование (SYS_ADMIN)")"
[ "$caps_root" = "14" ] && ok "14 capabilities из 41 — усечённый набор" \
|| ok "capabilities: $caps_root"
case "$mount_root" in
отказ*) ok "монтирование запрещено: нет SYS_ADMIN" ;;
*) bad "монтирование разрешено — проверьте настройки" ;;
esac
printf '\n═══ Пункт 2: --user против --cap-drop=ALL ═══\n'
printf ' %-22s %-6s %-14s %s\n' "конфигурация" "uid" "capabilities" "владелец файла"
chmod 777 shared
for cfg in "по умолчанию:" "--user 10001:10001" "--cap-drop=ALL"; do
case "$cfg" in
"по умолчанию:") args=(); label="по умолчанию"; fname="default" ;;
"--user 10001:10001") args=(--user 10001:10001); label="--user 10001"; fname="user" ;;
*) args=(--cap-drop=ALL); label="--cap-drop=ALL"; fname="capdrop" ;;
esac
out="$(run_probe "${args[@]}")"
uid="$(echo "$out" | field uid)"
ncaps="$(echo "$out" | field "всего_capabilities")"
docker run --rm "${args[@]}" -v "$PWD/shared:/data" alpine:3.21 \
sh -c "echo x > /data/$fname.txt" 2>/dev/null
owner="$(stat -c '%u:%g' "shared/$fname.txt" 2>/dev/null || echo '?')"
printf ' %-22s %-6s %-14s %s\n' "$label" "$uid" "$ncaps" "$owner"
done
owner_user="$(stat -c '%u' shared/user.txt 2>/dev/null || echo '?')"
owner_capdrop="$(stat -c '%u' shared/capdrop.txt 2>/dev/null || echo '?')"
caps_user="$(run_probe --user 10001:10001 | field "всего_capabilities")"
caps_drop="$(run_probe --cap-drop=ALL | field "всего_capabilities")"
[ "$caps_user" = "0" ] && [ "$caps_drop" = "0" ] \
&& ok "оба варианта дают пустой набор capabilities" || bad "capabilities: $caps_user и $caps_drop"
[ "$owner_user" = "10001" ] && [ "$owner_capdrop" = "0" ] \
&& ok "но владелец файла различается: 10001 против 0 — довод за USER" \
|| bad "владельцы: $owner_user и $owner_capdrop"
sudo rm -f shared/*.txt 2>/dev/null || rm -f shared/*.txt 2>/dev/null
printf '\n═══ Пункт 3: механизм user namespace ═══\n'
if command -v unshare > /dev/null 2>&1; then
printf ' на host: uid=%s uid_map=%s\n' "$(id -u)" "$(tr -s ' ' < /proc/self/uid_map | sed 's/^ //')"
unshare_out="$(unshare --user --map-root-user sh -c '
printf "uid=%s|map=%s|caps=%s|shadow=%s" \
"$(id -u)" \
"$(tr -s " " < /proc/self/uid_map | sed "s/^ //")" \
"$(grep CapEff /proc/self/status | awk "{print \$2}")" \
"$(cat /etc/shadow > /dev/null 2>&1 && echo разрешено || echo ОТКАЗ)"
' 2>/dev/null)"
if [ -n "$unshare_out" ]; then
IFS='|' read -r u m c s <<< "$unshare_out"
printf ' в namespace: %s %s\n' "$u" "$m"
printf ' %s %s\n' "$c" "$s"
echo "$u" | grep -q 'uid=0' && echo "$s" | grep -q 'ОТКАЗ' \
&& ok "внутри root, снаружи обычный UID: файлы root недоступны" \
|| bad "поведение неожиданное"
else
printf ' unshare недоступен в этой среде\n'
ok "механизм описан, проверка пропущена"
fi
else
printf ' утилита unshare отсутствует\n'
ok "механизм описан, проверка пропущена"
fi
printf '\n═══ Пункт 4: действующая модель, три проверки ═══\n'
# Проверка 1: режим демона
if docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q rootless; then
mode="rootless"
elif docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q userns; then
mode="userns-remap"
else
mode="rootful"
fi
printf ' 1. режим демона: %s\n' "$mode"
# Проверка 2: uid_map
map="$(docker run --rm alpine:3.21 cat /proc/self/uid_map 2>/dev/null | tr -s ' ' | sed 's/^ //')"
printf ' 2. uid_map в container: %s\n' "$map"
case "$map" in
"0 0 "*) map_verdict="отображения нет" ;;
*) map_verdict="отображение действует" ;;
esac
printf ' → %s\n' "$map_verdict"
# Проверка 3: владелец файла
docker run --rm -v "$PWD/shared:/data" alpine:3.21 sh -c 'echo x > /data/model.txt' 2>/dev/null
owner="$(stat -c '%u:%g' shared/model.txt 2>/dev/null || echo '?')"
printf ' 3. файл от root: владелец %s\n' "$owner"
case "$owner" in
"0:0") owner_verdict="UID 0 в container = настоящий root" ;;
*) owner_verdict="UID отображён" ;;
esac
printf ' → %s\n' "$owner_verdict"
sudo rm -f shared/model.txt 2>/dev/null || rm -f shared/model.txt 2>/dev/null
# Согласованность
consistent=0
if [ "$mode" = "rootful" ] && [ "$map_verdict" = "отображения нет" ] && [ "$owner" = "0:0" ]; then
consistent=1
elif [ "$mode" != "rootful" ] && [ "$map_verdict" = "отображение действует" ]; then
consistent=1
fi
[ "$consistent" -eq 1 ] && ok "три проверки согласованы: модель — $mode" \
|| bad "проверки противоречат друг другу"
printf '\n═══ Пункт 5: сравнение подходов ═══\n'
python3 - <<'PY'
MODELS = [
("USER в образе", "процесс не root в container", "UID приложения",
"root на host", 1, 5),
("userns-remap", "UID отображён в другой диапазон", "смещённый UID",
"root на host", 3, 3),
("rootless", "демон без привилегий", "смещённый UID",
"права пользователя", 4, 3),
]
print(f" {'подход':<16} {'побег даёт':<18} {'сокет даёт':<22} {'слож.':>5} {'совм.':>6}")
print(" " + "─" * 72)
for name, protects, escape, socket_gives, complexity, compat in MODELS:
print(f" {name:<16} {escape:<18} {socket_gives:<22} {complexity:>5} {compat:>6}")
print()
print(" Вывод: только rootless меняет последствия доступа к сокету.")
print(" USER применяется всегда и не конфликтует с остальными.")
PY
ok "таблица сравнения составлена"
printf '\n═══ Пункт 6: решение ═══\n'
cat > DECISION.md <<TXT
# Решение по модели привилегий
Дата: 2026-07-31. Действующий режим демона на момент решения: $mode.
## Применяем
**USER 10001:10001 в образе — безусловно.**
Обоснование: единственная мера, не требующая изменений на узле и
не имеющая ограничений совместимости. Снимает вектор «файлы от root
в смонтированных каталогах» и усложняет эксплуатацию уязвимостей ядра.
## Не применяем: userns-remap
Обоснование: узел используется одной командой, недоверенный код не
запускается. Внедрение требует миграции существующих volumes
(chown -R 100000:100000) и ломает container'ы, которым нужен --privileged.
Выигрыш не покрывает стоимость перехода при текущей модели угроз.
**Условие пересмотра:** появление на узле сервисов других команд;
запуск кода, полученного извне; требование аудита.
## Не применяем: rootless
Обоснование: требуется публикация портов ниже 1024 без дополнительной
настройки и полная производительность сети. Rootless накладывает
ограничения на оба пункта.
**Условие пересмотра:** переход на узлы без прав администратора;
необходимость снять вектор доступа к сокету полностью.
## Компенсирующая мера
Поскольку rootless не применяется, доступ к сокету остаётся
эквивалентом root ([урок 12.2]). Компенсируем запретом:
- Docker socket не монтируется ни в один container.
- Проверка в CI: grep по конфигурациям на docker.sock.
- Состав группы docker пересматривается ежеквартально.
TXT
head -14 DECISION.md | sed 's/^/ /'
[ -s DECISION.md ] && grep -q "Условие пересмотра" DECISION.md \
&& ok "решение записано с обоснованием и условиями пересмотра" \
|| bad "решение неполно"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
cd /tmp && rm -rf /tmp/userns
exit "$fail"
Ожидаемый вывод:
═══ Пункт 1: что может root в container ═══
uid=0 capabilities=14
uid_map: 0 0 4294967295
монтирование (SYS_ADMIN) отказ (errno 1)
изменение времени (SYS_TIME) отказ (errno 1)
chroot (SYS_CHROOT) РАЗРЕШЕНО
чтение /etc/shadow РАЗРЕШЕНО
✓ 14 capabilities из 41 — усечённый набор
✓ монтирование запрещено: нет SYS_ADMIN
═══ Пункт 2: --user против --cap-drop=ALL ═══
конфигурация uid capabilities владелец файла
по умолчанию 0 14 0:0
--user 10001 10001 0 10001:10001
--cap-drop=ALL 0 0 0:0
✓ оба варианта дают пустой набор capabilities
✓ но владелец файла различается: 10001 против 0 — довод за USER
═══ Пункт 3: механизм user namespace ═══
на host: uid=1000 uid_map=0 0 4294967295
в namespace: uid=0 map=0 1000 1
caps=000001ffffffffff shadow=ОТКАЗ
✓ внутри root, снаружи обычный UID: файлы root недоступны
═══ Пункт 4: действующая модель, три проверки ═══
1. режим демона: rootful
2. uid_map в container: 0 0 4294967295
→ отображения нет
3. файл от root: владелец 0:0
→ UID 0 в container = настоящий root
✓ три проверки согласованы: модель — rootful
═══ Пункт 5: сравнение подходов ═══
подход побег даёт сокет даёт слож. совм.
────────────────────────────────────────────────────────────────────────
USER в образе UID приложения root на host 1 5
userns-remap смещённый UID root на host 3 3
rootless смещённый UID права пользователя 4 3
Вывод: только rootless меняет последствия доступа к сокету.
USER применяется всегда и не конфликтует с остальными.
✓ таблица сравнения составлена
═══ Пункт 6: решение ═══
# Решение по модели привилегий
Дата: 2026-07-31. Действующий режим демона на момент решения: rootful.
## Применяем
**USER 10001:10001 в образе — безусловно.**
...
✓ решение записано с обоснованием и условиями пересмотра
═══ ИТОГ ═══
все требования выполнены
Все требования выполнены.
Обратите внимание на пункт 1: chroot и чтение /etc/shadow разрешены. Первое — потому что SYS_CHROOT входит в набор по умолчанию; второе — потому что процесс работает от настоящего UID 0, а файл принадлежит root. Ни то ни другое не является побегом, но показывает, что «root в container» — это всё-таки root.
Три решения, определяющие качество.
Пункт 2 сравнивает не только capabilities, но и владельца созданного файла. По количеству привилегий варианты неотличимы — оба дают ноль. Различие проявляется только на побочном эффекте: --cap-drop=ALL оставляет UID 0, и файлы на host принадлежат root. Это и есть практический довод за USER, который не виден при сравнении одних лишь capabilities.
Привилегированные операции проверяются через libc, а не через утилиты. Команда mount могла бы отсутствовать в образе, и отказ означал бы «нет программы», а не «нет привилегии». Прямой системный вызов даёт errno — однозначный ответ о причине.
Пункт 4 требует согласованности трёх проверок. Каждая по отдельности может ввести в заблуждение: docker info показывает настройку, а не факт; uid_map можно прочитать в container'е с --userns=host; владелец файла зависит от смонтированного каталога. Совпадение всех трёх — надёжный вывод.
Чего решение не делает. userns-remap не включается: это меняет поведение всех container'ов на узле и требует перезапуска демона, что недопустимо делать автоматически в учебном скрипте. Проверка выполняется на текущем режиме, а инструкция по переходу приведена отдельно. Не измеряется и цена rootless-режима: ограничения сети и производительности зависят от сценария и требуют собственных измерений.
Проверка результата
docker run --rm alpine:3.21 sh -c '
echo "uid: $(id -u)"
echo "uid_map: $(cat /proc/self/uid_map)"
echo "capabilities: $(grep CapEff /proc/self/status | awk "{print \$2}")"
'
Строка 0 0 4294967295 в uid_map означает, что user namespace не изолирован: UID 0 в container — это root на host.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
| Считают root в container безобидным | «Он же изолирован» | UID 0 = UID 0 на host без userns |
--cap-drop=ALL вместо USER | Capabilities обнуляются | Файлы на host всё равно от root |
Включают userns-remap без миграции volumes | Не знали о последствии | Данные становятся недоступны |
Ожидают, что userns-remap закроет вектор сокета | Логично предположить | Демон по-прежнему root |
| Считают rootless полной заменой hardening | Название обнадёживает | USER применяют всё равно |
--userns=host для удобства | Что-то не заработало | Возвращает прежние риски |
| Не проверяют действующую модель | Считают известной | Три проверки занимают минуту |
Правят /etc/subuid вручную без нужды | Кажется настройкой | Диапазоны выделяются автоматически |
Контрольные вопросы
На понимание:
- Что означает строка
0 0 4294967295вuid_map? - Что может и чего не может root в container при настройках по умолчанию?
- Чем
--user 10001отличается от--cap-drop=ALLот root? - Как работает отображение UID в user namespace?
- Почему
userns-remapне закрывает вектор доступа к сокету?
На применение:
- Как определить действующую модель привилегий на системе?
- Что нужно сделать с существующими volumes при включении
userns-remap? - Какой подход выбрать для многопользовательского узла?
На диагностику:
- После включения
userns-remapприложение не может писать в volume. Причина? - Файлы, созданные container'ом, принадлежат UID 100000. Что это означает?
Краткое резюме
- По умолчанию user namespace не изолирован: UID 0 в container — это root на host.
- Полномочия root в container усечены capabilities, seccomp и namespaces.
- Он может писать файлы, устанавливать пакеты, менять владельца в смонтированных каталогах.
- Не может монтировать, менять время и загружать модули — нет соответствующих capabilities.
- Root в container плох тремя вещами: проще эксплуатация ядра, файлы от root, цена ошибки.
USERв образе — обязательный минимум независимо от прочих мер.- User namespace отображает диапазон UID container'а в другой диапазон host.
userns-remapвключает отображение для всех container'ов узла.- Главная сложность внедрения — существующие volumes принадлежат прежним UID.
- Rootless запускает сам демон без привилегий и снимает вектор доступа к сокету.
- Три подхода не взаимоисключающие:
USERвсегда, остальное — по модели угроз. - Действующую модель определяют тремя проверками: режим демона,
uid_map, владелец файла.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: userns-remap | https://docs.docker.com/engine/security/userns-remap/ | Настройка, ограничения, --userns=host |
| Docker: rootless mode | https://docs.docker.com/engine/security/rootless/ | Модель, требования, ограничения |
| Docker: security | https://docs.docker.com/engine/security/ | Роль user namespace в модели безопасности |
Linux: user_namespaces(7) | https://man7.org/linux/man-pages/man7/user_namespaces.7.html | Механизм отображения, capabilities |
Linux: subuid(5) | https://man7.org/linux/man-pages/man5/subuid.5.html | Формат файлов диапазонов |
Linux: capabilities(7) | https://man7.org/linux/man-pages/man7/capabilities.7.html | Полный список, наборы |
| Dockerfile: USER | https://docs.docker.com/reference/dockerfile/#user | Задание пользователя в образе |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Capabilities и privileged
Главное оглавление