Назад к блогу

Один YAML до захвата GCP-организации: как Kubernetes становится вектором эскалации

Один YAML до захвата GCP-организации: как Kubernetes становится вектором эскалации

Исследование показывает, как один Kubernetes-манифест с объектом Config Connector способен выдать злоумышленнику права владельца всей организации Google Cloud — без единого скомпрометированного GCP-ключа. Разбор вскрывает фундаментальный конфликт двух независимых систем авторизации: Kubernetes RBAC и Google Cloud IAM принимают решения изолированно и не видят цепочку привилегий целиком, порождая классическую confused deputy problem. Материал будет полезен инженерам платформ и специалистам по безопасности, которые настраивают Workload Identity и декларативное управление облачной инфраструктурой из кластера.

Манифест, который создаёт объект Kubernetes, может выдать роль roles/owner на всю организацию Google Cloud. Это не ошибка в одной системе, а следствие того, что две системы авторизации — Kubernetes RBAC и Google Cloud IAM — принимают решения независимо и ни одна из них не видит цепочку целиком. Разберём механику: как устроена защита Kubernetes от эскалации, где именно она перестаёт работать и что видит клиент в момент применения манифеста.

Два независимых слоя авторизации

В одном YAML-манифесте объединяются объект Kubernetes RBAC (ClusterRoleBinding) и объект Config Connector (IAMPolicyMember). Config Connector нужен, чтобы описывать изменения в Google Cloud декларативно, из кластера. Объект IAMPolicyMember описывает желаемое изменение IAM в Google Cloud: он содержит member (кого назначают), role (какую роль дают) и resourceRef (на какой ресурс). Вредоносный вариант указывает member как serviceAccount:[email protected], role как roles/owner и resourceRef с kind: Organization и external: "123456789", то есть выдаёт злоумышленнику роль владельца на всю организацию GCP.

Атакующий применяет этот YAML через Kubernetes. Config Connector читает его и запрашивает изменение IAM у Google Cloud от имени своего сервисного аккаунта, у которого есть на это разрешение, поэтому запрос принимается, и атакующий получает контроль над организацией, не имея учётных данных Google Cloud.

Эскалация возникает из-за двух раздельных систем авторизации. Kubernetes RBAC проверяет лишь, разрешено ли пользователю создавать ресурс IAMPolicyMember в пространстве имён, и ничего не знает о Google Cloud. Google Cloud IAM проверяет лишь, есть ли у сервисного аккаунта Config Connector право установить эту привязку, и не знает, какой пользователь Kubernetes инициировал запрос. Это confused deputy problem: Config Connector обладает широкими полномочиями и выполняет инструкции менее полномочных пользователей, не проверяя, должны ли они иметь к ним доступ.

Уязвимая конфигурация сочетает сервисный аккаунт Config Connector с правами IAM на уровне организации и широкий доступ на запись в управляемые Config Connector пространства имён. По отдельности каждое условие управляемо, но вместе они создают путь повышения привилегий, не требующий учётных данных Google Cloud.

Как KCC аутентифицируется в Google Cloud

KCC аутентифицируется в Google Cloud через Workload Identity, используя Google service account (KCC GSA), контролируемую командой платформы. Этот аккаунт может иметь широкие роли, такие как role/owner или roles/resourcemanager.organizationAdmin, поскольку KCC управляет инфраструктурой в нескольких проектах, папках или всей организации.

Когда разработчик отправляет ресурс, KCC подхватывает его и вызывает Google Cloud IAM, создавая разрешение, при этом разработчик никогда не касается учётных данных Google Cloud. Все пространства имён используют единую организационную идентичность KCC, и KCC выполняет каждую операцию в Google Cloud через свой собственный service account, независимо от того, какой пользователь Kubernetes отправил ресурс. Если у аккаунта есть разрешения на уровне организации, пользователь с ограниченным доступом к кластеру может косвенно воспользоваться этими разрешениями.

Механика Kubernetes RBAC: как создаётся ClusterRoleBinding

Команда kubectl create clusterrolebinding строит объект ClusterRoleBinding пошагово. Обязательным флагом является только --clusterrole: он регистрируется в наборе флагов команды, после чего помечается как обязательный. Флаги --user, --group, --serviceaccount и --field-manager добавляются как необязательные; отдельно регистрируются опции аннотации применения, валидации и dry-run, значения которых затем читаются при обработке команды.

