Уязвимость nodes/proxy в Kubernetes: RCE без аудита

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

Тема уже не совсем свежая, но я всё равно хотел написать разбор.

В январе исследователь в области кибербезопасности опубликовал информацию об уязвимости в Kubernetes, наделавшей немало шума. Давно не было такой Kube-уязвимости, которая вызывала бы столь живое обсуждение — по крайней мере, по воспоминаниям Дениса (да, я говорю о себе в третьем лице).

Честно говоря, это довольно дико: RBAC-разрешение nodes/proxy GET позволяет любому ServiceAccount выполнять код внутри любого Pod в кластере, не оставляя ни единого следа в журналах аудита. Особенно неприятно, когда у тебя есть ServiceAccount с именем rook-ceph-system, у которого к тому же есть доступ на чтение ко всем Secret в кластере.

В этой статье подробно разобрана сама проблема, способ проверить наличие уязвимости, применяемые исправления и превентивные меры на случай, если обновить кластер прямо сейчас невозможно.

Суть проблемы: WebSocket + Kubelet = exec без аудита

Уязвимость задокументирована Грэмом Хелтоном в этой статье. Вот как она работает.

Kubernetes API открывает субресурс (subresource) nodes/proxy, который проксирует HTTP-запросы к Kubelet каждого узла. Сам Kubelet предоставляет API на порту 10250, в частности конечную точку (endpoint) /exec, позволяющую выполнять команды внутри контейнера.

Проблема кроется в том, как Kubelet обрабатывает авторизацию WebSocket-соединений:

  • kubectl exec использует WebSocket-соединение, рукопожатие (handshake) которого представляет собой HTTP GET

  • Kubelet сопоставляет этот начальный GET с RBAC-глаголом get

  • Он проверяет разрешение nodes/proxy GET и авторизует операцию — без какой-либо вторичной проверки глагола CREATE, обычно требуемого для /exec

Итог: любой ServiceAccount с разрешением nodes/proxy GET может выполнять команды в любом Pod кластера, включая системные Pod (etcd, kube-apiserver и т. д.).

# Эксплуатация через websocat
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"

И это ещё не всё. Команды, выполненные таким способом, не генерируют никаких записей в журнале аудита Kubernetes (при условии, что вы вообще его собираете 🙈). Обращение идёт напрямую через Kubelet, который не сообщает о событиях обратно API-серверу.

Официальная позиция Kubernetes по этому вопросу: Won’t Fix («исправлять не будем»). Это «проектное поведение» (в кавычках), которое адресуется через feature gate (KEP-2862, см. ниже).

Неприятно.

Аудит: уязвимые ServiceAccount в наших кластерах

В январе 2026 года, после публикации статьи Грэма Хелтона, многим пришлось срочно проводить аудит своих кластеров. Можно либо вручную проверить все Role и ClusterRole, либо воспользоваться скриптом для обнаружения, предоставленным исследователем.

Для примера приведём три распространённых компонента, которые являются отличными кандидатами для повышения привилегий:

Компонент ClusterRole ServiceAccounts

OpenTelemetry Collector

otel-otelcol-k8sobjects

opentelemetry-collector-daemonset-collector, opentelemetry-collector-deployment-collector

OpenTelemetry Operator

otel-operator-resources / opentelemetry-operator-manager

opentelemetry-operator

Rook-Ceph

rook-ceph-global, rook-ceph-mgr-cluster

rook-ceph-system, rook-ceph-mgr

Примечание: таких компонентов гораздо больше. Грэм Хелтон добавил в конец своей статьи раздел «Приложение: затронутые Helm-чарты», в котором по его подсчётам фигурируют как минимум 69 уязвимых Helm-чартов.

Критический случай: rook-ceph-system

В официальном чарте ServiceAccount rook-ceph-system совмещал два особенно опасных разрешения:

  • RCE через nodes/proxy GET

  • secrets GET/LIST/WATCH на весь кластер

Среди доступных Secret могут оказаться ключи LUKS для шифрования томов, ключи администратора Ceph, пароли дашборда и прочее — такой набор прав делает этот аккаунт первоочерёдной целью для злоумышленника.

Сценарий атаки: компрометация Pod rook-ceph-operator (через CVE, атаку на цепочку поставок или вредоносный образ) позволит прочитать все секреты Ceph, а затем выполнить код в любом Pod (включая etcd), что ведёт к полной компрометации кластера и зашифрованных данных.

Чтобы вручную проверить, уязвим ли ServiceAccount:

kubectl auth can-i get nodes --subresource=proxy \
  --as=system:serviceaccount:<namespace>:<serviceaccount>

Примеры применяемых исправлений

Rook-Ceph: исправление в upstream

Для Rook-Ceph исправление пришло из upstream: PR rook/rook#16979 удалил nodes/proxy из ClusterRole. Это исправление включено в Rook v1.19.1, так что для защиты кластеров достаточно было обновления.

После обновления проверка на всех кластерах:

kubectl get clusterroles rook-ceph-global -o yaml | grep -A3 nodes/proxy
# -> ничего

OTel / OTel operator

Для OpenTelemetry ситуация потенциально сложнее. Если вы используете otel-operator и Custom Resources OtelCollector, то, скорее всего, вам придётся управлять RBAC-манифестами самостоятельно.

Сделав это на практике, могу сказать: занятие довольно мучительное. В зависимости от типа коллектора и включённых ресиверов (receivers) нужно сверяться с несколькими документами на официальных сайтах OTel и otel-operator.

