Краткое резюме
Тема уже не совсем свежая, но я всё равно хотел написать разбор.
В январе исследователь в области кибербезопасности опубликовал информацию об уязвимости в 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) которого представляет собой HTTPGET -
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 |
|
|
OpenTelemetry Operator |
|
|
Rook-Ceph |
|
|
Примечание: таких компонентов гораздо больше. Грэм Хелтон добавил в конец своей статьи раздел «Приложение: затронутые 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, включён по умолчанию — |
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
Ссылки
-
Kubernetes RCE via nodes/proxy GET — Graham Helton
-
rook/rook#16979 — исправление в upstream Rook-Ceph
-
open-telemetry/opentelemetry-helm-charts#2083 — исправление в upstream OTel