В основном проходе объект создаётся функцией createClusterRoleBinding, затем добавляется аннотация последнего применения, если соответствующий флаг включён. Создание на сервере выполняется, только если стратегия dry-run не равна DryRunClient; при DryRunClient создание на сервере пропускается. В параметры создания передаются FieldManager и директива валидации, а DryRun=[metav1.DryRunAll] устанавливается только при DryRunServer. В конце результат печатается.

Сам объект формируется с RoleRef на ClusterRole, а пользователи, группы и serviceaccounts добавляются в Subjects. Serviceaccount при этом должен иметь формат namespace:name.

Canonicalize ничего не нормализует

Функция Canonicalize в strategy.go для ClusterRoleBinding не выполняет никакой нормализации полей: она лишь приводит переданный объект к типу *rbac.ClusterRoleBinding и отбрасывает результат. Никакие поля объекта не изменяются и не нормализуются до записи в etcd. Тело функции состоит из одного приведения типа с присваиванием в пустой идентификатор.

Где вызывается проверка на предотвращение эскалации

NewStorage в пакете policybased создаёт обёртку над обычным хранилищем ClusterRoleBinding, которая предотвращает повышение привилегий: она принимает нижележащее хранилище, авторизатор и преобразователь правил. Проверка выполняется не в самом NewStorage, а в методах Create и Update этой обёртки.

В Create сначала проверяется, разрешена ли эскалация — если да, вызов сразу делегируется нижележащему хранилищу. Затем проверяется, явно ли пользователь уполномочен связывать эту роль — если да, создание тоже делегируется. Если обе проверки не прошли, ссылка на роль конвертируется, правила роли получаются через преобразователь правил, и вызывается ConfirmNoEscalation; при ошибке возвращается Forbidden, иначе создание делегируется.

В Update аналогичная логика обёрнута в rest.WrapUpdatedObjectInfo: сначала пропускаются изменения только GC-полей, затем проверяется авторизация на связывание роли, затем выполняются те же конвертация, получение правил и ConfirmNoEscalation.

Защита от эскалации внутри Kubernetes: ConfirmNoEscalation

ConfirmNoEscalation берёт пользователя и namespace из контекста запроса, собирает его собственные правила через RulesFor и проверяет, покрывают ли они запрашиваемые правила с помощью validation.Covers; если нет — формирует ошибку с перечнем недостающих прав.

GetRoleReferenceRules резолвит ссылку на роль по полю roleRef.Kind: для Role берёт Role в namespace биндинга и возвращает его правила, для ClusterRole берёт ClusterRole и возвращает его правила, иначе возвращает ошибку о неподдерживаемом виде ссылки на роль.

Функция appliesTo перебирает Subjects биндинга и возвращает индекс первого, который подходит пользователю. appliesToUser сравнивает: для UserKind — имя пользователя с именем subject, для GroupKind — наличие имени subject среди групп пользователя, для ServiceAccountKind — совпадение через serviceaccount.MatchesUsername с namespace subject или namespace биндинга.

В storage.go создание ClusterRoleBinding отклоняется, если не разрешён escalate, не авторизован bind, и ConfirmNoEscalation вернул ошибку — тогда возвращается Forbidden. То есть биндинг отклоняется, когда у пользователя нет всех прав из резолвнутой роли и нет явной авторизации на bind.

Чем можно обойти проверку

Обойти ConfirmNoEscalation позволяет явное разрешение на глагол escalate для ресурсов roles или clusterroles в API-группе rbac.authorization.k8s.io. Это описано в разделе «Restrictions on role binding creation or update», потому что там же изложены ограничения на создание и обновление привязок ролей: привязку можно создать, только если у пользователя уже есть все права из роли, либо если ему разрешён глагол bind на конкретную роль.

Глагол bind, как и escalate, позволяет обойти встроенные защиты от повышения привилегий, давая создавать привязки к ролям с правами, которых у пользователя нет. В примере ClusterRole role-grantor право bind выдаётся с resourceNames: ["admin","edit","view"], что ограничивает привязку только этими ролями, а комментарий «omit resourceNames to allow binding any ClusterRole» показывает, что отсутствие resourceNames снимает это ограничение.

Поведение при удалении объекта во время reconcile

