Ingress-nginx обновлён. Как понять, успели ли украсть Secrets
Контроллер обновили, Pod пересоздался, сканер успокоился. А пароль production-базы мог уйти вчера. Я, Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш», разбираю, как сисадмину восстановить окно риска, отличить подозрение от доказанного доступа и составить план ротации, который не остановит бизнес.
1. Патч исправляет контроллер, но не возвращает украденные ключи
Моя позиция простая: обновление и расследование — две отдельные задачи. CVE-2025-1974 позволяла атакующему без Kubernetes-учётки, при сетевой доступности уязвимого admission webhook, добиться исполнения кода в контексте ingress-nginx. Дальнейший ущерб зависел от доступных контроллеру секретов и полномочий. Публичный сайт на 443 сам по себе ещё не означает открытый webhook; зато заражённого приложения внутри Pod-сети могло хватить. [Описание Kubernetes SRC](https://kubernetes.io/blog/2025/03/24/ingress-nginx-cve-2025-1974/).
Уязвимы версии контроллера ниже 1.11.0, версии 1.11.0–1.11.4 и 1.12.0. Первые исправления вышли 24 марта 2025 года: 1.11.5 и 1.12.1. Проверяйте именно запущенный controller image и digest: номер Helm chart — другая величина, а успешный upgrade не гарантирует, что старая реплика исчезла. Официальный advisory не предлагает однозначного индикатора эксплуатации. Это ограничение диагностики, а не обещание отсутствия следов. [Advisory CVE-2025-1974](https://github.com/kubernetes/kubernetes/issues/131009).
Для читателя в сентябре 2026 года есть дополнительная проблема: ingress-nginx завершил сопровождение 24 марта 2026 года. Поэтому я рассматриваю 1.11.5 и 1.12.1 как исторические исправления конкретной CVE, а не рекомендуемые сегодня версии. Расследование нужно довести до конца, одновременно готовя переход на сопровождаемый контроллер. Сам объект Gateway API программное обеспечение не заменяет: ему тоже нужна реализация. [Подтверждение завершения сопровождения](https://kubernetes.io/blog/2026/03/30/kubernetes-v1-36-sneak-peek/), [заявление комитетов Kubernetes](https://kubernetes.io/blog/2026/01/29/ingress-nginx-statement/).
2. Сначала восстанавливаю окно риска и реальные права
Я начинаю с временной шкалы: когда появился уязвимый образ, был ли включён webhook, откуда он был достижим и когда остановилась последняя уязвимая реплика. Дата публикации CVE не равна началу риска. Поднимаю GitOps-историю, ревизии Helm, старые манифесты и сетевые правила. Сегодняшняя NetworkPolicy ничего не доказывает о прошлом месяце. Если историю восстановить нельзя, прямо фиксирую неизвестную нижнюю границу периода.
В стандартной установке admission Service принимает 443 и направляет запросы на 8443 контейнера controller. Но имена и порты могли изменить. Нужный ServiceAccount беру из spec.serviceAccountName именно контроллера: аккаунт admission Job для сертификатов — другая сущность. Быстрая инвентаризация ниже помогает найти кандидатов; при изменённых labels дополнительно проверяю workloads и образы. [Официальный манифест v1.12.0](https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.12.0/deploy/static/provider/cloud/deploy.yaml). ```bash kubectl get pods -A -l app.kubernetes.io/name=ingress-nginx \ -o custom-columns='NS:.metadata.namespace,POD:.metadata.name,SA:.spec.serviceAccountName,IMAGE:.spec.containers[*].image,IMAGE_ID:.status.containerStatuses[*].imageID' kubectl get validatingwebhookconfigurations kubectl -n ingress-nginx get svc ingress-nginx-controller-admission -o yaml ```
Самая неприятная ловушка — проверить только get secrets. В штатном ClusterRole v1.12.0 для Secrets есть list/watch, позволяющие получать содержимое объектов по всему кластеру. Поэтому ответ no на get не успокаивает. Ниже пример с типовым аккаунтом и его группами; нужны права impersonation. При отрицательном результате для всего кластера проверяю отдельные namespaces, RoleBinding и ClusterRoleBinding. Затем повторяю анализ для исторического RBAC. [Шаблон ClusterRole](https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.12.0/charts/ingress-nginx/templates/clusterrole.yaml). ```bash for verb in get list watch; do printf '%s: ' "$verb" kubectl auth can-i "$verb" secrets --all-namespaces \ --as=system:serviceaccount:ingress-nginx:ingress-nginx \ --as-group=system:authenticated \ --as-group=system:serviceaccounts \ --as-group=system:serviceaccounts:ingress-nginx done ```
3. Собираю свидетельства из трёх независимых источников
При продолжающейся подозрительной активности сначала ограничиваю доступ, параллельно сохраняю свидетельства. Не держу атакуемый Pod открытым ради идеального снимка. До пересоздания по возможности забираю логи, сведения о процессах, соединениях и файловой системе штатными средствами реагирования. Если обновление уже выполнено, ищу старые записи в централизованном хранилище. Логи нового Pod не расскажут, что исполнялось в удалённом.
Первый источник — сеть: кто обращался к admission endpoint, какие соединения контроллер устанавливал после этого. Второй — runtime-телеметрия: необычный дочерний процесс, запуск оболочки, загрузка библиотеки из каталога временных тел запросов. Третий — Kubernetes audit и журналы внешних систем. Я сопоставляю время в UTC, Pod UID и исторические адреса. Один сегодняшний IP без привязки к конкретному Pod легко приводит расследование к уже другому приложению.
Обычный nginx access.log не является полным журналом admission webhook. Прямой AdmissionReview может пройти мимо kube-apiserver, поэтому я не ожидаю обязательного CREATE Ingress в audit или подозрительного объекта в etcd. Это вывод из маршрута атаки, описанного исследователями. Сами по себе nginx -t, временный конфиг или ошибка валидации тоже недостаточны: они встречались при штатной работе. Нужна связанная последовательность событий. [Первичное исследование Wiz](https://www.wiz.io/blog/ingress-nginx-kubernetes-vulnerabilities).
- Сохраняю оригиналы audit со всех API server, controller/runtime-логов и сетевых событий; отмечаю пропуски, срок хранения и контрольные суммы выгрузок.
- Проверяю исходящие соединения относительно разрешённых upstream: сам факт исходящего трафика для reverse proxy нормален.
- Ищу дальнейшее закрепление: изменения RBAC, новые workloads, exec, выпуск токенов и действия уже под другими украденными учётками.
4. Читаю audit без самообмана: LIST ещё не означает кражу
Для первичной выборки использую JSONL, где одна строка — одно Kubernetes audit-событие. Обёртку облачного провайдера сначала преобразую в этот формат. Фильтр оставляет все стадии и коды ответа: не хочу потерять отказ перед успешным запросом или незавершённый watch. Имя аккаунта замените своим; фильтр — начало расследования, а не полный детектор. ```bash jq -r --arg sa 'system:serviceaccount:ingress-nginx:ingress-nginx' ' select(.user.username == $sa) | select(.objectRef.resource == "secrets") | select(.verb == "get" or .verb == "list" or .verb == "watch") | [.requestReceivedTimestamp, .stage, .auditID, .verb, (.objectRef.namespace // "-"), (.objectRef.name // "-"), (.responseStatus.code // 0), ((.sourceIPs // []) | join(",")), (.userAgent // "-"), .requestURI] | @tsv ' audit.jsonl ```
События разных стадий объединяю по auditID. Для долгого watch важен ResponseStarted: ResponseComplete может появиться значительно позже. Уровень Metadata сохраняет сведения о запросе, но не тела. HTTP 403 означает отказ этому запросу; HTTP 200 подтверждает успешную обработку, но не фиксирует в таком журнале точный набор выданных значений. Для LIST проверяю namespace, selectors и пагинацию в requestURI. [Механика Kubernetes audit](https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/).
У контроллера нормально видеть LIST/WATCH после старта и переподключения. Сравниваю выборку с рестартами, штатной частотой, адресами и клиентами. Новый userAgent curl вместе с обращением со стороны приложения — повод разбираться, но строку userAgent задаёт клиент; sourceIPs также требуют учёта прокси и SNAT. Ни один из этих признаков отдельно не удостоверяет злоумышленника. [Поля audit Event](https://kubernetes.io/docs/reference/config-api/apiserver-audit.v1/).
На будущее добавляю приведённое правило в начало существующего списка rules, до подходящих правил None или RequestResponse. Проверяю, что ResponseStarted не исключён через omitStages. Это фрагмент audit policy, не отдельный Kubernetes workload. Для self-managed кластера политика подключается к kube-apiserver через --audit-policy-file вместе с настроенным backend; в managed-сервисе проверяю настройки провайдера. Такой учёт не восстановит прошлое, зато не создаст в логах вторую базу паролей. [Настройка аудита](https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/). ```yaml rules: - level: Metadata resources: - group: "" resources: ["secrets"] ```
5. Ротирую по границе доступа, а не по списку тревог
В отчёте разделяю три состояния. Первое: вход был доступен, признаков исполнения кода нет, но телеметрия неполна. Второе: есть убедительные признаки RCE в контроллере. Третье: подтверждено постороннее использование учётных данных или получение конкретных значений. При втором состоянии я считаю доступные контроллеру секреты потенциально раскрытыми и начинаю ротацию, не дожидаясь доказанной отправки файла наружу. При первом решение зависит от полноты наблюдения и цены утечки; для критичных ключей я выбираю осторожный вариант.
При cluster-wide чтении инвентаризирую доступные в период риска Secrets всех namespaces. Включаю удалённые позднее объекты, старые значения, которые ещё действуют, и секреты Helm-релизов: сохранённые values тоже могут содержать credentials. Одинаковый пароль в пяти объектах — одна внешняя учётка с пятью потребителями. В реестре нужны владелец, назначение, полномочия, копии, порядок переключения и способ проверки отзыва. Печатать значения в общий тикет не нужно.
Сначала останавливаю повторный доступ и убираю закрепление, иначе новые ключи могут снова утечь. Ротацию выполняю с доверенного рабочего места: выпускаю новые credentials у источника, обновляю потребителей, проверяю работу, отзываю старые и проверяю отказ. Перезапуск приложения особенно важен при передаче через env. Простое изменение Kubernetes Secret не меняет пароль в PostgreSQL и не отзывает API-токен у провайдера. Не допускаю и отката, который возвращает старый рабочий пароль.
Отдельно разбираю ServiceAccount-токены. Удаление исходного Pod инвалидирует привязанный к нему projected token для Kubernetes API с предусмотренной задержкой; legacy-токен в Secret от рестарта не исчезает. Его отзываю удалением соответствующего token Secret после подготовки потребителей. Внешний сервис, проверяющий JWT офлайн, может продолжать принимать токен до exp. Замена ingress Pod также не отзывает токены других аккаунтов и внешние сессии. [Управление ServiceAccount](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/).
- Первая очередь: административные kubeconfig и токены, облачный IAM, CI/CD, ключи подписи и доступ к хранилищу резервных копий.
- Следом: базы данных, очереди, registry, объектное хранилище и интеграции. Очерёдность уточняю по реальным полномочиям и доступности извне.
- TLS: при доступности приватного ключа выпускаю новую ключевую пару и сертификат, организую отзыв старого там, где он поддерживается. Продление с прежним ключом проблему не решает.
- Корневую CA кластера не меняю автоматически: сначала устанавливаю доступность её приватного ключа или компрометацию control plane. Наличие обычного ca.crt этого не доказывает.
6. Разбор стенда «Вектор»: почему 84 Secret — не 84 пароля
Покажу ход работы на учебном сценарии для условного ООО «Вектор», производственной компании на 120 рабочих мест. Это смоделированный разбор, не отчёт о выполненном клиентском пентесте. Задаю исторический стенд марта 2025 года: Kubernetes 1.31.6, ingress-nginx 1.11.4, две реплики, 12 namespaces, 41 Ingress и 84 Secret. Три control-plane VM получают по 2 vCPU и 4 ГБ RAM, три worker — по 4 vCPU и 8 ГБ. Это параметры примера, не минимальные требования и не рекомендация версий на 2026 год.
Исходная конфигурация: controller.admissionWebhooks.enabled=true, --validating-webhook=:8443, Service 443→8443; CNI поддерживает NetworkPolicy, но ограничений для webhook нет. Контроллер использует типовой cluster-wide RBAC. В условии упражнения задаю обновление последней реплики до 1.11.5 вечером 24 марта; этому controller соответствует Helm chart 4.11.5. Версии различаю даже там, где окончания совпадают. [Chart.yaml исправленного выпуска](https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.5/charts/ingress-nginx/Chart.yaml).
В учебную временную шкалу закладываю три события: в 10:14 приложение обращается на admission endpoint; в 10:15 runtime фиксирует необычную библиотеку и дочернюю оболочку в controller; в 10:16 audit показывает успешный LIST /api/v1/secrets под его аккаунтом с непривычным клиентом. CREATE Ingress отсутствует. Я трактую эту совокупность как основание для реагирования на компрометацию контроллера, но не объявляю доказанной кражу каждого из 84 объектов или точный CVE только по этим строкам.
Развязку упражнения задаю так: реестр содержит 20 TLS Secrets, 30 прикладных с 18 уникальными внешними credentials, 10 registry Secrets с двумя общими аккаунтами, четыре legacy ServiceAccount-токена и 20 Helm-релизов. После анализа копий план включает 18 прикладных credentials, два registry-аккаунта, четыре legacy-токена и 20 TLS-ключей; дополнительные уникальные credentials в Helm по условию отсутствуют. Критичные доступы отзываются в первую смену, остальные переключаются за два окна обслуживания. Приёмка требует успешной работы приложений с новыми ключами и отказа старым. Это целевой исход сценария, а не обещание уложить любой реальный инцидент в такой срок.
7. Что считаю достаточным для закрытия инцидента
На сетевом уровне оставляю доступ к admission только необходимым источникам API server. Проверяю и Service, и прямые Pod IP, причём из нескольких прикладных namespaces. У NetworkPolicy разрешения складываются; другая allow-all политика может разрушить замысел. Для hostNetwork поведение зависит от CNI, а SNAT меняет видимый источник. Поэтому вместе с отрицательным тестом из приложения проверяю создание допустимого Ingress через API, включая действительную работу webhook. [Ограничения NetworkPolicy](https://kubernetes.io/docs/concepts/services-networking/network-policies/).
Если где-то осталась уязвимая реплика и немедленное обновление невозможно, временно отключаю admission через controller.admissionWebhooks.enabled=false с применением изменения. В ручной установке недостаточно удалить ValidatingWebhookConfiguration: нужно убрать --validating-webhook из workload и дождаться остановки listener. Иначе прямой путь может сохраниться. Это временная мера, а не замена миграции с завершившего сопровождение проекта. [Официальные действия по ограничению риска](https://kubernetes.io/blog/2025/03/24/ingress-nginx-cve-2025-1974/).
Я закрываю работу, когда понятны границы расследования, выполнен отзыв старых credentials, проверены потребители и разобраны признаки закрепления. Не требую магической справки «никто ничего не украл», если журналов не было. Требую честного остаточного риска и ответственного за него. Поиск идеальной сигнатуры можно отложить; отзыв действующего административного ключа при подтверждённом взломе — нельзя. Переход на поддерживаемый ingress фиксирую отдельной задачей с владельцем и сроком.
Частые вопросы
Можно ли доказать отсутствие утечки, если audit не собирали?
Обычно нет. Сопоставляйте сетевые, runtime- и внешние журналы; при пробелах фиксируйте неопределённость и выбирайте ротацию по доступности и критичности credentials.
LIST Secrets от ingress-nginx — уже признак атаки?
Нет, LIST/WATCH нужны контроллеру для штатной работы. Ищите отклонения от его обычного поведения и подтверждайте их независимыми событиями.
Нужно ли менять секреты namespaces без Ingress?
При компрометации контроллера с cluster-wide правами — включать их в область ротации. Отсутствие публикации приложения не ограничивает RBAC контроллера.
Перезапуск ingress-nginx отзывает все украденные токены?
Нет. Привязанный токен удалённого Pod и legacy-токены имеют разные механизмы отзыва; внешние ключи, чужие токены и созданные сессии требуют отдельных действий.
В rf-buh я предлагаю начать с разбора вашей ситуации и определения приоритетов восстановления. Обратитесь за консультацией, чтобы согласовать объём помощи и последовательность действий.
Бесплатная консультация →