В upstream был принят условный подход через open-telemetry/opentelemetry-helm-charts#2083, зависящий от версии Kubernetes:

# Подход из upstream (opentelemetry-helm-charts#2083)
{{- if semverCompare ">=1.33-0" .Capabilities.KubeVersion.Version }}
- nodes/pods
{{- else }}
- nodes/proxy
{{- end }}

Здесь тоже: если все ваши кластеры уже обновлены, можно просто заменить nodes/proxy на nodes/pods напрямую, без условия.

otel-collector-crb.yaml:

# До
rules:
- apiGroups: [""]
  resources:
  - nodes
  - nodes/proxy # <- риск RCE
  - nodes/spec
  - nodes/stats
  verbs:
  - get
# После
rules:
- apiGroups: [""]
  resources:
  - nodes
  # nodes/pods заменяет nodes/proxy (риск RCE, см. https://grahamhelton.com/blog/nodes-proxy-rce)
  # Требуется K8s >= 1.33 (KEP-2862 fine-grained kubelet authz)
  - nodes/pods
  - nodes/spec
  - nodes/stats
  verbs:
  - get

otel-operator-rbac.yaml:

# До
- apiGroups: [""]
  resources:
  - nodes/proxy # <- риск RCE
  verbs:
  - get
# После
# nodes/pods заменяет nodes/proxy (риск RCE, см. https://grahamhelton.com/blog/nodes-proxy-rce)
# Требуется K8s >= 1.33 (KEP-2862 fine-grained kubelet authz)
- apiGroups: [""]
  resources:
  - nodes/pods
  verbs:
  - get

Превентивные меры

KEP-2862: гранулярная авторизация Kubelet API

Как уже упоминалось, реальное долгосрочное решение — это KEP-2862 (Fine-Grained Kubelet API Authorization, то есть детализированная авторизация Kubelet API). Он вводит гранулярные субресурсы (nodes/pods, nodes/metrics, nodes/stats, nodes/log и др.), обеспечивающие точечный доступ без использования nodes/proxy.

Версия K8s Статус KEP-2862

1.32

Alpha

1.33

Beta, включён по умолчанию — nodes/proxy GET больше не даёт доступ к /exec

1.36

GA (принудительно включён)

Однако это потребует пройтись по КАЖДОМУ используемому чарту и проверить все задеплоенные манифесты — как сейчас, так и в будущем.

CiliumNetworkPolicy: блокировка порта Kubelet

Пока обновление K8s не выполнено, или в качестве дополнительного уровня защиты (defense-in-depth), можно заблокировать доступ к порту 10250 из уязвимых Pod с помощью NetworkPolicy (или CiliumNetworkPolicy, если вы используете Cilium в качестве CNI-плагина).

Предупреждение: это применимо только к компонентам, которым не нужен доступ к Kubelet. OTel-коллектор потенциально нуждается в нём для сбора метрик Kubelet — в таком случае выхода нет, кроме как исправить RBAC.

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: deny-kubelet-api-access
  namespace: <namespace>
spec:
  endpointSelector:
    matchLabels:
      <app-label>: <value>
  egressDeny:
  - toEntities:
    - host
    - remote-node
    toPorts:
    - ports:
      - port: "10250"
        protocol: TCP

Kyverno: блокировка создания новых Role с nodes/proxy

Чтобы предотвратить регрессию (помним, что нужно защититься и на будущее), можно добавить ClusterPolicy в Kyverno, которая будет отклонять создание или изменение ClusterRole или Role, содержащих nodes/proxy.

К счастью, на официальном сайте Kyverno есть готовые примеры.

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: restrict-nodes-proxy
spec:
  validationFailureAction: Audit # переключить на Enforce после проверки
  background: true
  rules:
  - name: deny-nodes-proxy-in-clusterroles
    match:
      any:
      - resources:
          kinds:
          - ClusterRole
          - Role
    exclude:
      any:
      - resources:
          names:
          - "system:kubelet-api-admin" # встроенный K8s, не модифицируется
    validate:
      message: >
        nodes/proxy предоставляет возможность RCE через Kubelet WebSocket exec.
        Используйте nodes/pods (требуется K8s >= 1.33, KEP-2862).
      deny:
        conditions:
          any:
          - key: "nodes/proxy"
            operator: AnyIn
            value: "{{ request.object.rules[].resources[] }}"

Развёртывание выполняется в два этапа: сначала в режиме Audit — чтобы убедиться в отсутствии оставшихся уязвимых манифестов (их нужно исправить перед блокировкой), затем в режиме Enforce — для реальной блокировки.

Мониторинг журнала аудита

Даже если команды, выполненные через Kubelet, не оставляют следов, можно отслеживать SubjectAccessReview для обнаружения попыток перечисления (enumeration) разрешений nodes/proxy.

Конфигурация в политике аудита Kubernetes:

# audit-policy.yaml
- level: Request
  verbs: ["create"]
  resources:
  - group: "authorization.k8s.io"
    resources: ["subjectaccessreviews"]

Затем алерт в Prometheus/Alertmanager на SAR (SubjectAccessReview), связанные с nodes/proxy:

# Обнаружение SAR, нацеленных на nodes/proxy
increase(
  apiserver_audit_event_total{
    verb="create",
    resource="subjectaccessreviews"
  }[5m]
) > 0
© 2026 meganuke