При запуске reconcile сначала выполняется Get ожидаемого binding; если объект не найден, результат сразу формируется как ReconcileCreate со всеми ожидаемыми субъектами в MissingSubjects. Если объект существует, computeReconciledRoleBinding сравнивает roleRef: при различии возвращается ReconcileRecreate, иначе копия existing с merge аннотаций и меток и вычислением MissingSubjects/ExtraSubjects.

При ReconcileRecreate сначала вызывается Delete с UID-предусловием; если Delete вернул NotFound, объект уже отсутствует, и выполняется переход к Create. При ReconcileCreate вызов Create может вернуть AlreadyExists, что означает, что объект появился заново между Get и Create; тогда reconcile запускается повторно. Аналогично при ReconcileUpdate ошибка NotFound (объект удалён после начала reconcile) приводит к повторному запуску. Повторные запуски ограничены: если число попыток превышает 3, возвращается ошибка «exceeded maximum attempts», что не даёт бесконечно пересоздавать binding, который появляется и исчезает.

Дефолтные роли и агрегация: где прячется cluster-admin

Функция ClusterRoleBindings возвращает срез объектов ClusterRoleBinding — это привязки, которые связывают предопределённые ClusterRole с группами или пользователями. Каждая привязка создаётся вызовом конструктора для имени роли с указанием групп, пользователей или сервисных аккаунтов, после чего собирается объект ClusterRoleBinding.

Полный доступ суперпользователя даёт привязка роли cluster-admin к группе system:masters — встроенной группе Kubernetes, члены которой обладают неограниченными правами в кластере. Остальные привязки дают ограниченные права: system:monitoring — группе MonitoringGroup, system:discovery и system:basic-user — всем аутентифицированным, system:public-info-viewer — всем аутентифицированным и неаутентифицированным, system:node-proxier — пользователю KubeProxy, system:kube-controller-manager — пользователю KubeControllerManager, system:kube-dns — сервисному аккаунту kube-dns в kube-system, system:kube-scheduler и system:volume-scheduler — пользователю KubeScheduler. Дополнительно добавляется привязка system:service-account-issuer-discovery к группе AllServiceAccountsGroup, а при включённом ClusterTrustBundle — system:cluster-trust-bundle-discovery к той же группе.

Функция ClusterRolesToAggregate возвращает карту соответствия старых имён кластерных ролей новым агрегируемым: admin → system:aggregate-to-admin, edit → system:aggregate-to-edit, view → system:aggregate-to-view. Функция ClusterRoleBindingsToSplit перебирает все привязки и для привязки с именем system:public-info-viewer помещает её в карту под ключом system:discovery, чтобы скопировать Subjects, Annotations и Labels в шаблон назначения.

Как работает агрегация ClusterRole

Агрегированный ClusterRole — это объект, у которого задано поле aggregationRule; оно содержит селектор меток, по которому контроллер плоскости управления находит другие ClusterRole и объединяет их правила в поле rules этого объекта. Контроллер, работающий в составе плоскости управления, следит за ClusterRole с установленным aggregationRule и использует этот селектор для сопоставления объектов, которые должны быть объединены.

Плоскость управления перезаписывает любые значения, вручную указанные в поле rules агрегированной роли, поэтому менять или добавлять правила нужно в тех ClusterRole, которые выбираются селектором. Когда создаётся новый ClusterRole, чьи метки совпадают с селектором существующей агрегированной роли, это событие запускает добавление новых правил в агрегированную роль. Например, создание ClusterRole monitoring-endpointslices с меткой rbac.example.com/aggregate-to-monitoring: "true" приводит к тому, что его правила добавляются в ClusterRole monitoring.

Auto-reconciliation дефолтных ролей

При старте API-сервер создаёт набор дефолтных ClusterRole и ClusterRoleBinding, помеченных kubernetes.io/bootstrapping=rbac-defaults; среди них есть пользовательские роли: cluster-admin (биндится на группу system:masters), а admin, edit, view создаются без биндинга.

При каждом старте API-сервер выполняет auto-reconciliation: он добавляет отсутствующие разрешения в дефолтные ClusterRole и отсутствующих субъектов в дефолтные ClusterRoleBinding, что позволяет исправлять случайные изменения. Отключить это можно, установив аннотацию rbac.authorization.kubernetes.io/autoupdate в false на дефолтной роли или биндинге.

