Главная/Production-ready containers/Урок

11.2. Runtime hardening

Цели

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

  • определить, какие capabilities нужны приложению, и отобрать остальные;
  • объяснить, что делает no-new-privileges и от чего защищает;
  • запустить приложение с read-only корнем, определив нужные tmpfs измерением;
  • понимать, что даёт профиль seccomp по умолчанию и почему его не отключают;
  • проверить каждую меру командой, а не предположением;
  • оценить, какие меры избыточны для конкретного приложения.

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

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

ТерминОбъяснение
capabilityОтдельная привилегия, выделенная из полномочий root
no-new-privilegesЗапрет повышения привилегий через setuid-программы
seccompФильтр системных вызовов
AppArmorМандатный контроль доступа на уровне путей
bounding setПредельный набор capabilities, который процесс может получить
defence in depthНесколько независимых рубежей защиты

Теория

Что меняется без hardening

По умолчанию container получает:

ЧтоЗначение по умолчанию
Пользовательroot (UID 0), если не задан USER
Capabilities14 из 41 (урок 2.5)
Корневая файловая системаДоступна на запись
Повышение привилегийРазрешено
Профиль seccompdefault — блокирует около 44 вызовов
Профиль AppArmordocker-default, если AppArmor включён
Число процессовНе ограничено

Два последних пункта включены по умолчанию и работают без вашего участия. Остальные требуют явных действий.

Non-root: два способа и их различие

dockerfile
USER 10001:10001
bash
docker run --user 10001:10001 ...
USER в образе--user при запуске
Действует по умолчаниюДаТолько при явном флаге
Можно переопределитьДа, флагом
Пользователь есть в /etc/passwdОбычно даЧасто нет
HOME корректенДаТребует -e HOME=
Забыть невозможноДаЛегко забыть

Задавайте USER в образе. Флаг --user — дополнительный рубеж и способ переопределить UID для разработки (урок 7.5), но не замена.

Числовой UID предпочтительнее имени: платформы вроде Kubernetes умеют проверять runAsNonRoot только по числу.

Capabilities: отобрать всё, вернуть нужное

bash
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE ...

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

Что может понадобиться Python-приложению:

CapabilityКогда нужнаКак обойтись без неё
NET_BIND_SERVICEПорт ниже 1024Слушать 8000, публиковать 80
CHOWNСмена владельца файловЗадать владельца при сборке
SETUID, SETGIDСброс привилегий в entrypointUSER в образе
DAC_OVERRIDEОбход прав доступаИсправить права
KILLОтправка сигналов чужим процессамОбычно не нужна

Типичному веб-приложению на Python не нужна ни одна. Оно слушает порт выше 1024, работает от фиксированного UID и обращается только к своим файлам.

Проверка простая: запустить с --cap-drop=ALL и посмотреть, работает ли.

no-new-privileges

bash
docker run --security-opt=no-new-privileges ...

Флаг устанавливает бит ядра PR_SET_NO_NEW_PRIVS. После этого ни один запущенный процесс не может получить больше привилегий, чем имел родитель, — включая через setuid-программы.

От чего защищает:

text
атакующий получил выполнение кода от appuser (UID 10001)
   │
   ├── без флага: находит setuid-бинарник (например, /usr/bin/passwd)
   │              и пытается через него повысить привилегии
   │
   └── с флагом:  setuid-бит игнорируется, повышение невозможно

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

Флаг стоит включать всегда, если приложение работает без него.

Read-only корень

bash
docker run --read-only --tmpfs /tmp:size=32m,mode=1777 ...

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

Список каталогов, которым нужна запись, измеряют, а не угадывают (урок 7.4):

bash
docker run -d --name probe образ        # без --read-only
# ... нагрузка ...
docker diff probe                        # список записанных путей

Для Python обычно нужны /tmp и, реже, ~/.cache. Переменная PYTHONDONTWRITEBYTECODE=1 убирает __pycache__ без монтирования.

Профиль seccomp

Docker применяет профиль default автоматически. Он блокирует около 44 системных вызовов из более чем 300 — те, что не нужны обычным приложениям и открывают путь к эскалации.

ДействиеОценка
Оставить умолчаниеПравильно в подавляющем большинстве случаев
Свой профильОправдано, если нужно ужесточить
seccomp=unconfinedПочти всегда ошибка

Третья строка встречается в инструкциях как «решение» проблемы, где настоящая причина в другом. Отключение фильтра открывает вызовы вроде kexec_load и bpf.

Если приложение падает на системном вызове, правильный путь — понять, какой вызов и зачем, а не снимать фильтр целиком.

--pids-limit

bash
docker run --pids-limit 100 ...

Ограничивает число задач ядра в cgroup — процессов и потоков (урок 6.13).

Защищает от fork-бомбы: ошибка в коде или атака, порождающая процессы в цикле, не исчерпает таблицу процессов узла.

Значение подбирают с запасом:

text
pids_limit ≈ (число worker'ов + 2) × (потоков на worker + 2)

Слишком низкое значение даёт BlockingIOError: [Errno 11] Resource temporarily unavailable — сообщение, никак не указывающее на настоящую причину.

Что ещё бывает

МераЧто даётКогда применять
--security-opt apparmor=профильОграничение доступа к путямЕсть готовый профиль
userns-remapUID container'а смещены на hostМногопользовательский узел
--device-cgroup-ruleОграничение доступа к устройствамНужны устройства
--ipc=noneИзоляция IPCПриложение не использует IPC
Rootless DockerДемон без root (урок 1.6)Разработка, узлы без привилегий

Чего hardening не делает

Не защищает отПочему
Уязвимости в самом приложенииSQL-инъекция работает от любого UID
Утечки данных через APIПриложение имеет доступ к базе по определению
Уязвимости в зависимостяхТребует сканирования (урок 11.6)
Компрометации образа при сборкеТребует контроля цепочки поставки
Ошибок конфигурации сетиОтдельная тема (раздел 12)

Hardening ограничивает последствия проникновения, а не предотвращает его. Это рубеж обороны, а не забор.


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

Что происходит при --cap-drop=ALL

Ядро хранит для процесса несколько наборов capabilities: permitted, effective, inheritable, bounding, ambient. Docker очищает их, оставляя только запрошенные через --cap-add.

Проверить изнутри можно через /proc/self/status:

text
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 0000000000000000

Все нули означают, что даже процесс с UID 0 не сможет выполнить привилегированную операцию: capabilities отсутствуют.

Это важная деталь: --cap-drop=ALL ограничивает и root'а. Запуск от root с пустым набором capabilities безопаснее, чем кажется, — но не заменяет USER.

Как no-new-privileges виден процессу

bash
grep NoNewPrivs /proc/self/status

Значение 1 означает установленный бит. Он наследуется всеми потомками и не может быть снят — только установлен.


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

Что даёт container по умолчанию

bash
echo "═══ пользователь и capabilities по умолчанию ═══"
docker run --rm python:3.13-slim sh -c '
    echo "  UID: $(id -u)"
    echo "  CapEff: $(grep CapEff /proc/self/status | awk "{print \$2}")"
    echo "  NoNewPrivs: $(grep NoNewPrivs /proc/self/status | awk "{print \$2}")"
'

echo "═══ расшифровка набора ═══"
docker run --rm python:3.13-slim sh -c '
    caps=$(grep CapEff /proc/self/status | awk "{print \$2}")
    python3 -c "
import sys
KNOWN = {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\"}
mask = int(\"$caps\", 16)
have = [n for b, n in KNOWN.items() if mask & (1 << b)]
print(f\"  всего: {len(have)}\")
print(\"  \" + \", \".join(have))
"
'

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

text
═══ пользователь и capabilities по умолчанию ═══
  UID: 0
  CapEff: 00000000a80425fb
  NoNewPrivs: 0
═══ расшифровка набора ═══
  всего: 14
  CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL, SETGID, SETUID, SETPCAP, NET_BIND_SERVICE, NET_RAW, SYS_CHROOT, MKNOD, AUDIT_WRITE, SETFCAP
</text>

Четырнадцать привилегий и работа от root — то, что получает приложение, если не сделать ничего.

Из этого списка типичному веб-приложению не нужна ни одна.

Отбираем всё

bash
echo "═══ с --cap-drop=ALL ═══"
docker run --rm --cap-drop=ALL python:3.13-slim sh -c '
    echo "  UID: $(id -u)"
    echo "  CapEff: $(grep CapEff /proc/self/status | awk "{print \$2}")"
    echo "  CapBnd: $(grep CapBnd /proc/self/status | awk "{print \$2}")"
'

echo "═══ что перестало работать ═══"
docker run --rm --cap-drop=ALL python:3.13-slim sh -c '
    chown nobody /tmp 2>&1 | head -1 | sed "s/^/  chown: /"
    python3 -c "
import socket
s = socket.socket()
try:
    s.bind((\"0.0.0.0\", 80)); print(\"  порт 80: занят\")
except PermissionError:
    print(\"  порт 80: PermissionError — нужен NET_BIND_SERVICE\")
finally:
    s.close()
"
'

echo "═══ а что работает ═══"
docker run --rm --cap-drop=ALL python:3.13-slim python3 -c "
import socket
s = socket.socket()
s.bind(('0.0.0.0', 8000))
print('  порт 8000: занят без единой capability')
s.close()
"

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

text
═══ с --cap-drop=ALL ═══
  UID: 0
  CapEff: 0000000000000000
  CapBnd: 0000000000000000
═══ что перестало работать ═══
  chown: chown: changing ownership of '/tmp': Operation not permitted
  порт 80: PermissionError — нужен NET_BIND_SERVICE
═══ а что работает ═══
  порт 8000: занят без единой capability

Нули в CapEff и CapBnd — при UID 0. Процесс формально root, но ничего привилегированного сделать не может.

Последний блок — ключевой практический вывод: порт выше 1024 доступен без capabilities вовсе. Отсюда правило: слушайте 8000 и публикуйте наружу 80 через -p 80:8000 (урок 8.3).

no-new-privileges в действии

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

cat > Dockerfile.setuid <<'EOF'
FROM python:3.13-slim
# Демонстрация: setuid-программа, печатающая эффективный UID
RUN printf '#include <stdio.h>\n#include <unistd.h>\nint main(){printf("euid=%%d\\n", geteuid()); return 0;}\n' > /tmp/whoami.c \
    && apt-get update && apt-get install -y --no-install-recommends gcc > /dev/null \
    && gcc -o /usr/local/bin/show-euid /tmp/whoami.c \
    && chown root:root /usr/local/bin/show-euid \
    && chmod u+s /usr/local/bin/show-euid \
    && apt-get purge -y gcc > /dev/null && apt-get autoremove -y > /dev/null \
    && rm -rf /var/lib/apt/lists/* /tmp/whoami.c
RUN useradd --create-home --uid 10001 appuser
USER 10001:10001
CMD ["show-euid"]
EOF

docker build -q -f Dockerfile.setuid -t hard:setuid . > /dev/null

echo "═══ без no-new-privileges ═══"
docker run --rm hard:setuid 2>&1 | sed 's/^/  /'

echo "═══ с no-new-privileges ═══"
docker run --rm --security-opt=no-new-privileges hard:setuid 2>&1 | sed 's/^/  /'

echo "═══ флаг виден процессу ═══"
docker run --rm --security-opt=no-new-privileges python:3.13-slim \
    sh -c 'grep NoNewPrivs /proc/self/status' | sed 's/^/  /'

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

text
═══ без no-new-privileges ═══
  euid=0
═══ с no-new-privileges ═══
  euid=10001
═══ флаг виден процессу ═══
  NoNewPrivs:	1

Первый блок — суть проблемы. Приложение работает от UID 10001, но setuid-программа подняла эффективный UID до 0. Атакующий, получивший выполнение кода, получил бы root в container'е.

Второй блок: с флагом setuid-бит проигнорирован, эффективный UID остался прежним.

Флаг стоит один аргумент и закрывает целый класс атак.

Read-only корень: измеряем, что нужно

bash
cd /tmp/hard
mkdir -p app
cat > app/main.py <<'PY'
"""Приложение, пишущее в разные места — как это обычно и бывает."""
import os
import tempfile
from pathlib import Path

with tempfile.NamedTemporaryFile("w", delete=False, suffix=".tmp") as f:
    f.write("временные данные\n")
    print(f"tempfile: {f.name}", flush=True)

cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "app"
cache.mkdir(parents=True, exist_ok=True)
(cache / "state").write_text("ok\n")
print(f"кэш: {cache}", flush=True)

print("готово", flush=True)
PY

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 HOME=/home/appuser
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
COPY --chown=10001:10001 app/ ./app/
USER 10001:10001
CMD ["python", "app/main.py"]
EOF

docker build -q -t hard:app . > /dev/null

echo "═══ шаг 1: обычный запуск ═══"
docker run --name measure hard:app 2>&1 | sed 's/^/  /'

echo "═══ шаг 2: что было записано ═══"
docker diff measure | grep '^A' | sed 's/^A /  /' | head -10
docker rm measure > /dev/null

echo "═══ шаг 3: --read-only без подготовки ═══"
docker run --rm --read-only hard:app 2>&1 | tail -2 | sed 's/^/  /'

echo "═══ шаг 4: --read-only с нужными tmpfs ═══"
docker run --rm --read-only \
    --tmpfs /tmp:size=16m,mode=1777 \
    --tmpfs /home/appuser/.cache:size=16m,uid=10001,gid=10001 \
    hard:app 2>&1 | sed 's/^/  /'

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

text
═══ шаг 1: обычный запуск ═══
  tempfile: /tmp/tmpk3x9v2p1.tmp
  кэш: /home/appuser/.cache/app
  готово
═══ шаг 2: что было записано ═══
  /home/appuser/.cache
  /home/appuser/.cache/app
  /home/appuser/.cache/app/state
  /tmp/tmpk3x9v2p1.tmp
═══ шаг 3: --read-only без подготовки ═══
  OSError: [Errno 30] Read-only file system: '/tmp/tmpXXXXXXX'
═══ шаг 4: --read-only с нужными tmpfs ═══
  tempfile: /tmp/tmpq8w1e5r3.tmp
  кэш: /home/appuser/.cache/app
  готово

Шаг 2 дал готовый список — гадать не пришлось. Обратите внимание на uid=10001 в опциях второго tmpfs: без него точка монтирования принадлежит root, и приложение туда не запишет (урок 7.4).

Каталога __pycache__ в списке нет — его убрала переменная PYTHONDONTWRITEBYTECODE=1.

Проверка, что запись действительно закрыта

bash
cd /tmp/hard
docker run -d --name ro-test --read-only \
    --tmpfs /tmp:size=16m,mode=1777 \
    --cap-drop=ALL --security-opt=no-new-privileges \
    hard:app sleep 60 > /dev/null 2>&1 || \
docker run -d --name ro-test --read-only \
    --tmpfs /tmp:size=16m,mode=1777 \
    --cap-drop=ALL --security-opt=no-new-privileges \
    --entrypoint sleep hard:app 60 > /dev/null
sleep 2

echo "═══ попытки записи ═══"
for path in / /app /usr/local/bin /etc; do
    result="$(docker exec ro-test sh -c "echo x > $path/probe 2>&1" | tail -1)"
    printf '  %-16s %s\n' "$path" "${result:-запись прошла!}"
done
printf '  %-16s %s\n' "/tmp" "$(docker exec ro-test sh -c 'echo x > /tmp/probe && echo "запись разрешена"')"

echo "═══ и вся конфигурация целиком ═══"
docker inspect ro-test --format '  ReadonlyRootfs: {{.HostConfig.ReadonlyRootfs}}
  CapDrop:        {{json .HostConfig.CapDrop}}
  SecurityOpt:    {{json .HostConfig.SecurityOpt}}
  User:           {{if .Config.User}}{{.Config.User}}{{else}}(не задан){{end}}'

docker rm -f ro-test > /dev/null

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

text
═══ попытки записи ═══
  /                sh: 1: cannot create /probe: Read-only file system
  /app             sh: 1: cannot create /app/probe: Read-only file system
  /usr/local/bin   sh: 1: cannot create /usr/local/bin/probe: Read-only file system
  /etc             sh: 1: cannot create /etc/probe: Read-only file system
  /tmp             запись разрешена
═══ и вся конфигурация целиком ═══
  ReadonlyRootfs: true
  CapDrop:        ["ALL"]
  SecurityOpt:    ["no-new-privileges"]
  User:           10001:10001

Четыре каталога закрыты, один открыт — ровно тот, который нужен. /usr/local/bin особенно важен: именно туда атакующий положил бы исполняемый файл.

--pids-limit против fork-бомбы

bash
cd /tmp/hard
cat > forkbomb.py <<'PY'
"""Имитация неконтролируемого порождения процессов."""
import os
import sys
import time

spawned = 0
try:
    while spawned < 500:
        pid = os.fork()
        if pid == 0:
            time.sleep(30)
            os._exit(0)
        spawned += 1
except BlockingIOError as exc:
    print(f"остановлено на {spawned} процессах: {exc}", flush=True)
    sys.exit(0)
except OSError as exc:
    print(f"остановлено на {spawned} процессах: {type(exc).__name__} {exc}", flush=True)
    sys.exit(0)
print(f"создано {spawned} процессов без ограничений", flush=True)
PY

echo "═══ без --pids-limit ═══"
timeout 30 docker run --rm --memory 256m \
    -v "$PWD/forkbomb.py:/f.py:ro" python:3.13-slim python -u /f.py 2>&1 | tail -1 | sed 's/^/  /'

echo "═══ с --pids-limit 50 ═══"
timeout 30 docker run --rm --memory 256m --pids-limit 50 \
    -v "$PWD/forkbomb.py:/f.py:ro" python:3.13-slim python -u /f.py 2>&1 | tail -1 | sed 's/^/  /'

echo "═══ что видно в cgroup ═══"
docker run --rm --pids-limit 50 python:3.13-slim \
    sh -c 'echo "  pids.max: $(cat /sys/fs/cgroup/pids.max)"; echo "  pids.current: $(cat /sys/fs/cgroup/pids.current)"'

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

text
═══ без --pids-limit ═══
  создано 500 процессов без ограничений
═══ с --pids-limit 50 ═══
  остановлено на 47 процессах: [Errno 11] Resource temporarily unavailable
═══ что видно в cgroup ═══
  pids.max: 50
  pids.current: 2

Без лимита процесс создал 500 потомков и остановился только потому, что так написано в коде. Реальная fork-бомба продолжала бы до исчерпания таблицы процессов узла — затронув все container'ы и сам host.

С лимитом ядро отказало на 47-м. Сообщение Resource temporarily unavailable (EAGAIN) — то самое, которое не указывает на причину; знать про pids.max приходится заранее.

Профиль seccomp

bash
echo "═══ профиль по умолчанию ═══"
docker run --rm python:3.13-slim sh -c '
    grep Seccomp /proc/self/status | sed "s/^/  /"
'

echo "═══ заблокированный вызов ═══"
docker run --rm python:3.13-slim python3 -c "
import ctypes, ctypes.util
libc = ctypes.CDLL(ctypes.util.find_library('c'), use_errno=True)
# unshare(CLONE_NEWUSER) — есть в профиле по умолчанию, но capability нет
res = libc.unshare(0x10000000)
import os
print('  unshare(CLONE_NEWUSER):', 'разрешён' if res == 0 else f'errno={ctypes.get_errno()}')
" 2>&1 | tail -1

echo "═══ с seccomp=unconfined (ТАК НЕ ДЕЛАЙТЕ) ═══"
docker run --rm --security-opt seccomp=unconfined python:3.13-slim sh -c '
    grep Seccomp /proc/self/status | sed "s/^/  /"
'

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

text
═══ профиль по умолчанию ═══
  Seccomp:	2
  Seccomp_filters:	1
═══ заблокированный вызов ═══
  unshare(CLONE_NEWUSER): errno=1
═══ с seccomp=unconfined (ТАК НЕ ДЕЛАЙТЕ) ═══
  Seccomp:	0
  Seccomp_filters:	0

Seccomp: 2 означает режим фильтрации; 0 — фильтра нет.

Третий блок показывает, что делает seccomp=unconfined: снимает защиту целиком. Флаг встречается в инструкциях как обход проблемы, настоящая причина которой в другом — например, в отсутствии нужной capability.

Всё вместе и цена вопроса

bash
cd /tmp/hard
cat > compose.yaml <<'EOF'
name: hardened

services:
  # Без hardening — как получается по умолчанию
  plain:
    image: python:3.13-slim
    command: ["sleep", "300"]

  # С hardening
  hardened:
    image: python:3.13-slim
    command: ["sleep", "300"]
    user: "10001:10001"
    read_only: true
    tmpfs:
      - /tmp:size=16m,mode=1777
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    pids_limit: 100
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: "1.0"
EOF

docker compose up -d > /dev/null 2>&1
sleep 3

printf '%-16s %-8s %-18s %-12s %-10s %s\n' "СЕРВИС" "UID" "CapEff" "NoNewPrivs" "read-only" "pids.max"
for svc in plain hardened; do
    cid="$(docker compose ps -q $svc)"
    printf '%-16s %-8s %-18s %-12s %-10s %s\n' \
        "$svc" \
        "$(docker exec "$cid" id -u)" \
        "$(docker exec "$cid" sh -c 'grep CapEff /proc/self/status | awk "{print \$2}"')" \
        "$(docker exec "$cid" sh -c 'grep NoNewPrivs /proc/self/status | awk "{print \$2}"')" \
        "$(docker inspect "$cid" --format '{{.HostConfig.ReadonlyRootfs}}')" \
        "$(docker exec "$cid" cat /sys/fs/cgroup/pids.max)"
done

echo
echo "═══ что может сделать атакующий, получив выполнение кода ═══"
for svc in plain hardened; do
    cid="$(docker compose ps -q $svc)"
    printf '  %s:\n' "$svc"
    printf '    записать в /usr/local/bin: %s\n' \
        "$(docker exec "$cid" sh -c 'echo x > /usr/local/bin/evil 2>&1 && echo "ДА" || echo "нет"' | tail -1)"
    printf '    сменить владельца файла:   %s\n' \
        "$(docker exec "$cid" sh -c 'chown 0:0 /tmp 2>&1 >/dev/null && echo "ДА" || echo "нет"' | tail -1)"
    printf '    открыть привилегированный порт: %s\n' \
        "$(docker exec "$cid" python3 -c "
import socket
s = socket.socket()
try:
    s.bind(('0.0.0.0', 80)); print('ДА')
except PermissionError:
    print('нет')
finally:
    s.close()
" 2>/dev/null)"
done

docker compose down > /dev/null 2>&1
docker rmi -f hard:setuid hard:app > /dev/null 2>&1
cd /tmp && rm -rf /tmp/hard

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

text
СЕРВИС           UID      CapEff             NoNewPrivs   read-only  pids.max
plain            0        00000000a80425fb   0            false      max
hardened         10001    0000000000000000   1            true       100

═══ что может сделать атакующий, получив выполнение кода ═══
  plain:
    записать в /usr/local/bin: ДА
    сменить владельца файла:   ДА
    открыть привилегированный порт: ДА
  hardened:
    записать в /usr/local/bin: нет
    сменить владельца файла:   нет
    открыть привилегированный порт: нет

Таблица — итог урока. Слева состояние по умолчанию, справа — после шести строк конфигурации.

Второй блок переводит настройки в практическую плоскость: три операции, полезные атакующему, стали недоступны.

Цена: шесть строк в compose.yaml и одна проверка того, что приложение работает с этими ограничениями.


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

Задание. Проведите hardening приложения и докажите каждую меру измерением.

Требования:

  1. Приложение работает от non-root; UID задан в образе, а не флагом.
  2. --cap-drop=ALL без единого --cap-add; показано, что приложение работает.
  3. no-new-privileges включён; показано, что setuid-программа не повышает привилегии.
  4. Корень доступен только на чтение; список tmpfs получен измерением, а не подбором.
  5. --pids-limit задан; значение обосновано числом worker'ов и потоков.
  6. Профиль seccomp не отключён — проверено.
  7. Приложение продолжает работать: HTTP отвечает, остановка даёт код 0.

Скрипт сравнивает hardened и обычный вариант и возвращает ненулевой код при провале.

Подсказки

Подсказка 1

Для пункта 3 нужна setuid-программа в образе — иначе демонстрировать нечего.

Подсказка 2

Пункт 4 требует сначала запустить без --read-only и посмотреть docker diff.

Подсказка 3

Пункт 6 проверяется полем Seccomp в /proc/self/status.

Решение

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

cat > app/__init__.py <<'PY'
"""Приложение для практикума по hardening."""
PY

cat > app/main.py <<'PY'
"""Сервис, сообщающий о своём состоянии безопасности."""
from __future__ import annotations

import json
import os
import signal
import sys
import tempfile
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from threading import Thread

_server: HTTPServer | None = None


def read_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 security_report() -> dict[str, object]:
    return {
        "uid": os.getuid(),
        "gid": os.getgid(),
        "cap_eff": read_status("CapEff"),
        "cap_bnd": read_status("CapBnd"),
        "no_new_privs": read_status("NoNewPrivs"),
        "seccomp": read_status("Seccomp"),
        "pids_max": Path("/sys/fs/cgroup/pids.max").read_text().strip()
        if Path("/sys/fs/cgroup/pids.max").exists() else "?",
    }


class Handler(BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        if self.path == "/healthz":
            payload: dict[str, object] = {"status": "ok"}
        elif self.path == "/security":
            payload = security_report()
        elif self.path == "/write-test":
            # Проверяет, куда приложение может писать
            results = {}
            for target in ("/probe", "/app/probe", "/usr/local/bin/probe", "/tmp/probe"):
                try:
                    Path(target).write_text("x")
                    Path(target).unlink()
                    results[target] = "запись разрешена"
                except OSError as exc:
                    results[target] = exc.strerror or type(exc).__name__
            payload = results
        else:
            # Штатная работа: временный файл
            with tempfile.NamedTemporaryFile("w", delete=True) as f:
                f.write("работа\n")
                f.flush()
                size = os.path.getsize(f.name)
            payload = {"service": "hardened", "pid": os.getpid(), "tmp_bytes": size}

        body = json.dumps(payload, ensure_ascii=False).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args: object) -> None:
        pass


def shutdown(signum: int, _frame: object) -> None:
    print(f"получен {signal.Signals(signum).name}, завершаюсь", flush=True)
    if _server is not None:
        Thread(target=_server.shutdown, daemon=True).start()


def main() -> int:
    global _server
    signal.signal(signal.SIGTERM, shutdown)
    signal.signal(signal.SIGINT, shutdown)
    # Порт выше 1024: NET_BIND_SERVICE не требуется
    _server = HTTPServer(("0.0.0.0", 8000), Handler)
    print(f"запущен на 0.0.0.0:8000, uid={os.getuid()}", flush=True)
    _server.serve_forever()
    print("остановлен штатно", flush=True)
    return 0


if __name__ == "__main__":
    sys.exit(main())
PY

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1

FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    HOME=/home/appuser
WORKDIR /app

# Стадия для демонстрации: setuid-программа (в реальном образе её быть не должно)
FROM base AS withsuid
RUN apt-get update && apt-get install -y --no-install-recommends gcc > /dev/null 2>&1 \
    && printf '#include <stdio.h>\n#include <unistd.h>\nint main(){printf("euid=%%d\\n", geteuid()); return 0;}\n' > /tmp/e.c \
    && gcc -o /usr/local/bin/show-euid /tmp/e.c \
    && chmod u+s /usr/local/bin/show-euid \
    && apt-get purge -y gcc > /dev/null 2>&1 \
    && apt-get autoremove -y > /dev/null 2>&1 \
    && rm -rf /var/lib/apt/lists/* /tmp/e.c
# Пункт 1: UID задан в ОБРАЗЕ, не флагом
RUN useradd --create-home --uid 10001 appuser
COPY --chown=10001:10001 app/ ./app/
USER 10001:10001
EXPOSE 8000
CMD ["python", "-m", "app.main"]
EOF

cat > compose.yaml <<'EOF'
name: hardfull

x-image: &image
  build:
    context: .
    target: withsuid
  image: hardfull:app

services:
  # Контрольный вариант: без hardening
  plain:
    <<: *image
    ports:
      - "127.0.0.1:8501:8000"

  # Пункты 1–6
  hardened:
    <<: *image
    ports:
      - "127.0.0.1:8502:8000"
    # user НЕ задан: UID приходит из образа (пункт 1)
    read_only: true                      # пункт 4
    tmpfs:
      - /tmp:size=16m,mode=1777          # получено измерением
    cap_drop: [ALL]                      # пункт 2
    security_opt:
      - no-new-privileges:true           # пункт 3
      # seccomp НЕ отключён (пункт 6)
    pids_limit: 32                       # пункт 5
    stop_grace_period: 10s
    deploy:
      resources:
        limits:
          memory: 256M
          cpus: "1.0"
EOF

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }
sec()  { curl -s -m 5 "http://127.0.0.1:$1/security" 2>/dev/null \
         | python3 -c "import json,sys; print(json.load(sys.stdin)['$2'])" 2>/dev/null; }

printf '\n═══ Пункт 4 (подготовка): измеряем, что пишется ═══\n'
docker compose build -q > /dev/null 2>&1
docker run --name measure -d hardfull:app > /dev/null 2>&1
sleep 3
docker exec measure sh -c 'python3 -c "
import tempfile
with tempfile.NamedTemporaryFile(\"w\", delete=False) as f:
    f.write(\"x\")
"' 2>/dev/null
written="$(docker diff measure | grep '^A' | sed 's/^A //' | tr '\n' ' ')"
printf '    записанные пути: %s\n' "${written:-(нет)}"
docker rm -f measure > /dev/null 2>&1
echo "$written" | grep -q '/tmp' && ok "нужен только /tmp — tmpfs подобран измерением" \
    || ok "список путей получен: ${written:-пусто}"

printf '\n═══ Запуск обоих вариантов ═══\n'
docker compose up -d > /dev/null 2>&1
for _ in $(seq 40); do
    [ "$(curl -s -m 2 -o /dev/null -w '%{http_code}' http://127.0.0.1:8502/healthz 2>/dev/null)" = "200" ] && break
    sleep 1
done
printf '    plain:    HTTP %s\n' "$(curl -s -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8501/healthz)"
printf '    hardened: HTTP %s\n' "$(curl -s -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8502/healthz)"

printf '\n═══ Сравнение ═══\n'
printf '    %-14s %-8s %-18s %-12s %-9s %s\n' "" "uid" "cap_eff" "no_new_privs" "seccomp" "pids_max"
for pair in "plain 8501" "hardened 8502"; do
    set -- $pair
    printf '    %-14s %-8s %-18s %-12s %-9s %s\n' "$1" \
        "$(sec "$2" uid)" "$(sec "$2" cap_eff)" "$(sec "$2" no_new_privs)" \
        "$(sec "$2" seccomp)" "$(sec "$2" pids_max)"
done

printf '\n═══ Пункт 1: non-root из образа ═══\n'
uid_h="$(sec 8502 uid)"
user_flag="$(docker inspect "$(docker compose ps -q hardened)" --format '{{.Config.User}}')"
printf '    uid=%s, Config.User из образа=%s\n' "$uid_h" "$user_flag"
[ "$uid_h" = "10001" ] && [ "$user_flag" = "10001:10001" ] \
    && ok "UID задан в образе, не флагом" || bad "uid=$uid_h user=$user_flag"

printf '\n═══ Пункт 2: capabilities отобраны ═══\n'
cap_h="$(sec 8502 cap_eff)"
cap_p="$(sec 8501 cap_eff)"
printf '    hardened=%s  plain=%s\n' "$cap_h" "$cap_p"
[ "$cap_h" = "0000000000000000" ] && ok "ни одной capability" || bad "CapEff=$cap_h"
[ "$cap_p" != "0000000000000000" ] && ok "у plain они есть — сравнение информативно" \
    || bad "у plain тоже пусто"
code="$(curl -s -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:8502/)"
[ "$code" = "200" ] && ok "приложение работает без capabilities" || bad "HTTP $code"

printf '\n═══ Пункт 3: no-new-privileges ═══\n'
euid_plain="$(docker compose exec -T plain show-euid 2>/dev/null | tr -d '\r')"
euid_hard="$(docker compose exec -T hardened show-euid 2>/dev/null | tr -d '\r')"
printf '    setuid-программа в plain:    %s\n' "$euid_plain"
printf '    setuid-программа в hardened: %s\n' "$euid_hard"
[ "$euid_plain" = "euid=0" ] && [ "$euid_hard" = "euid=10001" ] \
    && ok "повышение привилегий заблокировано" || bad "plain=$euid_plain hardened=$euid_hard"
[ "$(sec 8502 no_new_privs)" = "1" ] && ok "бит NoNewPrivs установлен" || bad "бит не установлен"

printf '\n═══ Пункт 4: read-only корень ═══\n'
curl -s -m 5 "http://127.0.0.1:8502/write-test" | python3 -c "
import json, sys
for path, result in json.load(sys.stdin).items():
    print(f'    {path:<22} {result}')
"
writable="$(curl -s -m 5 "http://127.0.0.1:8502/write-test" | python3 -c "
import json, sys
d = json.load(sys.stdin)
bad = [p for p, r in d.items() if r == 'запись разрешена' and p != '/tmp/probe']
print(len(bad))
")"
[ "$writable" = "0" ] && ok "запись возможна только в /tmp" || bad "записываемых путей вне /tmp: $writable"

printf '\n═══ Пункт 5: pids-limit ═══\n'
printf '    pids.max=%s\n' "$(sec 8502 pids_max)"
printf '    обоснование: 1 процесс приложения + запас на exec-сессии\n'
printf '                 формула (worker + 2) * (потоков + 2) = (1+2)*(1+2) = 9, взято 32\n'
[ "$(sec 8502 pids_max)" = "32" ] && ok "лимит применён" || bad "pids.max=$(sec 8502 pids_max)"

printf '\n═══ Пункт 6: seccomp не отключён ═══\n'
printf '    Seccomp: hardened=%s plain=%s (2 = фильтрация)\n' "$(sec 8502 seccomp)" "$(sec 8501 seccomp)"
[ "$(sec 8502 seccomp)" = "2" ] && ok "профиль seccomp активен" || bad "Seccomp=$(sec 8502 seccomp)"
grep -q 'seccomp=unconfined\|seccomp:unconfined' compose.yaml \
    && bad "в конфигурации есть отключение seccomp" || ok "seccomp нигде не отключается"

printf '\n═══ Пункт 7: приложение работоспособно ═══\n'
curl -s -m 5 "http://127.0.0.1:8502/" | python3 -m json.tool --compact | sed 's/^/    /'
cid="$(docker compose ps -q hardened)"
s="$(date +%s.%N)"
docker compose stop hardened > /dev/null 2>&1
e="$(date +%s.%N)"
exit_code="$(docker inspect "$cid" --format '{{.State.ExitCode}}')"
printf '    остановка: %.1f c, код выхода: %s\n' \
    "$(awk -v a="$s" -v b="$e" 'BEGIN{print b-a}')" "$exit_code"
docker compose logs hardened --no-log-prefix 2>/dev/null | tail -2 | sed 's/^/    /'
[ "$exit_code" = "0" ] && ok "остановка штатная, код 0" || bad "код выхода $exit_code"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все семь пунктов подтверждены" || echo "  ЕСТЬ ПРОВАЛЫ"

docker compose down > /dev/null 2>&1
docker rmi -f hardfull:app > /dev/null 2>&1
cd /tmp && rm -rf /tmp/hardfull
exit "$fail"

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

text
═══ Пункт 4 (подготовка): измеряем, что пишется ═══
    записанные пути: /tmp/tmpx9k2p1v3 
  ✓ нужен только /tmp — tmpfs подобран измерением

═══ Запуск обоих вариантов ═══
    plain:    HTTP 200
    hardened: HTTP 200

═══ Сравнение ═══
                   uid      cap_eff            no_new_privs seccomp   pids_max
    plain          10001    0000000000000000   0            2         max
    hardened       10001    0000000000000000   1            2         32

═══ Пункт 1: non-root из образа ═══
    uid=10001, Config.User из образа=10001:10001
  ✓ UID задан в образе, не флагом

═══ Пункт 2: capabilities отобраны ═══
    hardened=0000000000000000  plain=0000000000000000
  ✓ ни одной capability
  ✗ у plain тоже пусто
  ✓ приложение работает без capabilities

═══ Пункт 3: no-new-privileges ═══
    setuid-программа в plain:    euid=0
    setuid-программа в hardened: euid=10001
  ✓ повышение привилегий заблокировано
  ✓ бит NoNewPrivs установлен

═══ Пункт 4: read-only корень ═══
    /probe                 Read-only file system
    /app/probe             Read-only file system
    /usr/local/bin/probe   Read-only file system
    /tmp/probe             запись разрешена
  ✓ запись возможна только в /tmp

═══ Пункт 5: pids-limit ═══
    pids.max=32
    обоснование: 1 процесс приложения + запас на exec-сессии
                 формула (worker + 2) * (потоков + 2) = (1+2)*(1+2) = 9, взято 32
  ✓ лимит применён

═══ Пункт 6: seccomp не отключён ═══
    Seccomp: hardened=2 plain=2 (2 = фильтрация)
  ✓ профиль seccomp активен
  ✓ seccomp нигде не отключается

═══ Пункт 7: приложение работоспособно ═══
    {"service":"hardened","pid":1,"tmp_bytes":7}
    остановка: 0.3 c, код выхода: 0
    получен SIGTERM, завершаюсь
    остановлен штатно
  ✓ остановка штатная, код 0

Одна проверка помечена крестом — и это важнее остальных галочек.

Разбор провалившейся проверки. Строка «у plain тоже пусто» означает, что контрольный вариант показал CapEff=0000000000000000, хотя cap_drop для него не задавался. Причина в том, что оба сервиса используют один образ с USER 10001:10001: при переходе на непривилегированного пользователя ядро очищает effective-набор независимо от --cap-drop.

Вывод: USER в образе уже даёт значительную часть эффекта. Флаг --cap-drop=ALL остаётся нужным — он очищает и bounding set, что видно по полю cap_bnd, — но контрольный вариант для этой проверки построен неверно. Правильный контроль требует образа без USER.

Такой результат полезнее зелёной галочки: он уточняет модель. Исправление — вынести USER из общего образа и задавать пользователя только hardened-варианту.

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

Приложение само сообщает своё состояние безопасности через /security. Альтернатива — docker exec с разбором /proc/self/status снаружи — проверяла бы другой процесс: exec создаёт новый, и его набор capabilities может отличаться от набора PID 1. Endpoint отвечает от имени самого приложения.

Пункт 4 проверяется четырьмя путями, а не одним. /tmp доступен, а /, /app и /usr/local/bin — нет. Последний важнее прочих: именно туда атакующий положил бы исполняемый файл. Проверка только корня оставила бы дыру незамеченной, если бы /usr/local/bin оказался на отдельном монтировании.

Пункт 5 печатает обоснование значения, а не только само значение. Число 32 без объяснения — магическая константа, которую следующий человек побоится менять. Формула и расчёт превращают его в решение, которое можно пересмотреть при переходе на Gunicorn.

Чего решение не делает. Не проверяется AppArmor: он включён не на всех системах, и профиль docker-default применяется автоматически — отдельная настройка нужна редко. Не рассматривается userns-remap: он влияет на весь демон, а не на отдельный container, и его включение меняет поведение всех существующих volumes (урок 7.5). Наконец, hardening не проверяется на реальном сценарии атаки — только на отдельных операциях, которые атака использовала бы.

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

bash
docker run --rm --user 10001:10001 --cap-drop=ALL \
    --security-opt=no-new-privileges --read-only \
    --tmpfs /tmp:size=8m --pids-limit 20 \
    python:3.13-slim sh -c '
        echo "uid: $(id -u)"
        grep -E "CapEff|NoNewPrivs|Seccomp:" /proc/self/status
        cat /sys/fs/cgroup/pids.max
        echo x > /probe 2>&1 | tail -1
    '

Ожидается uid: 10001, CapEff: 0000000000000000, NoNewPrivs: 1, Seccomp: 2, 20 и отказ записи.

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

ОшибкаПричинаИсправление
Убирают capabilities по однойКажется аккуратнееОстанется то, о чём не подумали; --cap-drop=ALL
NET_BIND_SERVICE для порта 80Приложение слушает 80Слушать 8000, публиковать -p 80:8000
seccomp=unconfined как обход проблемыТак посоветовалиСнимает защиту целиком; найти настоящую причину
--privileged для «чтобы заработало»БыстроОтменяет почти всю изоляцию
--user вместо USER в образеКажется гибчеЛегко забыть при запуске
--read-only без измеренияПодбирают наугадСписок даёт docker diff
tmpfs для non-root без uid=Не подумали о владельцеТочка монтирования принадлежит root
Считают hardening защитой от уязвимостейНазвание вводит в заблуждениеОграничивает последствия, не предотвращает
--pids-limit слишком низкийВзяли наугадEAGAIN без указания причины
Проверяют настройки через docker execУдобноexec — другой процесс с другим набором

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

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

  1. Почему --cap-drop=ALL предпочтительнее удаления по одной?
  2. Что делает no-new-privileges и от какой атаки защищает?
  3. Почему приложению, слушающему порт 8000, не нужна ни одна capability?
  4. Что означает Seccomp: 2 в /proc/self/status?
  5. Чем --pids-limit защищает узел, а не только container?

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

  1. Как определить список tmpfs для --read-only?
  2. Как проверить, что capabilities действительно отобраны?
  3. Как обосновать значение --pids-limit?

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

  1. Приложение падает с Resource temporarily unavailable. Первая версия?
  2. После --cap-drop=ALL приложение не стартует. Порядок действий?

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

  1. По умолчанию container работает от root с 14 capabilities и записываемым корнем.
  2. USER задают в образе; --user — дополнительный рубеж, а не замена.
  3. Правильный порядок: --cap-drop=ALL, затем точечный --cap-add.
  4. Типичному Python-приложению не нужна ни одна capability, если порт выше 1024.
  5. --cap-drop=ALL ограничивает и процессы с UID 0.
  6. no-new-privileges блокирует повышение привилегий через setuid и почти ничего не стоит.
  7. Список каталогов для tmpfs при --read-only измеряют через docker diff.
  8. Для non-root в опциях tmpfs задают uid и gid.
  9. Профиль seccomp по умолчанию блокирует около 44 вызовов; отключать его почти всегда ошибка.
  10. --pids-limit защищает узел от исчерпания таблицы процессов; при исчерпании — EAGAIN.
  11. Проверять настройки нужно от имени самого приложения: docker exec создаёт другой процесс.
  12. Hardening ограничивает последствия проникновения, но не предотвращает его.

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

ИсточникСсылкаЧто подтверждает
Docker: securityhttps://docs.docker.com/engine/security/Обзор механизмов изоляции
Docker: seccomphttps://docs.docker.com/engine/security/seccomp/Профиль по умолчанию, список вызовов
Docker: AppArmorhttps://docs.docker.com/engine/security/apparmor/Профиль docker-default
Docker: docker run referencehttps://docs.docker.com/reference/cli/docker/container/run/#security-opt--cap-drop, --security-opt, --read-only
Docker: resource constraintshttps://docs.docker.com/engine/containers/resource_constraints/--pids-limit
Linux: capabilities(7)https://man7.org/linux/man-pages/man7/capabilities.7.htmlНаборы, полный список
Linux: no_new_privshttps://docs.kernel.org/userspace-api/no_new_privs.htmlМеханизм и наследование
Linux: seccomp(2)https://man7.org/linux/man-pages/man2/seccomp.2.htmlРежимы фильтрации
OWASP: Docker Securityhttps://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.htmlСводка практик

Навигация

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

Markdown на GitHub ↗