Главная/Security/Урок

12.2. Daemon и socket

Цели

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

  • объяснить, почему доступ к Docker socket эквивалентен правам root на host;
  • показать это на собственной машине и понять механизм;
  • обоснованно выбрать между группой docker, sudo и rootless mode;
  • назвать альтернативы монтированию socket и выбрать подходящую;
  • объяснить, почему Docker API по TCP без TLS недопустим;
  • обнаружить в чужой конфигурации монтирование socket и оценить последствия.

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

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

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

ТерминОбъяснение
Docker daemonПроцесс dockerd, выполняющий все операции с container'ами
docker.sockUnix-сокет, через который CLI общается с демоном
Docker APIHTTP-подобный интерфейс демона
socket proxyПосредник, пропускающий только разрешённые запросы API
docker contextИменованное подключение к демону

Теория

Кто такой Docker daemon

dockerd работает от root и должен: создавать namespaces, монтировать файловые системы, настраивать сеть, управлять cgroups. Все эти операции требуют привилегий.

CLI docker привилегий не имеет — он лишь отправляет запросы демону через сокет.

text
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

bash
sudo usermod -aG docker $USER

Команда часто подаётся как «удобство, чтобы не писать sudo». Фактически это выдача прав root пользователю.

УтверждениеВерно
Группа docker — ограниченные праваНет
Через неё можно получить rootДа, тривиально
Это документированоДа, в официальной документации
Есть безопасная альтернативаДа: rootless mode

Практический вывод: добавление в группу docker эквивалентно добавлению в sudoers без пароля. Это допустимо на своей машине разработки и недопустимо на многопользовательском узле.

Монтирование socket в container

yaml
volumes:
  - /var/run/docker.sock:/var/run/docker.sock    # эквивалент root на host

Приём применяется для CI-агентов, инструментов вроде Watchtower и Portainer, сборщиков образов.

Последствие то же: процесс в container получает root на host. Изоляция самого container'а перестаёт иметь значение.

Что не помогаетПочему
--user 10001UID проверяется только правами сокета; в 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

bash
dockerd -H tcp://0.0.0.0:2375        # НИКОГДА так не делайте

Порт 2375 без TLS — открытый root-доступ для всех, кто может к нему подключиться. Такие узлы находят сканированием сети за минуты.

ПортЗначение
2375HTTP без шифрования — небезопасно
2376HTTPS с взаимной проверкой сертификатов

Если удалённый доступ нужен, правильный порядок предпочтений:

  1. docker context поверх SSH — аутентификация и шифрование уже есть;
  2. TLS с взаимной проверкой на порту 2376;
  3. TCP без TLS — недопустимо ни при каких условиях.

Как обнаружить проблему

Что искатьКоманда
Монтирование socket в container'ахdocker ps -q | xargs docker inspect --format ...
Состав группы dockergetent group docker
Открытый порт APIss -tlnp | grep -E '2375|2376'
Socket в Compose-файлахgrep -r docker.sock
Режим демонаdocker info --format '{{.SecurityOptions}}'

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

Что такое docker.sock

Обычный файл типа Unix-сокет:

text
srw-rw---- 1 root docker 0 июл 31 09:12 /var/run/docker.sock

Права 660, владелец root, группа docker. Доступ определяется правами файловой системы — никакой дополнительной аутентификации в API нет.

Отсюда следствие: Docker API не различает пользователей. Все запросы выполняются с полными правами демона.

Как выглядит запрос к API

bash
curl --unix-socket /var/run/docker.sock http://localhost/version

Это обычный HTTP поверх Unix-сокета. Никаких токенов, заголовков авторизации и ролей — если вы дошли до сокета, вы можете всё.

Именно поэтому socket proxy работает: он ставится между клиентом и сокетом и фильтрует запросы по методу и пути.


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

Что такое сокет и кто к нему допущен

bash
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)"

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

text
═══ файл сокета ═══
  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 отвечает без всякой аутентификации

bash
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 "  Доступ определяется ТОЛЬКО правами файла сокета."

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

text
═══ запрос к API напрямую ═══
  версия API:  1.51
  версия:      29.0.1
  ОС:          linux/amd64
═══ ни токена, ни пароля не потребовалось ═══
  Доступ определяется ТОЛЬКО правами файла сокета.

Обычный HTTP-запрос, никаких заголовков авторизации. Модель доступа Docker API — «всё или ничего».

Почему socket равен root: демонстрация

bash
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

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

text
═══ создаём файл, доступный только 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'а с монтированным сокетом

bash
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

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

text
═══ container с сокетом, максимально ограниченный ═══
  UID: 10001, capabilities отобраны, корень read-only, сети нет
  версия демона через сокет:
    29.0.1
═══ вывод ═══
  Все меры hardening применены — и ни одна не помешала.
  Они ограничивают процесс в container'е, а сокет позволяет
  попросить демон создать ДРУГОЙ container, уже без ограничений.