Функция computeReconciledRoleBinding сравнивает существующий и ожидаемый биндинг: если roleRef отличается, биндинг полностью пересоздаётся; иначе аннотации и метки сливаются, а субъекты либо добавляются (при removeExtraSubjects=false), либо полностью заменяются на ожидаемые (при removeExtraSubjects=true).

Мост в GCP: как KCC превращает Kubernetes-объект в IAM-изменение

KCC читает манифест IAMPolicyMember и от имени собственного сервисного аккаунта (KCC GSA) обращается к Google Cloud IAM, чтобы создать привязку роли. Scope привязки задаётся в spec.resourceRef: поля kind и external указывают, к какому ресурсу применяется роль — например, kind: Project с external: "my-project" или kind: Organization с external: "123456789". Член привязки задаётся полем spec.member, а назначаемая роль — полем spec.role.

KCC аутентифицируется в Google Cloud через Workload Identity, используя сервисный аккаунт, который может иметь широкие права, включая roles/owner или roles/resourcemanager.organizationAdmin.

Условия IAM, ограничивающие область действия

В binding можно использовать два условия IAM: ограничение по времени через request.time < timestamp('TIMESTAMP') и ограничение по тегу ресурса через resource.matchTag('TAG_KEY', 'TAG_VALUE').

Binding без таких ограничений становится organization-wide потому, что KCC выполняет запрос от своего service account, а Google Cloud проверяет только права этого service account, не зная, какой пользователь Kubernetes инициировал запрос. В примере атаки resourceRef указывает на kind: Organization с external: "123456789", а роль — roles/owner, что и даёт контроль над организацией.

Почему одного YAML достаточно: разрыв между двумя системами авторизации

Проверка ConfirmNoEscalation в Kubernetes работает только с RBAC-правилами: она получает правила пользователя через RulesFor и сравнивает их с запрашиваемыми правилами через validation.Covers, то есть проверяет лишь то, покрывают ли текущие RBAC-права пользователя выдаваемые им RBAC-права. Она не обращается к Google Cloud IAM и не знает о правах KCC-сервис-аккаунта на уровне организации.

KCC же выполняет операцию в Google Cloud через собственный сервис-аккаунт, и Google Cloud IAM проверяет только его права, а не Kubernetes-пользователя, инициировавшего запрос. Поэтому Kubernetes видит только создание ресурса внутри кластера, а Google Cloud — только сервис-аккаунт KCC, и ни одна из систем не проверяет всю цепочку целиком.

Документация не делает связь между двумя решениями очевидной, потому что администраторы обычно рассматривают их по отдельности: какие типы Kubernetes-ресурсов может создавать команда и что может делать сервисный аккаунт KCC в Google Cloud. Однако в KCC эти решения связаны: разрешение команде создавать ресурс IAMPolicyMember может дать этой команде способ использовать полномочия KCC в Google Cloud.

Вне поля зрения RBAC остаются шаги: атакующий применяет YAML через Kubernetes, KCC читает его и запрашивает изменение IAM в Google Cloud от имени своего сервисного аккаунта, обладающего нужным разрешением, после чего запрос принимается. В результате KCC может получить запрос от пользователя с ограниченным доступом и выполнить его с полномочиями, которых у этого пользователя в Google Cloud нет. Google называет такое поведение работой по проекту, поскольку администратор сам выбрал выдать KCC сервисный аккаунт уровня организации и разрешить разработчикам создавать IAMPolicyMember в управляемых KCC пространствах имён.

Что видит клиент и как это воспроизвести

При применении манифеста, создающего ClusterRoleBinding, API-сервер сначала проверяет, разрешена ли эскалация: если да, объект создаётся напрямую через хранилище. Иначе проверяется авторизация на привязку роли, и при её отсутствии резолвятся правила роли и вызывается ConfirmNoEscalation. Если прав пользователя не хватает, возвращается ошибка Forbidden с сообщением о попытке выдать неподдерживаемые RBAC-права.

Успешная эскалация отличается тем, что запрос проходит без ошибки, а отклонённая — статусом Forbidden и текстом вида «is attempting to grant RBAC permissions not currently held».

Проверка защиты ConfirmNoEscalation выполняется при создании или обновлении RBAC-объектов: функция получает правила из запроса и сравнивает их с правами пользователя, возвращая ошибку, если прав не хватает. Сначала она извлекает пользователя из контекста и возвращает ошибку, если его там нет, затем получает правила пользователя через RulesFor и проверяет покрытие через validation.Covers.

