12.2. Daemon и socket
Цели
После этого материала вы сможете:
- объяснить, почему доступ к Docker socket эквивалентен правам
rootна host; - показать это на собственной машине и понять механизм;
- обоснованно выбрать между группой
docker,sudoи rootless mode; - назвать альтернативы монтированию socket и выбрать подходящую;
- объяснить, почему Docker API по TCP без TLS недопустим;
- обнаружить в чужой конфигурации монтирование socket и оценить последствия.
Предварительные знания
Демонстрации выполняются на собственной учебной машине. Цель — понять механизм, чтобы уметь его закрыть.
Ключевые термины
| Термин | Объяснение |
|---|---|
Docker daemon | Процесс dockerd, выполняющий все операции с container'ами |
docker.sock | Unix-сокет, через который CLI общается с демоном |
Docker API | HTTP-подобный интерфейс демона |
socket proxy | Посредник, пропускающий только разрешённые запросы API |
docker context | Именованное подключение к демону |
Теория
Кто такой Docker daemon
dockerd работает от root и должен: создавать namespaces, монтировать файловые системы, настраивать сеть, управлять cgroups. Все эти операции требуют привилегий.
CLI docker привилегий не имеет — он лишь отправляет запросы демону через сокет.
docker run ... ──HTTP──► /var/run/docker.sock ──► dockerd (root)
(обычный (сокет, группа │
пользователь) docker, права 660) ▼
выполняет как root
Отсюда центральное утверждение урока:
Кто может писать в
docker.sock, тот может выполнить произвольную операцию от имениrootна host.
Не «почти как root», не «с оговорками» — ровно root.
Почему именно root
Достаточно одной возможности API: создать container с монтированием каталога host.
| Запрос к API | Результат |
|---|---|
Создать container с Binds: ["/:/host"] | Вся файловая система host доступна на запись |
Создать container с Privileged: true | Полный набор capabilities, доступ к устройствам |
Создать container с PidMode: "host" | Видны и доступны процессы host |
Создать container с NetworkMode: "host" | Сетевой стек host |
Первая строки достаточно: получив запись в /host/etc/, /host/root/.ssh/ или /host/usr/local/bin/, атакующий закрепляется на host.
Никакая настройка изоляции самого container'а этому не мешает: атакующий не выходит из container'а — он просит демон создать новый, уже без ограничений.
Группа docker — это root
sudo usermod -aG docker $USER
Команда часто подаётся как «удобство, чтобы не писать sudo». Фактически это выдача прав root пользователю.
| Утверждение | Верно |
|---|---|
Группа docker — ограниченные права | Нет |
| Через неё можно получить root | Да, тривиально |
| Это документировано | Да, в официальной документации |
| Есть безопасная альтернатива | Да: rootless mode |
Практический вывод: добавление в группу docker эквивалентно добавлению в sudoers без пароля. Это допустимо на своей машине разработки и недопустимо на многопользовательском узле.
Монтирование socket в container
volumes:
- /var/run/docker.sock:/var/run/docker.sock # эквивалент root на host
Приём применяется для CI-агентов, инструментов вроде Watchtower и Portainer, сборщиков образов.
Последствие то же: процесс в container получает root на host. Изоляция самого container'а перестаёт иметь значение.
| Что не помогает | Почему |
|---|---|
--user 10001 | UID проверяется только правами сокета; в container он root или в группе |
--cap-drop=ALL | Привилегии нужны демону, а не вам |
--read-only | Запись идёт через API, а не в файловую систему |
--security-opt no-new-privileges | Повышение не требуется |
Сеть internal | Сокет — файл, не сеть |
Ни одна мера hardening не ограничивает то, что можно попросить у демона.
Альтернативы монтированию socket
| Альтернатива | Что даёт | Ограничения |
|---|---|---|
| Socket proxy с фильтром API | Только разрешённые запросы | Нужна настройка списка |
| Rootless Docker | Демон без root; побег даёт права пользователя | Ограничения сети и хранилища |
| Отдельный демон в container (DinD) | Изоляция от основного демона | --privileged, свои риски |
| BuildKit без сокета | Сборка без доступа к демону | Только сборка |
docker context по SSH | Аутентификация на уровне SSH | Требует настройки ключей |
| Kubernetes API вместо Docker API | Ролевая модель | Другая платформа |
Socket proxy — самое частое решение для CI и инструментов мониторинга. Он пропускает, например, только GET /containers/json, блокируя всё, что создаёт container'ы.
Docker API по TCP
dockerd -H tcp://0.0.0.0:2375 # НИКОГДА так не делайте
Порт 2375 без TLS — открытый root-доступ для всех, кто может к нему подключиться. Такие узлы находят сканированием сети за минуты.
| Порт | Значение |
|---|---|
| 2375 | HTTP без шифрования — небезопасно |
| 2376 | HTTPS с взаимной проверкой сертификатов |
Если удалённый доступ нужен, правильный порядок предпочтений:
docker contextповерх SSH — аутентификация и шифрование уже есть;- TLS с взаимной проверкой на порту 2376;
- TCP без TLS — недопустимо ни при каких условиях.
Как обнаружить проблему
| Что искать | Команда |
|---|---|
| Монтирование socket в container'ах | docker ps -q | xargs docker inspect --format ... |
Состав группы docker | getent group docker |
| Открытый порт API | ss -tlnp | grep -E '2375|2376' |
| Socket в Compose-файлах | grep -r docker.sock |
| Режим демона | docker info --format '{{.SecurityOptions}}' |
Внутренний механизм
Что такое docker.sock
Обычный файл типа Unix-сокет:
srw-rw---- 1 root docker 0 июл 31 09:12 /var/run/docker.sock
Права 660, владелец root, группа docker. Доступ определяется правами файловой системы — никакой дополнительной аутентификации в API нет.
Отсюда следствие: Docker API не различает пользователей. Все запросы выполняются с полными правами демона.
Как выглядит запрос к API
curl --unix-socket /var/run/docker.sock http://localhost/version
Это обычный HTTP поверх Unix-сокета. Никаких токенов, заголовков авторизации и ролей — если вы дошли до сокета, вы можете всё.
Именно поэтому socket proxy работает: он ставится между клиентом и сокетом и фильтрует запросы по методу и пути.
Команды и примеры
Что такое сокет и кто к нему допущен
echo "═══ файл сокета ═══"
ls -l /var/run/docker.sock 2>/dev/null | sed 's/^/ /' || echo " сокет не найден"
echo "═══ кто в группе docker ═══"
getent group docker 2>/dev/null | sed 's/^/ /' || echo " группа docker отсутствует"
echo "═══ ваши группы ═══"
id -Gn | tr ' ' '\n' | grep -x docker > /dev/null \
&& echo " вы в группе docker — то есть имеете права root через демон" \
|| echo " вы НЕ в группе docker"
echo "═══ режим демона ═══"
docker info --format ' rootless: {{.SecurityOptions}}' 2>/dev/null | grep -o 'rootless' \
|| echo " режим: rootful (демон работает от root)"
Ожидаемый вывод:
═══ файл сокета ═══
srw-rw---- 1 root docker 0 июл 31 09:12 /var/run/docker.sock
═══ кто в группе docker ═══
docker:x:999:evg
═══ ваши группы ═══
вы в группе docker — то есть имеете права root через демон
═══ режим демона ═══
режим: rootful (демон работает от root)
Права srw-rw---- и группа docker — вся модель доступа. Никакой аутентификации внутри API нет.
Строка docker:x:999:evg означает, что пользователь evg имеет права root на этой машине — независимо от того, есть ли он в sudoers.
API отвечает без всякой аутентификации
echo "═══ запрос к API напрямую ═══"
curl -s --unix-socket /var/run/docker.sock http://localhost/version 2>/dev/null \
| python3 -c "
import json, sys
d = json.load(sys.stdin)
print(f\" версия API: {d.get('ApiVersion')}\")
print(f\" версия: {d.get('Version')}\")
print(f\" ОС: {d.get('Os')}/{d.get('Arch')}\")
" 2>/dev/null || echo " (нет доступа к сокету)"
echo "═══ ни токена, ни пароля не потребовалось ═══"
echo " Доступ определяется ТОЛЬКО правами файла сокета."
Ожидаемый вывод:
═══ запрос к API напрямую ═══
версия API: 1.51
версия: 29.0.1
ОС: linux/amd64
═══ ни токена, ни пароля не потребовалось ═══
Доступ определяется ТОЛЬКО правами файла сокета.
Обычный HTTP-запрос, никаких заголовков авторизации. Модель доступа Docker API — «всё или ничего».
Почему socket равен root: демонстрация
mkdir -p /tmp/sockdemo && cd /tmp/sockdemo
echo "═══ создаём файл, доступный только root ═══"
sudo sh -c 'echo "секрет, доступный только root" > /root/only-root.txt' 2>/dev/null
sudo chmod 600 /root/only-root.txt 2>/dev/null
sudo ls -l /root/only-root.txt 2>/dev/null | sed 's/^/ /'
echo "═══ обычный пользователь прочитать не может ═══"
cat /root/only-root.txt 2>&1 | tail -1 | sed 's/^/ /'
echo "═══ но через socket — может ═══"
docker run --rm -v /:/host:ro alpine:3.21 cat /host/root/only-root.txt 2>/dev/null \
| sed 's/^/ /'
echo "═══ что произошло ═══"
cat <<'TXT'
Мы не обходили изоляцию container'а. Мы попросили демон,
работающий от root, создать container с монтированием корня host.
Демон выполнил запрос — у него есть права, и API их не ограничивает.
TXT
Ожидаемый вывод:
═══ создаём файл, доступный только root ═══
-rw------- 1 root root 44 июл 31 15:02 /root/only-root.txt
═══ обычный пользователь прочитать не может ═══
cat: /root/only-root.txt: Permission denied
═══ но через socket — может ═══
секрет, доступный только root
═══ что произошло ═══
Мы не обходили изоляцию container'а. Мы попросили демон,
работающий от root, создать container с монтированием корня host.
Демон выполнил запрос — у него есть права, и API их не ограничивает.
Это и есть весь механизм. Никакой уязвимости не использовалось: API работает как задумано.
Запись работает так же: -v /:/host без :ro даёт запись в любой файл host, включая /etc/passwd и /root/.ssh/authorized_keys.
То же самое изнутри container'а с монтированным сокетом
cd /tmp/sockdemo
echo "═══ container с сокетом, максимально ограниченный ═══"
docker run --rm \
--user 10001:10001 \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--read-only --tmpfs /tmp:size=8m \
--network none \
-v /var/run/docker.sock:/var/run/docker.sock \
alpine:3.21 sh -c '
echo " UID: $(id -u), capabilities отобраны, корень read-only, сети нет"
apk add --no-cache curl > /dev/null 2>&1
echo " версия демона через сокет:"
curl -s --unix-socket /var/run/docker.sock http://localhost/version \
| sed "s/.*\"Version\":\"\([^\"]*\)\".*/ \1/" 2>/dev/null \
|| echo " (нет доступа)"
' 2>/dev/null
echo "═══ вывод ═══"
cat <<'TXT'
Все меры hardening применены — и ни одна не помешала.
Они ограничивают процесс в container'е, а сокет позволяет
попросить демон создать ДРУГОЙ container, уже без ограничений.
TXT
Ожидаемый вывод:
═══ container с сокетом, максимально ограниченный ═══
UID: 10001, capabilities отобраны, корень read-only, сети нет
версия демона через сокет:
29.0.1
═══ вывод ═══
Все меры hardening применены — и ни одна не помешала.
Они ограничивают процесс в container'е, а сокет позволяет
попросить демон создать ДРУГОЙ container, уже без ограничений.
Обратите внимание на --network none: сети у container'а нет, но API доступен — сокет это файл, а не сетевое соединение.
Ключевой вывод урока: если сокет смонтирован, hardening container'а не имеет значения для этого вектора.
Обнаружение монтирования socket
cd /tmp/sockdemo
cat > find-socket-mounts.sh <<'SH'
#!/usr/bin/env bash
# Поиск container'ов с доступом к Docker socket.
set -uo pipefail
found=0
printf ' %-24s %-10s %s\n' "CONTAINER" "UID" "МОНТИРОВАНИЕ"
printf ' %s\n' "──────────────────────────────────────────────────────────────"
while read -r cid; do
[ -z "$cid" ] && continue
name="$(docker inspect "$cid" --format '{{.Name}}' | tr -d '/')"
mounts="$(docker inspect "$cid" --format '{{range .Mounts}}{{.Source}}:{{.Destination}} {{end}}')"
case "$mounts" in
*docker.sock*)
user="$(docker inspect "$cid" --format '{{if .Config.User}}{{.Config.User}}{{else}}root{{end}}')"
sock="$(echo "$mounts" | tr ' ' '\n' | grep docker.sock | head -1)"
printf ' %-24s %-10s %s\n' "${name:0:24}" "$user" "$sock"
found=$((found + 1))
;;
esac
done < <(docker ps -q)
if [ "$found" -eq 0 ]; then
echo " container'ов с доступом к сокету не найдено"
else
printf '\n НАЙДЕНО: %s. Каждый из них имеет права root на host.\n' "$found"
fi
echo
echo " Поиск в конфигурациях:"
grep -rl 'docker\.sock' --include='*.yaml' --include='*.yml' . 2>/dev/null \
| head -5 | sed 's/^/ /' || echo " в файлах не найдено"
exit "$( [ "$found" -eq 0 ] && echo 0 || echo 1 )"
SH
chmod +x find-socket-mounts.sh
echo "═══ поднимаем container с сокетом для проверки ═══"
docker run -d --name sock-user \
-v /var/run/docker.sock:/var/run/docker.sock \
alpine:3.21 sleep 120 > /dev/null
cat > compose.example.yaml <<'EOF'
services:
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
EOF
./find-socket-mounts.sh || true
docker rm -f sock-user > /dev/null
Ожидаемый вывод:
═══ поднимаем container с сокетом для проверки ═══
CONTAINER UID МОНТИРОВАНИЕ
──────────────────────────────────────────────────────────────
sock-user root /var/run/docker.sock:/var/run/docker.sock
НАЙДЕНО: 1. Каждый из них имеет права root на host.
Поиск в конфигурациях:
./compose.example.yaml
Проверка занимает секунду и находит и работающие container'ы, и упоминания в конфигурациях. Её ставят в CI и в периодический аудит.
Socket proxy: ограничение API
cd /tmp/sockdemo
cat > proxy.py <<'PY'
"""Минимальный socket proxy: пропускает только разрешённые запросы.
Демонстрирует принцип. В эксплуатации используют готовые решения
(например, tecnativa/docker-socket-proxy) с полным разбором API.
"""
from __future__ import annotations
import http.server
import json
import re
import socket
import socketserver
import sys
SOCKET_PATH = "/var/run/docker.sock"
# Разрешаем только чтение: список и просмотр container'ов, версия.
ALLOWED = [
(r"^GET$", r"^/v[\d.]+/version$|^/version$"),
(r"^GET$", r"^/v[\d.]+/containers/json|^/containers/json"),
(r"^GET$", r"^/v[\d.]+/containers/[^/]+/json$|^/containers/[^/]+/json$"),
]
def allowed(method: str, path: str) -> bool:
return any(re.match(m, method) and re.match(p, path) for m, p in ALLOWED)
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _forward(self) -> None:
if not allowed(self.command, self.path):
body = json.dumps({
"message": f"запрещено политикой proxy: {self.command} {self.path}"
}).encode()
self.send_response(403)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
print(f" БЛОКИРОВАНО: {self.command} {self.path}", flush=True)
return
print(f" разрешено: {self.command} {self.path}", flush=True)
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect(SOCKET_PATH)
request = f"{self.command} {self.path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
s.sendall(request.encode())
chunks = []
while True:
data = s.recv(65536)
if not data:
break
chunks.append(data)
s.close()
self.wfile.write(b"".join(chunks))
do_GET = _forward
do_POST = _forward
do_DELETE = _forward
def log_message(self, *args: object) -> None:
pass
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 2380
print(f"proxy слушает {port}, разрешено только чтение", flush=True)
with socketserver.ThreadingTCPServer(("0.0.0.0", port), Handler) as srv:
srv.allow_reuse_address = True
srv.serve_forever()
PY
echo "═══ запускаем proxy ═══"
docker run -d --name sockproxy \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-v "$PWD/proxy.py:/proxy.py:ro" \
-p 127.0.0.1:2380:2380 \
python:3.13-slim python -u /proxy.py 2380 > /dev/null
sleep 3
echo "═══ разрешённый запрос ═══"
curl -s -m 5 http://127.0.0.1:2380/version 2>/dev/null \
| python3 -c "import json,sys; print(' версия:', json.load(sys.stdin).get('Version'))" 2>/dev/null \
|| echo " (proxy недоступен)"
echo "═══ запрещённый запрос: создание container ═══"
curl -s -m 5 -X POST -H 'Content-Type: application/json' \
-d '{"Image":"alpine","HostConfig":{"Binds":["/:/host"]}}' \
"http://127.0.0.1:2380/containers/create" 2>/dev/null \
| python3 -c "import json,sys; print(' ответ:', json.load(sys.stdin).get('message'))" 2>/dev/null \
|| echo " (нет ответа)"
echo "═══ журнал proxy ═══"
docker logs sockproxy 2>&1 | tail -4 | sed 's/^/ /'
docker rm -f sockproxy > /dev/null
Ожидаемый вывод:
═══ запускаем proxy ═══
═══ разрешённый запрос ═══
версия: 29.0.1
═══ запрещённый запрос: создание container ═══
ответ: запрещено политикой proxy: POST /containers/create
═══ журнал proxy ═══
proxy слушает 2380, разрешено только чтение
разрешено: GET /version
БЛОКИРОВАНО: POST /containers/create
═══
Proxy пропустил чтение версии и заблокировал создание container'а — то есть именно ту операцию, которая даёт root.
Инструменту мониторинга достаточно чтения; создавать container'ы ему не нужно. Это и есть принцип минимальных привилегий, применённый к API.
Обратите внимание: сам proxy имеет доступ к сокету и потому должен быть максимально изолирован — он становится новой границей доверия.
Rootless: побег даёт права пользователя, а не root
cd /tmp/sockdemo
echo "═══ текущий режим демона ═══"
if docker info --format '{{json .SecurityOptions}}' 2>/dev/null | grep -q rootless; then
echo " rootless"
printf ' сокет: %s\n' "${DOCKER_HOST:-unix://$XDG_RUNTIME_DIR/docker.sock}"
else
echo " rootful — демон работает от root"
printf ' сокет: /var/run/docker.sock (владелец root, группа docker)\n'
fi
echo "═══ чем отличается rootless ═══"
cat <<'TXT'
rootful: демон root → создание container с -v /:/host даёт полный root
rootless: демон от вашего пользователя → монтирование / даёт
ровно те права, что есть у вас; файлы root недоступны
Цена rootless: ограничения сети (нет портов < 1024 без настройки),
ограничения хранилища, часть возможностей недоступна.
TXT
echo "═══ проверка на текущей системе ═══"
docker run --rm -v /:/host:ro alpine:3.21 sh -c '
if cat /host/etc/shadow > /dev/null 2>&1; then
echo " /etc/shadow ЧИТАЕТСЯ → демон работает от root"
else
echo " /etc/shadow недоступен → вероятно, rootless"
fi
' 2>/dev/null
Ожидаемый вывод:
═══ текущий режим демона ═══
rootful — демон работает от root
сокет: /var/run/docker.sock (владелец root, группа docker)
═══ чем отличается rootless ═══
rootful: демон root → создание container с -v /:/host даёт полный root
rootless: демон от вашего пользователя → монтирование / даёт
ровно те права, что есть у вас; файлы root недоступны
Цена rootless: ограничения сети (нет портов < 1024 без настройки),
ограничения хранилища, часть возможностей недоступна.
═══ проверка на текущей системе ═══
/etc/shadow ЧИТАЕТСЯ → демон работает от root
Чтение /etc/shadow через container — однозначный признак rootful-демона. В rootless-режиме та же команда вернула бы отказ (урок 1.6).
API по TCP: почему без TLS недопустимо
cd /tmp/sockdemo
echo "═══ проверка открытых портов API ═══"
for port in 2375 2376; do
if ss -tln 2>/dev/null | grep -q ":$port "; then
printf ' порт %s: ОТКРЫТ\n' "$port"
[ "$port" = "2375" ] && printf ' ⚠ это API без TLS — root-доступ для всех, кто дойдёт\n'
else
printf ' порт %s: закрыт\n' "$port"
fi
done
echo "═══ что было бы при открытом 2375 ═══"
cat <<'TXT'
Любой, кто может подключиться к порту:
curl -X POST http://УЗЕЛ:2375/containers/create \
-d '{"Image":"alpine","HostConfig":{"Binds":["/:/host"]}}'
— и получает запись в любой файл host.
Такие узлы находят массовым сканированием сети.
Правильные варианты удалённого доступа:
1. docker context поверх SSH ← предпочтительно
2. TLS с взаимной проверкой на 2376
3. TCP без TLS ← недопустимо никогда
TXT
echo "═══ настройка доступа по SSH ═══"
cat <<'TXT'
docker context create prod --docker "host=ssh://user@узел"
docker context use prod
docker ps # выполняется на удалённом узле через SSH
TXT
docker context ls --format ' {{.Name}}\t{{.DockerEndpoint}}' 2>/dev/null | head -3
Ожидаемый вывод:
═══ проверка открытых портов API ═══
порт 2375: закрыт
порт 2376: закрыт
═══ что было бы при открытом 2375 ═══
Любой, кто может подключиться к порту:
curl -X POST http://УЗЕЛ:2375/containers/create \
-d '{"Image":"alpine","HostConfig":{"Binds":["/:/host"]}}'
— и получает запись в любой файл host.
Такие узлы находят массовым сканированием сети.
Правильные варианты удалённого доступа:
1. docker context поверх SSH ← предпочтительно
2. TLS с взаимной проверкой на 2376
3. TCP без TLS ← недопустимо никогда
═══ настройка доступа по SSH ═══
docker context create prod --docker "host=ssh://user@узел"
docker context use prod
docker ps # выполняется на удалённом узле через SSH
default unix:///var/run/docker.sock
Порты закрыты — правильное состояние. Проверку стоит включить в периодический аудит узлов.
Сборка без доступа к демону
cd /tmp/sockdemo
echo "═══ типичный CI-агент с сокетом ═══"
cat <<'TXT'
services:
ci-agent:
volumes:
- /var/run/docker.sock:/var/run/docker.sock ← root на host
TXT
echo "═══ альтернатива: сборка без сокета ═══"
cat <<'TXT'
BuildKit умеет собирать образы, не обращаясь к демону Docker:
docker buildx create --driver docker-container --name builder
docker buildx build --builder builder -t app .
Драйвер docker-container запускает BuildKit в отдельном container'е.
Агенту нужен доступ к нему, а не к основному демону.
Ещё варианты без сокета:
kaniko — сборка внутри Kubernetes без демона
buildah — сборка без демона вообще
TXT
echo "═══ доступные драйверы buildx ═══"
docker buildx ls 2>/dev/null | head -5 | sed 's/^/ /' || echo " buildx недоступен"
Ожидаемый вывод:
═══ типичный CI-агент с сокетом ═══
services:
ci-agent:
volumes:
- /var/run/docker.sock:/var/run/docker.sock ← root на host
═══ альтернатива: сборка без сокета ═══
BuildKit умеет собирать образы, не обращаясь к демону Docker:
docker buildx create --driver docker-container --name builder
docker buildx build --builder builder -t app .
Драйвер docker-container запускает BuildKit в отдельном container'е.
Агенту нужен доступ к нему, а не к основному демону.
Ещё варианты без сокета:
kaniko — сборка внутри Kubernetes без демона
buildah — сборка без демона вообще
═══ доступные драйверы buildx ═══
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS
default* docker
\_ default \_ default running v0.28.1 linux/amd64
sudo rm -f /root/only-root.txt 2>/dev/null
cd /tmp && rm -rf /tmp/sockdemo
Практическое упражнение
Задание. Продемонстрируйте риск доступа к сокету и постройте безопасную альтернативу.
Требования:
- Показать, что доступ к сокету даёт чтение файла, недоступного обычному пользователю.
- Показать, что hardening container'а этому не мешает: применить все меры и убедиться, что API по-прежнему доступен.
- Реализовать socket proxy, пропускающий только чтение.
- Показать, что через proxy разрешённый запрос проходит, а создание container'а блокируется.
- Написать скрипт аудита: найти container'ы с сокетом и упоминания в конфигурациях.
- Скрипт аудита должен возвращать ненулевой код при находке и нулевой при чистой системе.
Подсказки
Подсказка 1
Для пункта 2 примените --user, --cap-drop=ALL, --read-only, --network none — и всё равно обратитесь к API.
Подсказка 2
Proxy должен проверять и метод, и путь: GET /containers/json безопасен, POST /containers/create — нет.
Подсказка 3
Пункт 6 требует проверки на обоих состояниях: с найденным container'ом и без него.
Решение
Показать решение
mkdir -p /tmp/sockfull && cd /tmp/sockfull
cat > proxy.py <<'PY'
"""Socket proxy с политикой «только чтение».
Ставится между клиентом и Docker socket. Пропускает запросы,
не создающие и не изменяющие объекты. Всё остальное — 403.
В эксплуатации используют готовые решения с полным разбором API;
здесь показан принцип.
"""
from __future__ import annotations
import http.server
import json
import os
import re
import socket
import socketserver
import sys
SOCKET_PATH = os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")
# Белый список: метод плюс путь. Всё, что не совпало, запрещено.
POLICY: list[tuple[str, str, str]] = [
("GET", r"^(/v[\d.]+)?/version$", "версия демона"),
("GET", r"^(/v[\d.]+)?/_ping$", "проверка доступности"),
("GET", r"^(/v[\d.]+)?/info$", "сведения о демоне"),
("GET", r"^(/v[\d.]+)?/containers/json", "список container'ов"),
("GET", r"^(/v[\d.]+)?/containers/[^/]+/json$", "сведения о container"),
("GET", r"^(/v[\d.]+)?/containers/[^/]+/stats", "метрики container"),
("GET", r"^(/v[\d.]+)?/images/json", "список образов"),
]
_blocked = 0
_allowed = 0
def check(method: str, path: str) -> tuple[bool, str]:
for allowed_method, pattern, description in POLICY:
if method == allowed_method and re.match(pattern, path):
return True, description
return False, "не входит в белый список"
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _handle(self) -> None:
global _allowed, _blocked
ok, reason = check(self.command, self.path)
if not ok:
_blocked += 1
body = json.dumps({
"message": f"ЗАПРЕЩЕНО proxy: {self.command} {self.path} — {reason}",
"policy": "только чтение",
}, ensure_ascii=False).encode()
self.send_response(403)
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)
print(f"БЛОК {self.command} {self.path}", flush=True)
return
_allowed += 1
print(f"ПРОПУСК {self.command} {self.path} ({reason})", flush=True)
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
s.connect(SOCKET_PATH)
req = (f"{self.command} {self.path} HTTP/1.1\r\n"
f"Host: localhost\r\nConnection: close\r\n\r\n")
s.sendall(req.encode())
chunks = []
while True:
data = s.recv(65536)
if not data:
break
chunks.append(data)
self.wfile.write(b"".join(chunks))
except OSError as exc:
body = json.dumps({"message": f"ошибка проксирования: {exc}"}).encode()
self.send_response(502)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
finally:
s.close()
do_GET = _handle
do_POST = _handle
do_PUT = _handle
do_DELETE = _handle
def log_message(self, *args: object) -> None:
pass
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 2380
print(f"proxy на порту {port}: политика «только чтение», "
f"{len(POLICY)} разрешённых операций", flush=True)
socketserver.ThreadingTCPServer.allow_reuse_address = True
with socketserver.ThreadingTCPServer(("0.0.0.0", port), Handler) as srv:
srv.serve_forever()
PY
cat > audit-socket.sh <<'SH'
#!/usr/bin/env bash
# Аудит: поиск доступа к Docker socket.
# Код 0 — чисто; код 1 — найдены container'ы или конфигурации с сокетом.
set -uo pipefail
SCAN_DIR="${1:-.}"
found_containers=0
found_configs=0
printf '\n── Работающие container'"'"'ы ──\n'
printf ' %-26s %-8s %s\n' "ИМЯ" "USER" "ДОСТУП К СОКЕТУ"
printf ' %s\n' "─────────────────────────────────────────────────────────────"
while read -r cid; do
[ -z "$cid" ] && continue
name="$(docker inspect "$cid" --format '{{.Name}}' 2>/dev/null | tr -d '/')"
mounts="$(docker inspect "$cid" --format '{{range .Mounts}}{{.Source}} {{end}}' 2>/dev/null)"
user="$(docker inspect "$cid" --format '{{if .Config.User}}{{.Config.User}}{{else}}root{{end}}' 2>/dev/null)"
case "$mounts" in
*docker.sock*)
printf ' %-26s %-8s %s\n' "${name:0:26}" "${user:0:8}" "ЕСТЬ — права root на host"
found_containers=$((found_containers + 1))
;;
esac
done < <(docker ps -q 2>/dev/null)
[ "$found_containers" -eq 0 ] && printf ' (container'"'"'ов с сокетом нет)\n'
printf '\n── Конфигурационные файлы ──\n'
while read -r file; do
[ -z "$file" ] && continue
line="$(grep -n 'docker\.sock' "$file" | head -1)"
printf ' %s: %s\n' "$file" "$(echo "$line" | cut -c1-60)"
found_configs=$((found_configs + 1))
done < <(grep -rl 'docker\.sock' "$SCAN_DIR" \
--include='*.yaml' --include='*.yml' --include='*.json' 2>/dev/null | head -10)
[ "$found_configs" -eq 0 ] && printf ' (упоминаний в конфигурациях нет)\n'
printf '\n── Прочее ──\n'
if getent group docker > /dev/null 2>&1; then
members="$(getent group docker | cut -d: -f4)"
printf ' группа docker: %s\n' "${members:-(пусто)}"
[ -n "$members" ] && printf ' ⚠ перечисленные имеют права root через демон\n'
fi
for port in 2375 2376; do
ss -tln 2>/dev/null | grep -q ":$port " \
&& printf ' ⚠ порт %s открыт — Docker API доступен по сети\n' "$port"
done
total=$((found_containers + found_configs))
printf '\n── ИТОГ ──\n'
if [ "$total" -eq 0 ]; then
printf ' чисто: доступа к сокету не обнаружено\n'
else
printf ' НАЙДЕНО: container'"'"'ов %s, конфигураций %s\n' "$found_containers" "$found_configs"
fi
exit "$( [ "$total" -eq 0 ] && echo 0 || echo 1 )"
SH
chmod +x audit-socket.sh
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Требование 1: сокет даёт доступ к файлам root ═══\n'
sudo sh -c 'echo "содержимое, доступное только root" > /root/audit-probe.txt' 2>/dev/null
sudo chmod 600 /root/audit-probe.txt 2>/dev/null
direct="$(cat /root/audit-probe.txt 2>&1 | tail -1)"
printf ' прямое чтение: %s\n' "$direct"
via_socket="$(docker run --rm -v /:/host:ro alpine:3.21 \
cat /host/root/audit-probe.txt 2>/dev/null)"
printf ' через демон: %s\n' "${via_socket:-(не удалось)}"
case "$direct" in
*"Permission denied"*|*"Отказано"*)
[ -n "$via_socket" ] && ok "файл root недоступен напрямую, но читается через демон" \
|| bad "через демон тоже не удалось" ;;
*) printf ' (запущено от root — демонстрация не показательна)\n'
ok "проверка выполнена" ;;
esac
printf '\n═══ Требование 2: hardening не мешает ═══\n'
result="$(docker run --rm \
--user 10001:10001 \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--read-only --tmpfs /tmp:size=8m \
--network none \
--pids-limit 32 \
-v /var/run/docker.sock:/var/run/docker.sock \
python:3.13-slim python -c "
import json, socket
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
s.connect('/var/run/docker.sock')
s.sendall(b'GET /version HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n')
data = b''
while True:
chunk = s.recv(65536)
if not chunk:
break
data += chunk
body = data.split(b'\r\n\r\n', 1)[1]
print(json.loads(body).get('Version', '?'))
except Exception as e:
print(f'ошибка: {type(e).__name__}')
finally:
s.close()
" 2>/dev/null)"
printf ' применено: user=10001, cap-drop=ALL, no-new-privileges, read-only, network=none\n'
printf ' версия демона через сокет: %s\n' "${result:-(нет ответа)}"
case "$result" in
*ошибка*|"") bad "API недоступен — проверка не показательна" ;;
*) ok "все меры применены, API по-прежнему доступен" ;;
esac
printf '\n═══ Требования 3-4: socket proxy ═══\n'
docker rm -f sockproxy > /dev/null 2>&1
docker run -d --name sockproxy \
--user 0:0 \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
-v "$PWD/proxy.py:/proxy.py:ro" \
-p 127.0.0.1:2380:2380 \
python:3.13-slim python -u /proxy.py 2380 > /dev/null 2>&1
for _ in $(seq 30); do
curl -s -m 2 -o /dev/null http://127.0.0.1:2380/_ping 2>/dev/null && break
sleep 0.5
done
printf ' разрешённые запросы:\n'
for path in /version /containers/json; do
code="$(curl -s -m 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:2380$path" 2>/dev/null)"
printf ' GET %-20s HTTP %s\n' "$path" "$code"
done
printf ' запрещённые запросы:\n'
codes_blocked=0
for spec in "POST /containers/create" "POST /containers/x/start" "DELETE /containers/x"; do
method="${spec%% *}"; path="${spec#* }"
code="$(curl -s -m 5 -o /dev/null -w '%{http_code}' -X "$method" \
-H 'Content-Type: application/json' \
-d '{"Image":"alpine","HostConfig":{"Binds":["/:/host"]}}' \
"http://127.0.0.1:2380$path" 2>/dev/null)"
printf ' %-6s %-22s HTTP %s\n' "$method" "$path" "$code"
[ "$code" = "403" ] && codes_blocked=$((codes_blocked + 1))
done
allowed_ok="$(curl -s -m 5 -o /dev/null -w '%{http_code}' http://127.0.0.1:2380/version 2>/dev/null)"
[ "$allowed_ok" = "200" ] && ok "чтение проходит через proxy" || bad "чтение заблокировано: $allowed_ok"
[ "$codes_blocked" -eq 3 ] && ok "все операции создания и удаления заблокированы" \
|| bad "заблокировано только $codes_blocked из 3"
printf ' ответ на запрещённый запрос:\n'
curl -s -m 5 -X POST -d '{}' "http://127.0.0.1:2380/containers/create" 2>/dev/null \
| python3 -c "import json,sys; print(' ' + json.load(sys.stdin)['message'])" 2>/dev/null
printf ' журнал proxy:\n'
docker logs sockproxy 2>&1 | tail -5 | sed 's/^/ /'
docker rm -f sockproxy > /dev/null 2>&1
printf '\n═══ Требования 5-6: аудит ═══\n'
cat > compose.risky.yaml <<'EOF'
services:
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
EOF
printf ' состояние с найденным container и конфигурацией:\n'
docker run -d --name audit-target \
-v /var/run/docker.sock:/var/run/docker.sock \
alpine:3.21 sleep 120 > /dev/null 2>&1
./audit-socket.sh . 2>/dev/null | sed 's/^/ /'
dirty_rc=$?
docker rm -f audit-target > /dev/null 2>&1
printf '\n состояние без находок:\n'
mkdir -p clean && ./audit-socket.sh clean 2>/dev/null | sed 's/^/ /'
clean_rc=$?
printf '\n коды возврата: с находками=%s, чисто=%s\n' "$dirty_rc" "$clean_rc"
[ "$dirty_rc" -ne 0 ] && [ "$clean_rc" -eq 0 ] \
&& ok "аудит различает состояния" || bad "коды: $dirty_rc и $clean_rc"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
sudo rm -f /root/audit-probe.txt 2>/dev/null
cd /tmp && rm -rf /tmp/sockfull
exit "$fail"
Ожидаемый вывод:
═══ Требование 1: сокет даёт доступ к файлам root ═══
прямое чтение: cat: /root/audit-probe.txt: Permission denied
через демон: содержимое, доступное только root
✓ файл root недоступен напрямую, но читается через демон
═══ Требование 2: hardening не мешает ═══
применено: user=10001, cap-drop=ALL, no-new-privileges, read-only, network=none
версия демона через сокет: 29.0.1
✓ все меры применены, API по-прежнему доступен
═══ Требования 3-4: socket proxy ═══
разрешённые запросы:
GET /version HTTP 200
GET /containers/json HTTP 200
запрещённые запросы:
POST /containers/create HTTP 403
POST /containers/x/start HTTP 403
DELETE /containers/x HTTP 403
✓ чтение проходит через proxy
✓ все операции создания и удаления заблокированы
ответ на запрещённый запрос:
ЗАПРЕЩЕНО proxy: POST /containers/create — не входит в белый список
журнал proxy:
proxy на порту 2380: политика «только чтение», 7 разрешённых операций
ПРОПУСК GET /version (версия демона)
ПРОПУСК GET /containers/json (список container'ов)
БЛОК POST /containers/create
БЛОК DELETE /containers/x
═══ Требования 5-6: аудит ═══
состояние с найденным container и конфигурацией:
── Работающие container'ы ──
ИМЯ USER ДОСТУП К СОКЕТУ
─────────────────────────────────────────────────────────────
audit-target root ЕСТЬ — права root на host
── Конфигурационные файлы ──
./compose.risky.yaml: 5: - /var/run/docker.sock:/var/run
── Прочее ──
группа docker: evg
⚠ перечисленные имеют права root через демон
── ИТОГ ──
НАЙДЕНО: container'ов 1, конфигураций 1
состояние без находок:
── Работающие container'ы ──
(container'ов с сокетом нет)
── Конфигурационные файлы ──
(упоминаний в конфигурациях нет)
── ИТОГ ──
чисто: доступа к сокету не обнаружено
коды возврата: с находками=1, чисто=0
✓ аудит различает состояния
═══ ИТОГ ═══
все требования выполнены
Все требования выполнены.
Обратите внимание на требование 2: применены все меры hardening из урока 11.2, включая --network none, — и API остался доступен. Это не дефект конфигурации, а свойство вектора: сокет обходит изоляцию не через уязвимость, а через легитимный интерфейс.
Три решения, определяющие качество.
Требование 1 проверяет обе стороны: что файл недоступен напрямую и что читается через демон. Одно только успешное чтение через демон ничего не доказывало бы — файл мог быть доступен всем. Пара «отказ напрямую, успех через демон» показывает именно повышение привилегий.
Proxy реализован белым списком, а не чёрным. Список запрещённых операций пришлось бы пополнять при каждом расширении API, и пропуск одной строки открыл бы вектор. Белый список ошибается в безопасную сторону: неизвестная операция запрещена по умолчанию.
Аудит проверяется на двух состояниях с разными кодами возврата. Скрипт, всегда возвращающий ноль, прошёл бы «проверку» на чистой системе и молча пропустил бы находку. Пара «1 и 0» доказывает, что он различает случаи.
Чего решение не делает. Proxy разбирает только метод и путь; полноценные решения учитывают тело запроса, версии API и параметры — например, GET /containers/{id}/archive позволяет читать файлы из container'а и в белом списке быть не должен. Сам proxy имеет доступ к сокету и становится новой границей доверия: его компрометация возвращает исходную проблему. Не рассматривается и rootless-режим, который снимает вектор в корне, но требует перестройки окружения (урок 12.3).
Проверка результата
ls -l /var/run/docker.sock
getent group docker
docker run --rm -v /:/host:ro alpine:3.21 sh -c 'ls /host/root 2>&1 | head -2'
Если последняя команда выводит содержимое /root, демон работает от root, и доступ к сокету равносилен правам root.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Группа docker «для удобства» | Не хочется писать sudo | Это выдача прав root; rootless mode |
| Монтирование сокета в CI-агент | Так во всех примерах | BuildKit с драйвером docker-container, kaniko |
| Считают, что hardening защитит container с сокетом | Логично предположить | Меры не касаются запросов к API |
--user 10001 для container с сокетом | Кажется ограничением | Доступ определяют права файла сокета |
| Docker API на порту 2375 | Нужен удалённый доступ | Root для всех; docker context по SSH |
Монтирование сокета :ro считают безопасным | Кажется ограничением | Запросы API — не запись в файл |
| Чёрный список в socket proxy | Кажется проще | Новая операция API окажется разрешённой |
Не проверяют конфигурации на docker.sock | Не задумывались | Аудит занимает секунду |
Контрольные вопросы
На понимание:
- Почему доступ к
docker.sockэквивалентен правам root? - Какая единственная возможность API достаточна для получения root?
- Почему
--user,--cap-dropи--read-onlyне защищают container с сокетом? - Чем rootless-режим меняет последствия доступа к сокету?
- Почему монтирование сокета с
:roне является ограничением?
На применение:
- Как дать CI-агенту возможность собирать образы без доступа к сокету?
- Как ограничить набор операций API для инструмента мониторинга?
- Как организовать удалённый доступ к демону?
На диагностику:
- В конфигурации найдено монтирование сокета. Как оценить последствия?
- Нужно узнать, кто на узле имеет права root через Docker. Что проверить?
Краткое резюме
dockerdработает от root; CLI лишь отправляет ему запросы через сокет.- Доступ к
docker.sockэквивалентен правам root на host — без оговорок. - Достаточно одной возможности API: создать container с монтированием каталога host.
- Членство в группе
dockerравносильно записи вsudoersбез пароля. - Монтирование сокета в container передаёт ему те же права.
- Ни одна мера hardening не ограничивает то, что можно запросить у демона.
- Флаг
:roна сокете ничего не меняет: запросы API не являются записью в файл. - Docker API не имеет аутентификации: доступ определяют права файла сокета.
- Socket proxy с белым списком ограничивает набор допустимых операций.
- Rootless-режим меняет последствия: побег даёт права пользователя, а не root.
- Сборку в CI можно вести без сокета: BuildKit с отдельным драйвером, kaniko, buildah.
- Удалённый доступ — только через SSH-контекст или TLS с взаимной проверкой.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: daemon socket option | https://docs.docker.com/reference/cli/dockerd/#daemon-socket-option | Сокет, TCP, требование TLS |
| Docker: security | https://docs.docker.com/engine/security/#docker-daemon-attack-surface | Поверхность атаки демона, группа docker |
| Docker: protect the daemon socket | https://docs.docker.com/engine/security/protect-access/ | TLS, взаимная проверка |
| Docker: rootless mode | https://docs.docker.com/engine/security/rootless/ | Модель безопасности и ограничения |
| Docker: contexts | https://docs.docker.com/engine/manage-resources/contexts/ | Подключение по SSH |
| Docker Engine API | https://docs.docker.com/reference/api/engine/ | Полный перечень операций |
| Docker: buildx drivers | https://docs.docker.com/build/builders/drivers/ | Сборка без доступа к демону |
| OWASP: Docker Security | https://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.html | Рекомендация не монтировать сокет |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Пользователи и namespaces
Главное оглавление