Обратите внимание на --network none: сети у container'а нет, но API доступен — сокет это файл, а не сетевое соединение.

Ключевой вывод урока: если сокет смонтирован, hardening container'а не имеет значения для этого вектора.

Обнаружение монтирования socket

bash
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

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

text
═══ поднимаем 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

bash
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

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

text
═══ запускаем 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

bash
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

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

text
═══ текущий режим демона ═══
  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 недопустимо

bash
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

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

text
═══ проверка открытых портов 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

Порты закрыты — правильное состояние. Проверку стоит включить в периодический аудит узлов.

Сборка без доступа к демону

bash
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 недоступен"

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

text
═══ типичный 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
bash
sudo rm -f /root/only-root.txt 2>/dev/null
cd /tmp && rm -rf /tmp/sockdemo

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

Задание. Продемонстрируйте риск доступа к сокету и постройте безопасную альтернативу.

Требования:

  1. Показать, что доступ к сокету даёт чтение файла, недоступного обычному пользователю.
  2. Показать, что hardening container'а этому не мешает: применить все меры и убедиться, что API по-прежнему доступен.
  3. Реализовать socket proxy, пропускающий только чтение.
  4. Показать, что через proxy разрешённый запрос проходит, а создание container'а блокируется.
  5. Написать скрипт аудита: найти container'ы с сокетом и упоминания в конфигурациях.
  6. Скрипт аудита должен возвращать ненулевой код при находке и нулевой при чистой системе.

Подсказки

Подсказка 1

Для пункта 2 примените --user, --cap-drop=ALL, --read-only, --network none — и всё равно обратитесь к API.

Подсказка 2

Proxy должен проверять и метод, и путь: GET /containers/json безопасен, POST /containers/create — нет.

Подсказка 3

Пункт 6 требует проверки на обоих состояниях: с найденным container'ом и без него.

Решение

Показать решение
bash
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"

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

text
═══ Требование 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).

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

bash
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Не задумывалисьАудит занимает секунду

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

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

  1. Почему доступ к docker.sock эквивалентен правам root?
  2. Какая единственная возможность API достаточна для получения root?
  3. Почему --user, --cap-drop и --read-only не защищают container с сокетом?
  4. Чем rootless-режим меняет последствия доступа к сокету?
  5. Почему монтирование сокета с :ro не является ограничением?

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

  1. Как дать CI-агенту возможность собирать образы без доступа к сокету?
  2. Как ограничить набор операций API для инструмента мониторинга?
  3. Как организовать удалённый доступ к демону?

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

  1. В конфигурации найдено монтирование сокета. Как оценить последствия?
  2. Нужно узнать, кто на узле имеет права root через Docker. Что проверить?

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

  1. dockerd работает от root; CLI лишь отправляет ему запросы через сокет.
  2. Доступ к docker.sock эквивалентен правам root на host — без оговорок.
  3. Достаточно одной возможности API: создать container с монтированием каталога host.
  4. Членство в группе docker равносильно записи в sudoers без пароля.
  5. Монтирование сокета в container передаёт ему те же права.
  6. Ни одна мера hardening не ограничивает то, что можно запросить у демона.
  7. Флаг :ro на сокете ничего не меняет: запросы API не являются записью в файл.
  8. Docker API не имеет аутентификации: доступ определяют права файла сокета.
  9. Socket proxy с белым списком ограничивает набор допустимых операций.
  10. Rootless-режим меняет последствия: побег даёт права пользователя, а не root.
  11. Сборку в CI можно вести без сокета: BuildKit с отдельным драйвером, kaniko, buildah.
  12. Удалённый доступ — только через SSH-контекст или TLS с взаимной проверкой.

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

ИсточникСсылкаЧто подтверждает
Docker: daemon socket optionhttps://docs.docker.com/reference/cli/dockerd/#daemon-socket-optionСокет, TCP, требование TLS
Docker: securityhttps://docs.docker.com/engine/security/#docker-daemon-attack-surfaceПоверхность атаки демона, группа docker
Docker: protect the daemon sockethttps://docs.docker.com/engine/security/protect-access/TLS, взаимная проверка
Docker: rootless modehttps://docs.docker.com/engine/security/rootless/Модель безопасности и ограничения
Docker: contextshttps://docs.docker.com/engine/manage-resources/contexts/Подключение по SSH
Docker Engine APIhttps://docs.docker.com/reference/api/engine/Полный перечень операций
Docker: buildx drivershttps://docs.docker.com/build/builders/drivers/Сборка без доступа к демону
OWASP: Docker Securityhttps://cheatsheetseries.owasp.org/cheatsheets/Docker_Security_Cheat_Sheet.htmlРекомендация не монтировать сокет

Навигация

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

Markdown на GitHub ↗