11.2. Runtime hardening
Цели
После этого материала вы сможете:
- определить, какие capabilities нужны приложению, и отобрать остальные;
- объяснить, что делает
no-new-privilegesи от чего защищает; - запустить приложение с read-only корнем, определив нужные
tmpfsизмерением; - понимать, что даёт профиль seccomp по умолчанию и почему его не отключают;
- проверить каждую меру командой, а не предположением;
- оценить, какие меры избыточны для конкретного приложения.
Предварительные знания
- 2.5. Capabilities;
- 7.4. tmpfs и read-only filesystem;
- 6.7. Non-root и права;
- 11.1. Принципы production.
Ключевые термины
| Термин | Объяснение |
|---|---|
capability | Отдельная привилегия, выделенная из полномочий root |
no-new-privileges | Запрет повышения привилегий через setuid-программы |
seccomp | Фильтр системных вызовов |
AppArmor | Мандатный контроль доступа на уровне путей |
bounding set | Предельный набор capabilities, который процесс может получить |
defence in depth | Несколько независимых рубежей защиты |
Теория
Что меняется без hardening
По умолчанию container получает:
| Что | Значение по умолчанию |
|---|---|
| Пользователь | root (UID 0), если не задан USER |
| Capabilities | 14 из 41 (урок 2.5) |
| Корневая файловая система | Доступна на запись |
| Повышение привилегий | Разрешено |
| Профиль seccomp | default — блокирует около 44 вызовов |
| Профиль AppArmor | docker-default, если AppArmor включён |
| Число процессов | Не ограничено |
Два последних пункта включены по умолчанию и работают без вашего участия. Остальные требуют явных действий.
Non-root: два способа и их различие
USER 10001:10001
docker run --user 10001:10001 ...
USER в образе | --user при запуске | |
|---|---|---|
| Действует по умолчанию | Да | Только при явном флаге |
| Можно переопределить | Да, флагом | — |
Пользователь есть в /etc/passwd | Обычно да | Часто нет |
HOME корректен | Да | Требует -e HOME= |
| Забыть невозможно | Да | Легко забыть |
Задавайте USER в образе. Флаг --user — дополнительный рубеж и способ переопределить UID для разработки (урок 7.5), но не замена.
Числовой UID предпочтительнее имени: платформы вроде Kubernetes умеют проверять runAsNonRoot только по числу.
Capabilities: отобрать всё, вернуть нужное
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE ...
Порядок именно такой: сначала убрать всё, затем вернуть точечно. Обратный подход — убирать по одной — оставляет то, о чём не подумали.
Что может понадобиться Python-приложению:
| Capability | Когда нужна | Как обойтись без неё |
|---|---|---|
NET_BIND_SERVICE | Порт ниже 1024 | Слушать 8000, публиковать 80 |
CHOWN | Смена владельца файлов | Задать владельца при сборке |
SETUID, SETGID | Сброс привилегий в entrypoint | USER в образе |
DAC_OVERRIDE | Обход прав доступа | Исправить права |
KILL | Отправка сигналов чужим процессам | Обычно не нужна |
Типичному веб-приложению на Python не нужна ни одна. Оно слушает порт выше 1024, работает от фиксированного UID и обращается только к своим файлам.
Проверка простая: запустить с --cap-drop=ALL и посмотреть, работает ли.
no-new-privileges
docker run --security-opt=no-new-privileges ...
Флаг устанавливает бит ядра PR_SET_NO_NEW_PRIVS. После этого ни один запущенный процесс не может получить больше привилегий, чем имел родитель, — включая через setuid-программы.
От чего защищает:
атакующий получил выполнение кода от appuser (UID 10001)
│
├── без флага: находит setuid-бинарник (например, /usr/bin/passwd)
│ и пытается через него повысить привилегии
│
└── с флагом: setuid-бит игнорируется, повышение невозможно
Побочный эффект близок к нулю: приложения, которым нужен setuid, — редкость, и в container'е они почти всегда указывают на ошибку проектирования.
Флаг стоит включать всегда, если приложение работает без него.
Read-only корень
docker run --read-only --tmpfs /tmp:size=32m,mode=1777 ...
Что даёт: атакующий, получивший выполнение кода, не может записать файл — ни веб-шелл, ни модифицированный модуль, ни бинарник.
Список каталогов, которым нужна запись, измеряют, а не угадывают (урок 7.4):
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
docker run --pids-limit 100 ...
Ограничивает число задач ядра в cgroup — процессов и потоков (урок 6.13).
Защищает от fork-бомбы: ошибка в коде или атака, порождающая процессы в цикле, не исчерпает таблицу процессов узла.
Значение подбирают с запасом:
pids_limit ≈ (число worker'ов + 2) × (потоков на worker + 2)
Слишком низкое значение даёт BlockingIOError: [Errno 11] Resource temporarily unavailable — сообщение, никак не указывающее на настоящую причину.
Что ещё бывает
| Мера | Что даёт | Когда применять |
|---|---|---|
--security-opt apparmor=профиль | Ограничение доступа к путям | Есть готовый профиль |
userns-remap | UID 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:
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapBnd: 0000000000000000
Все нули означают, что даже процесс с UID 0 не сможет выполнить привилегированную операцию: capabilities отсутствуют.
Это важная деталь: --cap-drop=ALL ограничивает и root'а. Запуск от root с пустым набором capabilities безопаснее, чем кажется, — но не заменяет USER.
Как no-new-privileges виден процессу
grep NoNewPrivs /proc/self/status
Значение 1 означает установленный бит. Он наследуется всеми потомками и не может быть снят — только установлен.
Команды и примеры
Что даёт container по умолчанию
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))
"
'
Ожидаемый вывод:
═══ пользователь и 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 — то, что получает приложение, если не сделать ничего.
Из этого списка типичному веб-приложению не нужна ни одна.
Отбираем всё
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()
"
Ожидаемый вывод:
═══ с --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 в действии
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/^/ /'
Ожидаемый вывод:
═══ без no-new-privileges ═══
euid=0
═══ с no-new-privileges ═══
euid=10001
═══ флаг виден процессу ═══
NoNewPrivs: 1
Первый блок — суть проблемы. Приложение работает от UID 10001, но setuid-программа подняла эффективный UID до 0. Атакующий, получивший выполнение кода, получил бы root в container'е.
Второй блок: с флагом setuid-бит проигнорирован, эффективный UID остался прежним.
Флаг стоит один аргумент и закрывает целый класс атак.
Read-only корень: измеряем, что нужно
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/^/ /'
Ожидаемый вывод:
═══ шаг 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.
Проверка, что запись действительно закрыта
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
Ожидаемый вывод:
═══ попытки записи ═══
/ 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-бомбы
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)"'
Ожидаемый вывод:
═══ без --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
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/^/ /"
'
Ожидаемый вывод:
═══ профиль по умолчанию ═══
Seccomp: 2
Seccomp_filters: 1
═══ заблокированный вызов ═══
unshare(CLONE_NEWUSER): errno=1
═══ с seccomp=unconfined (ТАК НЕ ДЕЛАЙТЕ) ═══
Seccomp: 0
Seccomp_filters: 0
Seccomp: 2 означает режим фильтрации; 0 — фильтра нет.
Третий блок показывает, что делает seccomp=unconfined: снимает защиту целиком. Флаг встречается в инструкциях как обход проблемы, настоящая причина которой в другом — например, в отсутствии нужной capability.
Всё вместе и цена вопроса
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
Ожидаемый вывод:
СЕРВИС 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 приложения и докажите каждую меру измерением.
Требования:
- Приложение работает от non-root; UID задан в образе, а не флагом.
--cap-drop=ALLбез единого--cap-add; показано, что приложение работает.no-new-privilegesвключён; показано, чтоsetuid-программа не повышает привилегии.- Корень доступен только на чтение; список
tmpfsполучен измерением, а не подбором. --pids-limitзадан; значение обосновано числом worker'ов и потоков.- Профиль seccomp не отключён — проверено.
- Приложение продолжает работать: HTTP отвечает, остановка даёт код
0.
Скрипт сравнивает hardened и обычный вариант и возвращает ненулевой код при провале.
Подсказки
Подсказка 1
Для пункта 3 нужна setuid-программа в образе — иначе демонстрировать нечего.
Подсказка 2
Пункт 4 требует сначала запустить без --read-only и посмотреть docker diff.
Подсказка 3
Пункт 6 проверяется полем Seccomp в /proc/self/status.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Пункт 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 не проверяется на реальном сценарии атаки — только на отдельных операциях, которые атака использовала бы.
Проверка результата
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 — другой процесс с другим набором |
Контрольные вопросы
На понимание:
- Почему
--cap-drop=ALLпредпочтительнее удаления по одной? - Что делает
no-new-privilegesи от какой атаки защищает? - Почему приложению, слушающему порт 8000, не нужна ни одна capability?
- Что означает
Seccomp: 2в/proc/self/status? - Чем
--pids-limitзащищает узел, а не только container?
На применение:
- Как определить список
tmpfsдля--read-only? - Как проверить, что capabilities действительно отобраны?
- Как обосновать значение
--pids-limit?
На диагностику:
- Приложение падает с
Resource temporarily unavailable. Первая версия? - После
--cap-drop=ALLприложение не стартует. Порядок действий?
Краткое резюме
- По умолчанию container работает от root с 14 capabilities и записываемым корнем.
USERзадают в образе;--user— дополнительный рубеж, а не замена.- Правильный порядок:
--cap-drop=ALL, затем точечный--cap-add. - Типичному Python-приложению не нужна ни одна capability, если порт выше 1024.
--cap-drop=ALLограничивает и процессы с UID 0.no-new-privilegesблокирует повышение привилегий черезsetuidи почти ничего не стоит.- Список каталогов для
tmpfsпри--read-onlyизмеряют черезdocker diff. - Для non-root в опциях
tmpfsзадаютuidиgid. - Профиль seccomp по умолчанию блокирует около 44 вызовов; отключать его почти всегда ошибка.
--pids-limitзащищает узел от исчерпания таблицы процессов; при исчерпании —EAGAIN.- Проверять настройки нужно от имени самого приложения:
docker execсоздаёт другой процесс. - Hardening ограничивает последствия проникновения, но не предотвращает его.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: security | https://docs.docker.com/engine/security/ | Обзор механизмов изоляции |
| Docker: seccomp | https://docs.docker.com/engine/security/seccomp/ | Профиль по умолчанию, список вызовов |
| Docker: AppArmor | https://docs.docker.com/engine/security/apparmor/ | Профиль docker-default |
Docker: docker run reference | https://docs.docker.com/reference/cli/docker/container/run/#security-opt | --cap-drop, --security-opt, --read-only |
| Docker: resource constraints | https://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_privs | https://docs.kernel.org/userspace-api/no_new_privs.html | Механизм и наследование |
Linux: seccomp(2) | https://man7.org/linux/man-pages/man2/seccomp.2.html | Режимы фильтрации |
| OWASP: Docker Security | https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html | Сводка практик |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Healthchecks и readiness
Главное оглавление