Воспроизвести шаги можно через kubectl auth reconcile с манифестом RBAC-объектов и флагом --dry-run=client, который показывает изменения без применения. Для привязки ClusterRole ко всему кластеру используется kubectl create clusterrolebinding, который выдаёт разрешения ClusterRole во всех пространствах имён. Защита сработает, если у пользователя нет выдаваемых прав и нет явного разрешения на глагол escalate для roles или clusterroles, либо глагола bind для привязываемой роли.

Снижение риска: ограничение обеих сторон

На стороне Kubernetes защита строится на встроенных проверках RBAC: создать или обновить привязку роли можно только если у пользователя уже есть все права из этой роли в том же масштабе, либо ему явно разрешён глагол bind на эту роль. Аналогично для создания ролей: пользователь не сможет включить в ClusterRole права, которых у него самого нет, если ему не выдан глагол escalate.

Поэтому выдачу bind и escalate следует ограничивать, так как bind позволяет обойти встроенные защиты от повышения привилегий. От использования wildcards в правилах следует отказаться, поскольку они дают избыточные права на чувствительные ресурсы.

На стороне GCP нужно ограничить права сервисного аккаунта KCC: проверить его роли на уровнях проекта, папки и организации и убрать широкие роли, такие как roles/owner и roles/resourcemanager.organizationAdmin, если они не строго необходимы. Также следует ограничить, кто может создавать ресурсы IAMPolicyMember, IAMPolicy и IAMPartialPolicy — это должно быть доступно только одобренным платформенным или инфраструктурным командам.

Про VPC Service Controls сказано, что они действуют в пределах периметра, но не обеспечивают контроль доступа для межпериметровых запросов на основе федеративных идентичностей.

Как это соотносится с общими рекомендациями по RBAC

Рекомендации из rbac-good-practices описывают, как избыточные RBAC-права внутри кластера позволяют обойти границы доверия, а разобранный случай показывает конкретный пример, когда такое избыточное право в Kubernetes превращается в захват GCP-организации.

Так, право patch на Namespace позволяет менять метки, что при использовании Pod Security Admission даёт настроить более мягкую политику, а при NetworkPolicy — открыть доступ к сервисам; аналогично именно право создавать IAMPolicyMember в KCC-управляемом namespace запускает цепочку. Ключевая параллель — отсутствие проверки полной цепочки: RBAC проверяет только право создать IAMPolicyMember в namespace, а Google Cloud IAM проверяет только права сервисного аккаунта KCC, из-за чего возникает confused deputy problem.

Рекомендация ограничивать создание PersistentVolume и доступ к nodes/proxy (который даёт права на Kubelet API и обходит audit logging) по духу совпадает с чек-листом: ограничить и то, что может сервисный аккаунт KCC в GCP, и то, какие пользователи Kubernetes могут отправлять ему ресурсы. В обоих источниках вывод один: границы внутри namespace считаются слабыми, поэтому нужно следовать least privilege и ограничивать обе стороны авторизации.

Что из этого следует на практике

  • Разделение ответственности между Kubernetes RBAC и Google Cloud IAM — не гарантия безопасности, а источник разрыва: каждая система проверяет только свою часть цепочки, и ни одна не видит её целиком.
  • Право создавать IAMPolicyMember в управляемом KCC пространстве имён равносильно праву использовать все полномочия KCC GSA в Google Cloud — включая права уровня организации, если они есть.
  • Встроенная защита Kubernetes от эскалации (ConfirmNoEscalation) работает только против RBAC-эскалации внутри кластера и не может ничего проверить за его пределами.
  • Глаголы bind и escalate снимают встроенные защиты RBAC, поэтому их выдача должна быть ограничена; отсутствие resourceNames при выдаче bind позволяет привязывать любую ClusterRole.
  • Агрегация ClusterRole означает, что создание нового ClusterRole с подходящей меткой автоматически расширяет права уже существующей агрегированной роли — это ещё один путь непрямого расширения прав.
  • Смягчение требует действий с обеих сторон: ограничить роли KCC GSA на уровнях проекта, папки и организации и одновременно ограничить круг пользователей Kubernetes, которые могут отправлять KCC ресурсы.

Где смотреть в коде

Источники

Похожее